E37: migrate WordCraft to egui 0.37 with guarded IME and capture lifecycle

This commit is contained in:
skylartaylor
2026-10-24 08:30:31 +02:00
parent 14001aee65
commit fd071202e7
87 changed files with 10201 additions and 1230 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+1 -1
View File
@@ -28,4 +28,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: wordcraft
app-title: WordCraft
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
Generated
+1490 -199
View File
File diff suppressed because it is too large Load Diff
+23 -6
View File
@@ -54,7 +54,7 @@ harfrust = "0.13"
wuff = "0.2.9"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
@@ -75,10 +75,12 @@ getrandom = "0.3"
zeroize = "1"
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
# A monotonic clock that works on native and in the browser (`performance.now()`).
web-time = "1.1"
rfd = "0.17"
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
@@ -110,4 +112,19 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" }
+22 -17
View File
@@ -116,21 +116,6 @@ fn stored_prefs() -> Option<String> {
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
for msg in msgs {
self.app.status(msg);
@@ -149,11 +134,31 @@ impl eframe::App for WebShell {
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Browser drops are input: take them only in the first real UI pass, never in a hidden
// `run_logic` tick that still sees the previous shown frame's RawInput.
if ui.ctx().current_pass_index() == 0 {
let ctx = ui.ctx().clone();
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
self.app.ui(ui);
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ wordcraft-fonts = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The control key file's location (shared with the MCP bridge).
wordcraft-control-key = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" }
rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
+18 -9
View File
@@ -27,39 +27,44 @@ mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
window_geometry::{WindowGeometry, take_rescue},
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
};
/// The app, the restored window geometry (or, with none, the default: no position) until the
/// first frame that knows the monitor has checked it, the control server's key file (removed on
/// exit), the file dialogs the app asked for and, on macOS, the documents opened from Finder.
/// exit), the file dialogs the app asked for, this frame's raw host window state and, on macOS,
/// the documents opened from Finder.
struct App(
WordApp,
Option<WindowGeometry>,
Option<control_server::KeyFile>,
file_dialogs::Launcher,
// The host's answer for this frame. `InputState` still describes the last painted frame on
// a hidden logic tick.
RawWindowState,
#[cfg(target_os = "macos")] fmv_macos_events::Inbox,
);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
apple_events::poll(&self.4, &mut self.0, ctx);
apple_events::poll(&self.5, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
let window = self.4;
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) {
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
let prev = self.0.ui.window;
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
self.0.ui.window = WindowGeometry::track(prev, &window);
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
// window that opened at its normal size so it never flashed on screen half-opened.
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());
if let Some(pos) = take_rescue(&mut self.1, &window) {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
if let Some(command) = maximize {
@@ -85,8 +90,11 @@ impl eframe::App for App {
}
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// Keep this frame's native window state. `logic` must not read the last painted
// `InputState` when a hidden Wayland tick reports no fresh inner rect.
self.4 = RawWindowState::from_raw(raw);
self.0.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.0.ui(ui);
@@ -480,6 +488,7 @@ fn main() -> eframe::Result {
Some(restored.unwrap_or_default()),
key_file,
dialogs,
RawWindowState::default(),
#[cfg(target_os = "macos")]
apple_events.connect(&cc.egui_ctx),
)))
+4 -1
View File
@@ -5,7 +5,10 @@ pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let printers = craft_print::printers()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
let _ = sender.send(printers);
});
receiver
+1 -1
View File
@@ -1 +1 @@
04600cbb1508f1b15927ec574a5e32e5f376fd8b
e5bfe19b95d30f50f250985bdc3aeec47c05af09
+694
View File
@@ -0,0 +1,694 @@
//! Visible logical text for IME surrounding-text queries.
//!
//! The platform input method works on what the reader sees, not on the stored run text: tracked
//! deletions are hidden, and an inline object stands for its plain-text result. The helpers here
//! build a bounded window around a selection without cloning the paragraph or the document, and
//! map both ends in both directions so a query and a delete use exactly the same text.
use std::ops::Range;
use crate::para::{OBJ, Paragraph};
use crate::{Document, Pos};
/// A bounded visible-logical window around a selection.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeWindow {
/// At most [`MAX_IME_TEXT_BYTES`] bytes of visible committed text.
pub text: String,
/// UTF-8 byte offset of the primary end in [`Self::text`].
pub cursor: usize,
/// UTF-8 byte offset of the other end in [`Self::text`].
pub anchor: usize,
}
/// The largest visible window an IME query copies.
pub const MAX_IME_TEXT_BYTES: usize = 4000;
/// Which unit an input method's delete-surrounding counts use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeleteUnit {
/// Unicode scalar values of the visible text.
Chars,
/// UTF-8 bytes of the visible text.
Bytes,
}
/// A deletion request in the paragraph's stored byte offsets.
///
/// The selection itself is never part of `before` or `after`; both ranges are outside it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeDelete {
/// Stored byte range to erase before the selection.
pub before: Option<Range<usize>>,
/// Stored byte range to erase after the selection.
pub after: Option<Range<usize>>,
/// Stored byte offset of the selection's anchor **before** the deletions.
///
/// The ranges are returned in the paragraph's stored text as it is now; the caller applies
/// them (and shifts these offsets past `before`) itself.
pub anchor: usize,
/// Stored byte offset of the selection's focus **before** the deletions.
pub focus: usize,
}
/// One paragraph's visible logical text.
///
/// Deleted runs are skipped and inline objects contribute their plain-text result. Iteration is
/// lazy: no full visible copy is made.
pub struct VisibleParagraph<'a> {
para: &'a Paragraph,
deleted: Vec<Range<usize>>,
}
impl<'a> VisibleParagraph<'a> {
/// A view of `para` with tracked deletions hidden.
pub fn new(para: &'a Paragraph) -> Self {
let mut deleted = para.deleted_ranges();
deleted.sort_unstable_by_key(|r| r.start);
Self { para, deleted }
}
/// Whether `off` falls inside a tracked deletion, advancing `next` past the runs that end
/// before it.
///
/// The ranges are sorted, so one monotone cursor makes a whole scan O(text + ranges) instead
/// of O(text × ranges): alternating tracked revisions used to make every character scan every
/// range.
fn is_deleted_at(&self, next: &mut usize, off: usize) -> bool {
while let Some(r) = self.deleted.get(*next) {
if r.end > off {
break;
}
*next = next.saturating_add(1);
}
self.deleted.get(*next).is_some_and(|r| r.start <= off && off < r.end)
}
/// Call `f` for every visible piece: `(stored byte start, piece, stored byte len)`.
fn for_each_piece(&self, mut f: impl FnMut(usize, &str, usize)) {
self.for_each_piece_while(|start, piece, stored| {
f(start, piece, stored);
true
});
}
/// Call `f` for visible pieces until it returns `false`.
fn for_each_piece_while(&self, mut f: impl FnMut(usize, &str, usize) -> bool) {
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let keep_going = if is_object {
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
f(i, piece, OBJ.len_utf8())
} else {
let mut buf = [0u8; 4];
f(i, c.encode_utf8(&mut buf), c.len_utf8())
};
if !keep_going {
break;
}
}
}
/// Total visible UTF-8 bytes.
pub fn visible_len(&self) -> usize {
let mut len = 0usize;
self.for_each_piece(|_, piece, _| len = len.saturating_add(piece.len()));
len
}
/// Visible offset of a stored paragraph byte offset.
///
/// `None` when the stored offset is not a character boundary, points inside a tracked
/// deletion, or would split an inline object's plain-text result.
pub fn visible_offset(&self, stored: usize) -> Option<usize> {
if stored > self.para.text.len() || !self.para.text.is_char_boundary(stored) {
return None;
}
let mut visible = 0usize;
let mut found = false;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
if i == stored {
found = true;
break;
}
if i > stored {
break;
}
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
// A position at the start of a deleted run is still a visible position; one
// inside it is not.
continue;
}
if is_object {
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
visible = visible.saturating_add(piece.len());
} else {
visible = visible.saturating_add(c.len_utf8());
}
}
if !found {
// The end of the paragraph, or a deleted run at its end.
let mut next = 0usize;
return (!self.is_deleted_at(&mut next, stored)).then_some(visible);
}
if stored < self.para.text.len() && self.is_deleted_at(&mut deleted, stored) {
// The start of a deleted run is still a visible caret position; only a position
// strictly inside one is unrepresentable.
let at_start = self.deleted.iter().any(|r| r.start == stored);
return at_start.then_some(visible);
}
Some(visible)
}
/// Stored paragraph byte offset for a visible offset.
///
/// `None` when the visible offset falls inside an inline object's plain-text result or is
/// not a boundary of the visible representation.
pub fn stored_offset(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut answer = None;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible == acc {
answer = Some(i);
break;
}
if visible < acc.saturating_add(piece_len) {
// Inside a character (or an object's expansion): no stored boundary there.
return None;
}
acc = acc.saturating_add(piece_len);
}
answer.or_else(|| (visible == acc).then_some(self.para.text.len()))
}
/// Stored offset for the **start** of a visible range, skipping zero-length objects at
/// the same boundary.
///
/// A content control's markers are inline objects with no plain text, so they share a
/// visible offset with the content just inside (or after) them. A range start must map
/// after a control-start marker and after a control-end marker, never onto the marker
/// itself: an IME deletion must not break the control. Non-empty object expansions still
/// map to the object's own stored offset, so a range that reaches one deletes it whole.
pub fn stored_offset_after(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible == acc && piece_len > 0 {
return Some(i);
}
if visible < acc.saturating_add(piece_len) {
return None;
}
acc = acc.saturating_add(piece_len);
}
(visible == acc).then_some(self.para.text.len())
}
/// Stored offset for a visible offset, preferring the position **before** a tracked
/// deletion that starts there. Use this for the end of a range so a deletion run just
/// after the caret is never drawn into it.
pub fn stored_offset_before(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
if visible == acc {
return Some(i);
}
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible < acc.saturating_add(piece_len) {
return None;
}
acc = acc.saturating_add(piece_len);
}
(visible == acc).then_some(self.para.text.len())
}
/// Round a visible byte offset forward to the next visible boundary (inward for a start).
pub fn ceil_boundary(&self, visible: usize) -> usize {
let mut acc = 0usize;
let mut out = None;
self.for_each_piece(|_, piece, _| {
if out.is_some() {
return;
}
if acc >= visible {
out = Some(acc);
return;
}
acc = acc.saturating_add(piece.len());
if acc >= visible {
out = Some(acc);
}
});
out.unwrap_or(acc)
}
/// Round a visible byte offset backward to the previous visible boundary (inward for an end).
pub fn floor_boundary(&self, visible: usize) -> usize {
let mut acc = 0usize;
let mut out = 0usize;
self.for_each_piece(|_, piece, _| {
let next = acc.saturating_add(piece.len());
if next > visible {
return;
}
acc = next;
out = acc;
});
out
}
/// A bounded window with `cursor` and `anchor` as visible byte offsets.
///
/// The selection is never split and the window never exceeds [`MAX_IME_TEXT_BYTES`]. A
/// selection wider than that, an invalid offset, or an offset that cannot be represented as
/// visible text returns `None` (unavailable, not fabricated).
pub fn window(&self, cursor: usize, anchor: usize, max_bytes: usize) -> Option<ImeWindow> {
// The public API caps its own budget: a caller cannot ask for an unbounded copy.
let max_bytes = max_bytes.min(MAX_IME_TEXT_BYTES);
let (sel_start, sel_end) = (cursor.min(anchor), cursor.max(anchor));
let selection_len = sel_end - sel_start;
if selection_len > max_bytes {
return None;
}
// Spend the budget around the selection and stop walking at its end: the paragraph is
// never copied and the work is bounded by the caret's distance from the paragraph start.
let before = (max_bytes - selection_len) / 2;
let window_start = sel_start.saturating_sub(before);
let window_end = window_start.saturating_add(max_bytes);
let mut text = String::with_capacity(window_end.saturating_sub(window_start).min(max_bytes));
let mut first_kept = None;
let mut acc = 0usize;
self.for_each_piece_while(|_, piece, _| {
for (k, c) in piece.char_indices() {
let start = acc.saturating_add(k);
let end = start.saturating_add(c.len_utf8());
if end > window_end {
return false;
}
if start >= window_start {
if first_kept.is_none() {
first_kept = Some(start);
}
text.push(c);
}
}
acc = acc.saturating_add(piece.len());
acc < window_end
});
let Some(first) = first_kept else {
// An empty document is an available empty context, never unavailable.
return (cursor == 0 && anchor == 0).then_some(ImeWindow { text: String::new(), cursor: 0, anchor: 0 });
};
Some(ImeWindow { text, cursor: cursor.saturating_sub(first), anchor: anchor.saturating_sub(first) })
}
}
impl Document {
/// Bounded visible logical text around `cursor`/`anchor` in one paragraph.
///
/// A selection across paragraphs or blocks is unrepresentable and returns `None`.
pub fn ime_surrounding(&self, cursor: &Pos, anchor: &Pos, max_bytes: usize) -> Option<ImeWindow> {
if cursor.story != anchor.story || cursor.path != anchor.path {
return None;
}
let para = self.para_at(cursor)?;
let view = VisibleParagraph::new(para);
let c = view.visible_offset(cursor.off)?;
let a = view.visible_offset(anchor.off)?;
view.window(c, a, max_bytes.min(MAX_IME_TEXT_BYTES))
}
/// Delete-surrounding ranges in the stored paragraph text.
///
/// Counts exclude the selection, tracked deletions and any pre-edit (which never lives in
/// the model here). Byte counts that end inside a scalar leave it whole: a start rounds
/// forward and an end rounds backward; a byte count beyond the visible text is refused.
/// Character counts clamp at the paragraph's ends, as the web text agent's do. A range that
/// cannot be represented in stored text returns `None` instead of guessing; so does a
/// request with nothing to erase (`0` on both sides).
pub fn ime_delete_surrounding(&self, cursor: &Pos, anchor: &Pos, before: usize, after: usize, unit: DeleteUnit) -> Option<ImeDelete> {
if cursor.story != anchor.story || cursor.path != anchor.path {
return None;
}
if before == 0 && after == 0 {
// Nothing to erase: don't build the boundary table (or walk a long paragraph) to
// discover it.
return None;
}
let para = self.para_at(cursor)?;
let view = VisibleParagraph::new(para);
let c = view.visible_offset(cursor.off)?;
let a = view.visible_offset(anchor.off)?;
let (sel_start, sel_end) = (c.min(a), c.max(a));
let (before_start, after_end) = match unit {
DeleteUnit::Bytes => {
let total = view.visible_len();
if before > sel_start || after > total - sel_end {
return None;
}
(view.ceil_boundary(sel_start.saturating_sub(before)), view.floor_boundary(sel_end.saturating_add(after)))
}
DeleteUnit::Chars => char_boundaries(&view, sel_start, sel_end, before, after)?,
};
// Start a range after a deletion run and end it before one, so tracked deletions are
// never part of the erased stored range.
let before_start = view.stored_offset_after(before_start)?;
let before_end = view.stored_offset_before(sel_start)?;
let after_start = view.stored_offset_after(sel_end)?;
let after_end = view.stored_offset_before(after_end)?;
let before_range = (before_start < before_end).then_some(before_start..before_end);
let after_range = (after_start < after_end).then_some(after_start..after_end);
Some(ImeDelete { before: before_range, after: after_range, anchor: anchor.off, focus: cursor.off })
}
}
/// The visible byte boundaries `before` characters back from `sel_start` and `after`
/// characters on from `sel_end`.
///
/// Two walks and constant memory replace the old per-character `Vec` that held every boundary
/// (a hostile count could make it allocate the whole paragraph). Character counts clamp at the
/// paragraph's ends; `None` when either selection end is not a boundary of the visible text.
fn char_boundaries(view: &VisibleParagraph<'_>, sel_start: usize, sel_end: usize, before: usize, after: usize) -> Option<(usize, usize)> {
let mut acc = 0usize;
let mut chars = 0usize;
let mut start_index = None;
let mut end_index = None;
view.for_each_piece(|_, piece, _| {
for (k, _) in piece.char_indices() {
let at = acc.saturating_add(k);
if at == sel_start {
start_index = Some(chars);
}
if at == sel_end {
end_index = Some(chars);
}
chars = chars.saturating_add(1);
}
acc = acc.saturating_add(piece.len());
});
if sel_start == acc {
start_index = Some(chars);
}
if sel_end == acc {
end_index = Some(chars);
}
let start_index = start_index?;
let end_index = end_index?;
let target_before = start_index.saturating_sub(before);
let target_after = end_index.saturating_add(after).min(chars);
let mut acc = 0usize;
let mut index = 0usize;
let mut before_start = None;
let mut after_end = (target_after == 0).then_some(0);
view.for_each_piece(|_, piece, _| {
for (k, _) in piece.char_indices() {
let at = acc.saturating_add(k);
if index == target_before && before_start.is_none() {
before_start = Some(at);
}
if index == target_after && after_end.is_none() {
after_end = Some(at);
}
index = index.saturating_add(1);
}
acc = acc.saturating_add(piece.len());
});
if index == target_before && before_start.is_none() {
before_start = Some(acc);
}
if index == target_after && after_end.is_none() {
after_end = Some(acc);
}
Some((before_start?, after_end?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::para::Run;
use crate::props::CharProps;
use crate::{InlineObject, StoryRef};
fn para_with_runs(parts: &[(&str, bool)]) -> Paragraph {
let mut p = Paragraph::new();
for (text, deleted) in parts {
let mut props = CharProps::default();
if *deleted {
props.del = Some(0);
}
p.text.push_str(text);
p.runs.push(Run { len: text.len(), props });
}
p
}
#[test]
fn tracked_deletions_are_hidden_and_offsets_still_map() {
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), 4);
assert_eq!(view.visible_offset(0), Some(0));
assert_eq!(view.visible_offset(2), Some(2), "the start of a deleted run is a visible caret position");
assert_eq!(view.visible_offset(3), None, "inside a tracked deletion");
assert_eq!(view.visible_offset(5), Some(2));
assert_eq!(view.visible_offset(7), Some(4));
assert_eq!(view.stored_offset(2), Some(5));
assert_eq!(view.stored_offset(4), Some(7));
assert_eq!(view.window(2, 2, 4000).map(|w| w.text), Some("abcd".into()));
}
#[test]
fn objects_contribute_their_plain_text_and_split_mapping_stops() {
let mut p = Paragraph::new();
p.text = format!("a{OBJ}b");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), 5);
assert_eq!(view.visible_offset(1), Some(1));
assert_eq!(view.visible_offset(4), Some(4));
assert_eq!(view.stored_offset(1), Some(1));
assert_eq!(view.stored_offset(4), Some(4));
assert_eq!(view.stored_offset(2), None, "inside the object's text");
}
#[test]
fn an_empty_document_is_available_and_empty() {
let p = Paragraph::new();
let view = VisibleParagraph::new(&p);
let w = view.window(0, 0, MAX_IME_TEXT_BYTES).expect("an available empty context");
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("", 0, 0));
let doc = Document::new();
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), MAX_IME_TEXT_BYTES).expect("available");
assert_eq!(w.text, "");
}
#[test]
fn bounded_window_keeps_the_selection_and_rejects_too_wide_ones() {
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
let view = VisibleParagraph::new(&p);
let w = view.window(5000, 5000, MAX_IME_TEXT_BYTES).expect("a bounded window");
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
assert_eq!(w.cursor, w.anchor);
assert!(w.cursor <= w.text.len());
assert!(view.window(0, 4001, MAX_IME_TEXT_BYTES).is_none());
}
#[test]
fn byte_deletion_rounds_inward_and_leaves_partial_scalars_whole() {
let doc = Document::from_text("aé👍b");
let caret = Pos::body(0, 8);
let one = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("b");
assert_eq!(one.before, Some(7..8));
// Two bytes start inside the emoji: it is left whole, only `b` goes.
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b");
assert_eq!(two.before, Some(7..8));
// Six bytes start inside `é`: it stays whole, the emoji and `b` go.
let six = doc.ime_delete_surrounding(&caret, &caret, 6, 0, DeleteUnit::Bytes).expect("emoji and b");
assert_eq!(six.before, Some(3..8));
// A count beyond the paragraph is refused, not clipped to delete a whole side.
assert!(doc.ime_delete_surrounding(&caret, &caret, 0, 1, DeleteUnit::Bytes).is_none());
assert!(doc.ime_delete_surrounding(&caret, &caret, 99, 0, DeleteUnit::Bytes).is_none());
}
#[test]
fn byte_deletion_excludes_a_reversed_selection() {
let doc = Document::from_text("abcd");
let cursor = Pos::body(0, 1);
let anchor = Pos::body(0, 3);
let del = doc.ime_delete_surrounding(&cursor, &anchor, 1, 1, DeleteUnit::Bytes).expect("a deletion");
assert_eq!(del.before, Some(0..1), "only the character before the selection");
assert_eq!(del.after, Some(3..4), "only the character after the selection");
assert_eq!((del.anchor, del.focus), (3, 1), "the reversed selection is preserved");
}
#[test]
fn tracked_deletions_are_not_counted_for_deletion_or_query() {
let mut doc = Document::from_text("abDELcd");
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
let view = VisibleParagraph::new(doc.para_at(&Pos::body(0, 0)).expect("paragraph"));
assert_eq!(view.window(2, 2, 100).map(|w| w.text), Some("abcd".into()));
let caret = Pos::body(0, 7);
let del = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("c");
assert_eq!(del.before, Some(6..7), "the stored deletion run is skipped");
}
#[test]
fn cross_paragraph_selection_is_unavailable() {
let doc = Document::from_text("one\ntwo");
let cursor = Pos::body(0, 1);
let anchor = Pos::body(1, 1);
assert!(doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).is_none());
assert!(doc.ime_delete_surrounding(&cursor, &anchor, 1, 0, DeleteUnit::Chars).is_none());
}
/// An object's plain-text result stands for the object in the visible text; a deletion
/// that starts inside that expansion leaves the object whole (byte) or is refused (chars).
#[test]
fn object_expansion_is_never_partly_deleted() {
let mut doc = Document::from_text("");
let mut p = Paragraph::new();
p.text = format!("a{OBJ}b");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
let caret = Pos::body(0, 5);
// Two bytes before the caret end inside `x+y` (`y` is the last byte): the start rounds
// forward past the object, so only `b` goes.
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b only");
assert_eq!(two.before, Some(4..5), "stored UTF-8 offsets keep the object whole");
let mut remaining = format!("a{OBJ}b");
remaining.replace_range(two.before.clone().unwrap(), "");
assert_eq!(remaining, format!("a{OBJ}"));
// A character that lands inside the expansion has no stored boundary: refused.
assert!(doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Chars).is_none());
// A count reaching before the object erases the object as a whole.
let five = doc.ime_delete_surrounding(&caret, &caret, 5, 0, DeleteUnit::Bytes).expect("object and b");
assert_eq!(five.before, Some(0..5));
}
/// Character counts on the right side clamp at the paragraph's end and never cross into the
/// next paragraph.
#[test]
fn trailing_character_deletion_clamps_at_the_paragraph_end() {
let doc = Document::from_text("ab\ncd");
let caret = Pos::body(0, 1);
let del = doc.ime_delete_surrounding(&caret, &caret, 0, 99, DeleteUnit::Chars).expect("b only");
assert_eq!(del.after, Some(1..2));
let empty = doc.ime_delete_surrounding(&caret, &caret, 0, 0, DeleteUnit::Chars);
assert!(empty.is_none(), "nothing to erase");
}
/// Sorted tracked deletions are scanned with one cursor: alternating revisions stay correct
/// (and linear) instead of each character scanning every range.
#[test]
fn alternating_tracked_revisions_map_correctly() {
// Equal-length visible/deleted pieces, so the expected visible byte count is exact.
let parts: Vec<(String, bool)> =
(0..2000).map(|i| (if i % 2 == 0 { format!("v{i:04};") } else { format!("D{i:04};") }, i % 2 == 1)).collect();
let parts: Vec<(&str, bool)> = parts.iter().map(|(t, d)| (t.as_str(), *d)).collect();
let p = para_with_runs(&parts);
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), p.text.len() - 6 * 1000);
assert_eq!(view.visible_offset(p.text.len()), Some(view.visible_len()));
let w = view.window(0, 0, 100).expect("a window");
assert!(w.text.len() <= 100);
assert!(!w.text.contains('D'), "tracked deletions never show");
}
/// The public window never copies more than the IME cap, whatever a caller asks for.
#[test]
fn window_budget_is_capped() {
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
let view = VisibleParagraph::new(&p);
let w = view.window(0, 0, usize::MAX).expect("a bounded window");
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
let doc = Document::from_text(&"a".repeat(10_000));
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), usize::MAX).expect("bounded");
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
}
#[test]
fn table_cell_paragraph_maps_inside_its_cell() {
let mut doc = Document::from_text("outside");
let mut table = crate::Table::new(1, 1, 100.0);
table.rows[0].cells[0].blocks = vec![crate::para_block(Paragraph::with_text("cell", CharProps::default()))];
doc.body.push(std::sync::Arc::new(crate::Block::Table(table)));
let path = crate::Path(vec![1, 0, 0, 0]);
let cursor = Pos { story: StoryRef::Body, path: path.clone(), off: 4 };
let anchor = Pos { story: StoryRef::Body, path, off: 2 };
let w = doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).expect("the cell text");
assert_eq!(w.text, "cell");
assert_eq!(w.cursor, 4);
assert_eq!(w.anchor, 2);
}
/// Content-control markers are zero-length visible objects. A deletion range starts after
/// the marker and ends before the next one, so an ordinary IME edit never removes a marker
/// or leaves a half-paired control.
#[test]
fn control_markers_are_not_included_in_delete_ranges() {
use crate::control::{ContentControl, ControlKind};
let mut p = Paragraph::new();
p.text = format!("pre{OBJ}Ada{OBJ}X");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
let mut ctl = ContentControl::new(ControlKind::RichText);
ctl.placeholder_text = "Enter name".into();
p.objects.push(InlineObject::ControlStart { control: Box::new(ctl) });
p.objects.push(InlineObject::ControlEnd);
let mut doc = Document::new();
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
// Stored positions are UTF-8 byte offsets; each OBJ marker occupies three bytes.
let cursor = Pos::body(0, 9);
let del = doc.ime_delete_surrounding(&cursor, &cursor, 3, 1, DeleteUnit::Bytes).expect("a deletion");
assert_eq!(del.before, Some(6..9), "the control-start marker and `pre` stay outside");
assert_eq!(del.after, Some(12..13), "the control-end marker stays outside; only `X` goes");
assert_eq!((del.anchor, del.focus), (9, 9));
}
}
+1
View File
@@ -26,6 +26,7 @@ pub mod fields;
pub mod fill;
pub mod freeform;
pub mod graphic;
pub mod ime_text;
pub mod legacy_form;
pub mod line_dash;
pub mod link;
+6
View File
@@ -8,6 +8,12 @@ description = "WordCraft engine: session, selection, undo, the command registry
[lints]
workspace = true
[features]
# Front-end producer tests need to simulate a globally exhausted IME revision allocator. It only
# ever makes receipt classification fail closed. Never enable this for a production build; the
# UI's dev-dependencies enable it for its tests.
test-support = []
[dependencies]
wordcraft-geom = { workspace = true }
wordcraft-doc = { workspace = true }
+418 -49
View File
@@ -10,7 +10,7 @@ use wordcraft_doc::para::OBJ;
use wordcraft_doc::{InlineObject, Pos};
use super::{delete_selection, sel_result, split_para};
use crate::{CmdError, CmdResult, CommandSpec, MathEdit, Selection, Session, p};
use crate::{CmdError, CmdResult, CommandSpec, ImeEditOutcome, MathEdit, Selection, Session, p};
fn editing(s: &Session) -> Option<&'static str> {
if s.math.is_none() { Some("not editing an equation") } else { None }
@@ -40,6 +40,9 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"dir": "left|right|up|down|home|end|next|prev"}"#),
CommandSpec::new("equation.backspace", "Delete Back in Equation", "Equation", |s, _| delete(s, true)).when(editing),
CommandSpec::new("equation.delete", "Delete in Equation", "Equation", |s, _| delete(s, false)).when(editing),
CommandSpec::new("equation.deleteSurrounding", "Delete Around in Equation", "Equation", delete_surrounding)
.when(editing)
.params(r#"{"before": n, "after": n}"#),
CommandSpec::new("equation.enter", "Enter in Equation", "Equation", enter).when(editing),
CommandSpec::new("equation.convert", "Convert", "Equation › Conversions", convert)
.when(near_equation_or_all)
@@ -100,21 +103,68 @@ fn equation_at_caret(s: &Session) -> Option<Pos> {
None
}
/// What one equation edit changed. `content_changed` gates an IME receipt; `state_changed`
/// covers style, representation and lazy-normalization changes that must poison a mixed frame.
#[derive(Clone, Copy, Debug, Default)]
struct EquationEffect {
content_changed: bool,
state_changed: bool,
}
impl EquationEffect {
fn merge(&mut self, other: Self) {
self.content_changed |= other.content_changed;
self.state_changed |= other.state_changed;
}
}
fn apply_effect(s: &mut Session, effect: EquationEffect, transformed: bool) {
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed || effect.content_changed,
transformed,
selection_changed: false,
});
}
fn classified_noop(s: &mut Session) {
s.set_ime_edit_outcome(ImeEditOutcome { classified: true, ..ImeEditOutcome::default() });
}
/// Run `f` on the equation at `at`; keeps its linear text in step and drops the source OMML
/// (it no longer matches).
fn with_eq<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<R, CmdError> {
///
/// The logical before-state is captured *after* lazy `nodes` materialization: an imported
/// equation that only gets normalized is a state change, never an IME content change.
fn with_eq<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<(R, EquationEffect), CmdError> {
let para = s.doc.para_mut(at.story, &at.path)?;
let Some(InlineObject::Equation { linear, display, math }) = para.object_at_mut(at.off) else {
return Err(CmdError::Failed("no equation there".into()));
};
if math.nodes.is_empty() && !linear.is_empty() && !math.linear {
let normalized = math.nodes.is_empty() && !linear.is_empty() && !math.linear;
if normalized {
math.nodes = parse_linear(linear);
}
let nodes_before = math.nodes.clone();
let linear_mode_before = math.linear;
let jc_before = math.jc;
let display_before = *display;
let cached_linear_before = linear.clone();
let omml_before_nonempty = !math.omml.is_empty();
let r = f(math, display);
*linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) };
let new_linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) };
let content_changed = math.nodes != nodes_before || math.linear != linear_mode_before;
let state_changed = normalized
|| content_changed
|| math.jc != jc_before
|| *display != display_before
|| omml_before_nonempty
|| new_linear != cached_linear_before;
*linear = new_linear;
math.omml.clear();
para.touch();
Ok(r)
Ok((r, EquationEffect { content_changed, state_changed }))
}
/// All the text of an equation's runs (its linear form while shown linear).
@@ -178,11 +228,22 @@ fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult
let obj = InlineObject::Equation { linear, display, math: Math { nodes, ..Default::default() } };
let end = s.doc.insert_object(&at, obj, &props)?;
if p::bool(v, "edit").unwrap_or(true) {
let Some(session) = crate::next_math_session() else {
return Err(CmdError::Failed("no equation editing session available".into()));
};
s.sel = Selection::caret(at.clone());
s.math = Some(MathEdit { at, pos: caret });
s.math = Some(MathEdit { at, pos: caret, session });
} else {
s.sel = Selection::caret(end);
}
// Creating an equation inserts visible content, even when its tree is initially empty.
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
result(s)
}
@@ -200,8 +261,11 @@ fn edit(s: &mut Session, v: &Value) -> CmdResult {
};
let pos =
v.get("pos").and_then(|p| serde_json::from_value::<MathPos>(p.clone()).ok()).unwrap_or_else(|| MathPos::new(Vec::new(), me::units(&nodes)));
let Some(session) = crate::next_math_session() else {
return Err(CmdError::Failed("no equation editing session available".into()));
};
s.sel = Selection::caret(at.clone());
s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at });
s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at, session });
result(s)
}
@@ -221,17 +285,24 @@ fn current(s: &Session) -> Result<MathEdit, CmdError> {
fn type_text(s: &mut Session, v: &Value) -> CmdResult {
let text = p::req_str(v, "text")?.to_string();
let m = current(s)?;
if text.is_empty() {
// No normalization, no linear-cache rewrite, no revision: an empty commit is a no-op.
classified_noop(s);
return result(s);
}
let latex = s.math_latex;
let style = TypeStyle { nor: s.math_normal_text };
let mut pos = m.pos.clone();
with_eq(s, &m.at, |math, _| {
let mut transformed = false;
let (_, effect) = with_eq(s, &m.at, |math, _| {
if math.linear {
// Linear form is plain text: no build-up.
let text: String = text.chars().take(crate::MAX_EQUATION_TYPE_CHARS).collect();
pos = me::insert_text(&mut math.nodes, &pos, &text, TypeStyle::default());
set_literal(&mut math.nodes);
return;
}
for c in text.chars().take(10_000) {
for c in text.chars().take(crate::MAX_EQUATION_TYPE_CHARS) {
let rel = matches!(wordcraft_doc::math::math_class(c), wordcraft_doc::math::MClass::Rel);
if !style.nor && (c == ' ' || c == '\t' || rel) {
let mut changed = false;
@@ -243,6 +314,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
pos = p;
changed = true;
}
if changed {
transformed = true;
}
if c == ' ' && changed {
continue;
}
@@ -253,9 +327,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, transformed);
result(s)
}
/// Runs of a linear-form equation are literal text.
fn set_literal(nodes: &mut Arg) {
for n in nodes.iter_mut() {
@@ -272,7 +346,7 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
return insert_nodes_as_equation(s, nodes, &json!({}));
};
let mut pos = m.pos.clone();
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
if math.linear {
pos = me::insert_text(&mut math.nodes, &pos, &to_linear(&nodes), TypeStyle::default());
set_literal(&mut math.nodes);
@@ -283,6 +357,7 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
@@ -296,7 +371,7 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
};
let mut pos = m.pos.clone();
let style = TypeStyle { nor: s.math_normal_text };
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
pos = me::insert_text(&mut math.nodes, &pos, &c, style);
if math.linear {
set_literal(&mut math.nodes);
@@ -305,6 +380,7 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
@@ -324,48 +400,49 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params(format!("unknown direction {dir}")));
}
// Leaving a slot finishes what was typed there (AutoCorrect names, build-up). That is worked
// out on a copy: plain navigation must leave the equation (and its source OMML) untouched,
// and a real change is a document edit — undoable and marking the document dirty (#446, #447).
// out on a local copy: plain navigation must leave the equation (and its source OMML)
// untouched, and a real change is a document edit — undoable and marking the document dirty
// (#446, #447). Any failure happens before the single commit, so no rollback is needed.
let shown_linear = matches!(
s.doc.para_at(&m.at).and_then(|p| p.object_at(m.at.off)),
Some(InlineObject::Equation { math, .. }) if math.linear
);
if !s.math_normal_text
&& !shown_linear
&& let Some(before) = nodes_of(s, &m.at)
{
let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let mut pos = m.pos.clone();
if !s.math_normal_text && !shown_linear {
let latex = s.math_latex;
let mut nodes = before.clone();
let mut pos = m.pos.clone();
let before = nodes.clone();
let mut candidate = nodes.clone();
let mut candidate_pos = pos.clone();
let mut changed = false;
if let Some(p) = me::autocorrect(&mut nodes, &pos) {
pos = p;
if let Some(p) = me::autocorrect(&mut candidate, &candidate_pos) {
candidate_pos = p;
changed = true;
}
if let Some(p) = me::build_up(&mut nodes, &pos, latex) {
pos = p;
if let Some(p) = me::build_up(&mut candidate, &candidate_pos, latex) {
candidate_pos = p;
changed = true;
}
if changed && nodes != before {
pos = candidate_pos;
if changed && candidate != before {
// `equation.move` is a pure command, so record the edit here.
s.checkpoint("Build Up");
with_eq(s, &m.at, |math, _| math.nodes = nodes)?;
let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = candidate.clone())?;
apply_effect(s, effect, true);
// `equation.move` is pure for ordinary navigation, so `Session::run` does not touch
// the document for us. A build-up is a real edit: mark it dirty and refresh layout.
s.touch();
if let Some(mm) = s.math.as_mut() {
mm.pos = pos;
}
nodes = candidate;
}
}
let m = current(s)?;
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let next = match dir {
"left" => me::move_left(&nodes, &m.pos),
"right" => me::move_right(&nodes, &m.pos),
"up" => Some(me::move_vertical(&nodes, &m.pos, true).unwrap_or(m.pos.clone())),
"down" => Some(me::move_vertical(&nodes, &m.pos, false).unwrap_or(m.pos.clone())),
"home" => Some(me::home_end(&nodes, &m.pos, false)),
"end" => Some(me::home_end(&nodes, &m.pos, true)),
"next" | "prev" => Some(me::next_placeholder(&nodes, &m.pos, dir == "prev").unwrap_or(m.pos.clone())),
"left" => me::move_left(&nodes, &pos),
"right" => me::move_right(&nodes, &pos),
"up" => Some(me::move_vertical(&nodes, &pos, true).unwrap_or(pos.clone())),
"down" => Some(me::move_vertical(&nodes, &pos, false).unwrap_or(pos.clone())),
"home" => Some(me::home_end(&nodes, &pos, false)),
"end" => Some(me::home_end(&nodes, &pos, true)),
"next" | "prev" => Some(me::next_placeholder(&nodes, &pos, dir == "prev").unwrap_or(pos.clone())),
other => return Err(CmdError::Params(format!("unknown direction {other}"))),
};
match next {
@@ -379,34 +456,92 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
None => exit(s, &json!({"before": dir == "left"})),
}
}
fn delete(s: &mut Session, back: bool) -> CmdResult {
let m = current(s)?;
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
// An empty equation goes away entirely.
// An empty equation goes away entirely: removing the object is a content change.
if nodes.is_empty() {
s.math = None;
s.sel = Selection { anchor: m.at.clone(), focus: Pos { off: m.at.off + OBJ.len_utf8(), ..m.at.clone() } };
let at = delete_selection(s)?;
s.sel = Selection::caret(at);
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
let mut pos = m.pos.clone();
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
pos = if back { me::delete_back(&mut math.nodes, &pos) } else { me::delete_forward(&mut math.nodes, &pos) };
})?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
/// Delete a bounded number of equation units on each side of the caret in one transaction.
///
/// The requested steps are simulated on a local tree and caret. Any step that cannot make
/// progress refuses the whole request before the document is touched; the commit (if any) is a
/// single command and a single undo step.
fn delete_surrounding(s: &mut Session, v: &Value) -> CmdResult {
let m = current(s)?;
let before = p::u64(v, "before").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX);
let after = p::u64(v, "after").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX);
if before.saturating_add(after) == 0 {
return Err(CmdError::Params("nothing to delete".into()));
}
if before > crate::MAX_EQUATION_DELETE_UNITS || after > crate::MAX_EQUATION_DELETE_UNITS {
return Err(CmdError::Params(format!("at most {} units on each side", crate::MAX_EQUATION_DELETE_UNITS)));
}
let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let mut pos = m.pos.clone();
for _ in 0..before {
let prev_nodes = nodes.clone();
let prev_pos = pos.clone();
pos = me::delete_back(&mut nodes, &pos);
if nodes == prev_nodes {
// Walking into the next argument is not a deletion. A requested unit that only
// moves the caret must refuse the whole request, or a later step can apply against
// a tree the platform's surrounding snapshot did not describe.
return Err(CmdError::Failed(if pos == prev_pos {
"the equation cannot delete that many units".into()
} else {
"the equation delete made no progress".into()
}));
}
}
for _ in 0..after {
let prev_nodes = nodes.clone();
let prev_pos = pos.clone();
pos = me::delete_forward(&mut nodes, &pos);
if nodes == prev_nodes {
return Err(CmdError::Failed(if pos == prev_pos {
"the equation cannot delete that many units".into()
} else {
"the equation delete made no progress".into()
}));
}
}
let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = nodes)?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
Ok(json!({"before": before, "after": after, "changed": true}))
}
fn enter(s: &mut Session, _: &Value) -> CmdResult {
let m = current(s)?;
let latex = s.math_latex;
let mut pos = m.pos.clone();
let mut changed = false;
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
if math.linear {
return;
}
@@ -427,6 +562,7 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, true);
return result(s);
}
// Nothing to build: Enter ends the equation and starts a new paragraph.
@@ -434,6 +570,13 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult {
let after = Pos { off: m.at.off + OBJ.len_utf8(), ..m.at };
let new = split_para(s, &after)?;
s.sel = Selection::caret(new);
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
sel_result(s)
}
@@ -457,6 +600,7 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult {
let latex = s.math_latex;
if p::bool(v, "all").unwrap_or(false) {
let mut n = 0;
let mut effect = EquationEffect::default();
let stories: Vec<wordcraft_doc::StoryRef> =
std::iter::once(wordcraft_doc::StoryRef::Body).chain(s.doc.parts.keys().map(|k| wordcraft_doc::StoryRef::Part(*k))).collect();
for story in stories {
@@ -468,7 +612,8 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult {
.unwrap_or_default();
for off in offs {
let at = Pos { story, path: path.clone(), off };
with_eq(s, &at, |math, _| convert_math(math, professional, latex))?;
let (_, one) = with_eq(s, &at, |math, _| convert_math(math, professional, latex))?;
effect.merge(one);
n += 1;
}
}
@@ -477,23 +622,26 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult {
m.pos = MathPos::default();
}
s.clamp_selection();
apply_effect(s, effect, false);
return Ok(json!({"converted": n}));
}
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let mut end = 0;
with_eq(s, &at, |math, _| {
let (_, effect) = with_eq(s, &at, |math, _| {
convert_math(math, professional, latex);
end = me::units(&math.nodes);
})?;
if let Some(m) = s.math.as_mut() {
m.pos = MathPos::new(Vec::new(), end);
}
apply_effect(s, effect, false);
result(s)
}
fn set_display(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?;
let (_, effect) = with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?;
apply_effect(s, effect, false);
result(s)
}
@@ -505,10 +653,11 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult {
"center" => MathJc::Center,
_ => MathJc::CenterGroup,
};
with_eq(s, &at, |math, display| {
let (_, effect) = with_eq(s, &at, |math, display| {
math.jc = jc;
*display = true;
})?;
apply_effect(s, effect, false);
result(s)
}
@@ -516,7 +665,7 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult {
fn number(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let mut pos_reset = false;
with_eq(s, &at, |math, display| {
let (_, effect) = with_eq(s, &at, |math, display| {
let numbered = matches!(math.nodes.as_slice(), [MNode::EqArr { rows }] if rows.iter().any(|r| wordcraft_doc::math::has_number_mark(r)));
let want = p::bool(v, "value").unwrap_or(!numbered);
if want && !numbered {
@@ -539,6 +688,7 @@ fn number(s: &mut Session, v: &Value) -> CmdResult {
m.pos = MathPos::default();
}
s.clamp_selection();
apply_effect(s, effect, false);
result(s)
}
@@ -566,13 +716,14 @@ fn set(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give linear or latex".into()))?;
let end = me::units(&nodes);
with_eq(s, &at, |math, _| {
let (_, effect) = with_eq(s, &at, |math, _| {
math.nodes = nodes;
math.linear = false;
})?;
if let Some(m) = s.math.as_mut() {
m.pos = MathPos::new(Vec::new(), end);
}
apply_effect(s, effect, false);
result(s)
}
@@ -614,11 +765,12 @@ fn structure(s: &mut Session, v: &Value) -> CmdResult {
.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?,
};
let mut pos = None;
with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?;
let (_, effect) = with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?;
let pos = pos.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
@@ -863,9 +1015,16 @@ mod tests {
// #447: building `a/b` up on a Left move left the document clean and Undo did nothing.
let mut s = imported("<m:r><m:t>a/b</m:t></m:r>");
assert!(!s.dirty);
let layout_rev = s.rev();
let ime_rev = revision(&mut s);
s.run("equation.move", &json!({"dir": "left"})).unwrap();
// Every command replaces the last outcome; sample the edit before the read-only query.
let outcome = s.ime_edit_outcome();
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "\\frac{a}{b}");
assert!(s.dirty, "build-up marks the document dirty");
assert!(s.rev() > layout_rev, "build-up refreshes the layout revision");
assert!(outcome.classified && outcome.content_changed && outcome.state_changed && outcome.transformed, "{outcome:?}");
assert!(revision(&mut s) > ime_rev, "build-up advances the unavailable IME revision");
s.run("edit.undo", &json!({})).unwrap();
s.run("equation.edit", &json!({})).unwrap();
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "a/b", "Undo restores the plain run");
@@ -941,4 +1100,214 @@ mod tests {
assert_eq!(subs, ["i", "j"], "{nodes:#?}");
assert!(nodes.iter().all(|n| matches!(n, MNode::Script { .. }) || matches!(n, MNode::Run(r) if r.text.trim().is_empty())), "{nodes:#?}");
}
fn revision(s: &mut Session) -> u64 {
s.ime_state_revision().expect("an IME state revision")
}
fn clear_nodes(s: &mut Session) {
let at = s.math.as_ref().expect("editing").at.clone();
if let Some(InlineObject::Equation { math, .. }) = s.doc.para_mut(at.story, &at.path).expect("paragraph").object_at_mut(at.off) {
math.nodes.clear();
}
}
#[test]
fn equation_backspace_at_start_is_a_classified_noop() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x+y"})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
let before = revision(&mut s);
s.run("equation.backspace", &json!({})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified);
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
assert_eq!(revision(&mut s), before, "a no-op allocates no revision");
assert_eq!(linear(&s), "x+y");
}
#[test]
fn lazy_normalization_is_state_not_content() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x+1"})).unwrap();
clear_nodes(&mut s);
// Editing parses the local tree but leaves the stored equation lazy.
s.run("equation.edit", &json!({})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
let before = revision(&mut s);
s.run("equation.backspace", &json!({})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified);
assert!(!outcome.content_changed, "the normalized tree was already logical content");
assert!(outcome.state_changed, "materializing the cached nodes is a state change");
assert!(revision(&mut s) > before, "state changes advance the revision");
}
#[test]
fn empty_equation_type_is_a_classified_noop_in_both_modes() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x"})).unwrap();
let before = revision(&mut s);
s.run("equation.type", &json!({"text": ""})).unwrap();
assert!(s.ime_edit_outcome().classified);
assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed);
assert_eq!(revision(&mut s), before, "built-up empty type is a no-op");
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
let before = revision(&mut s);
s.run("equation.type", &json!({"text": ""})).unwrap();
assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed);
assert_eq!(revision(&mut s), before, "linear empty type is a no-op");
}
#[test]
fn automatic_equation_rewrites_are_transformed() {
for text in ["a/b ", "\\alpha "] {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
s.run("equation.type", &json!({"text": text})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.content_changed, "{text}: {outcome:?}");
assert!(outcome.transformed, "{text}: {outcome:?}");
}
}
#[test]
fn equation_style_changes_are_state_changes_without_content() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x"})).unwrap();
let before = revision(&mut s);
s.run("equation.justify", &json!({"jc": "left"})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified && outcome.state_changed && !outcome.content_changed, "{outcome:?}");
assert!(revision(&mut s) > before);
let before = revision(&mut s);
s.run("equation.display", &json!({"value": false})).unwrap();
assert!(s.ime_edit_outcome().state_changed && !s.ime_edit_outcome().content_changed);
assert!(revision(&mut s) > before);
}
#[test]
fn convert_all_merges_every_equations_effect() {
let mut s = session();
s.run("document.setText", &json!({"text": "\r"})).unwrap();
s.run("insert.equation", &json!({"linear": "a/b"})).unwrap();
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
s.run("equation.exit", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.equation", &json!({"linear": "c/d"})).unwrap();
s.run("equation.exit", &json!({})).unwrap();
s.run("equation.convert", &json!({"to": "professional", "all": true})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}");
}
#[test]
fn equation_delete_surrounding_is_one_transaction_and_one_undo_step() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "abcd"})).unwrap();
let depth = s.undo_depth();
s.run("equation.deleteSurrounding", &json!({"before": 2, "after": 0})).unwrap();
assert_eq!(linear(&s), "ab");
assert_eq!(s.undo_depth(), depth + 1, "one undo step, not one per unit");
assert!(s.ime_edit_outcome().classified && s.ime_edit_outcome().content_changed);
}
#[test]
fn equation_delete_surrounding_refuses_partial_progress() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "ab"})).unwrap();
let session_token = s.math.as_ref().unwrap().session;
let at = s.math.as_ref().unwrap().at.clone();
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 3, "after": 0})).unwrap_err();
assert!(err.to_string().contains("cannot delete"), "{err}");
assert_eq!(linear(&s), "ab", "the whole request is refused, no prefix applied");
assert_eq!(s.undo_depth(), depth, "no undo step for a refused transaction");
let m = s.math.as_ref().expect("still editing");
assert_eq!(m.session, session_token);
assert_eq!(m.at, at);
assert!(!s.ime_edit_outcome().classified, "a refusal has no outcome");
assert_eq!(
s.last_command.as_ref().map(|(id, _)| id.as_str()),
Some("equation.deleteSurrounding"),
"only the pre-dispatch Repeat record remains"
);
}
#[test]
fn equation_delete_surrounding_bounds_and_empty_equations() {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 0})).is_err(), "zero counts are refused");
assert!(s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).is_err(), "an empty equation refuses");
assert!(s.math.is_some(), "the empty equation object stays");
s.run("equation.type", &json!({"text": "a"})).unwrap();
let depth = s.undo_depth();
assert!(s.run("equation.deleteSurrounding", &json!({"before": 65, "after": 0})).is_err());
assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 65})).is_err());
assert_eq!(linear(&s), "a");
assert_eq!(s.undo_depth(), depth);
s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).unwrap();
assert_eq!(linear(&s), "", "exactly deleting the last unit leaves the equation object");
assert!(s.math.is_some());
}
#[test]
fn equation_delete_surrounding_refuses_a_caret_only_step() {
let mut s = session();
s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap();
// At the end of the numerator a forward delete only steps into the denominator: the
// tree does not change, so a request that would report progress is refused whole.
s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 };
let before = linear(&s);
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 1})).unwrap_err();
assert!(err.to_string().contains("no progress"), "{err}");
assert_eq!(linear(&s), before);
assert_eq!(s.undo_depth(), depth);
}
/// A deletion that really erases on one side cannot buy a caret-only step on the other:
/// the mixed request is refused whole, so a following IME commit cannot land in the
/// argument the platform never asked about.
#[test]
fn equation_delete_surrounding_refuses_a_mixed_caret_only_step() {
let mut s = session();
s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap();
// At the end of the numerator. `before: 1` erases `a`, but `after: 1` would only step
// into the denominator (the report's mixed-direction reproducer).
s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 };
let before = linear(&s);
let pos = s.math.as_ref().expect("editing").pos.clone();
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 1})).unwrap_err();
assert!(err.to_string().contains("no progress"), "{err}");
assert_eq!(linear(&s), before, "the successful-looking prefix is not applied");
assert_eq!(s.undo_depth(), depth, "no undo step for a refused mixed request");
assert_eq!(s.math.as_ref().expect("editing").pos, pos, "the caret stays where the request found it");
}
#[test]
fn equation_type_caps_linear_and_built_up_input() {
for linear_mode in [false, true] {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
if linear_mode {
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
}
let text = "a".repeat(crate::MAX_EQUATION_TYPE_CHARS + 5);
s.run("equation.type", &json!({"text": text})).unwrap();
assert_eq!(linear(&s).chars().count(), crate::MAX_EQUATION_TYPE_CHARS, "linear={linear_mode}");
}
}
#[test]
fn equation_delete_surrounding_moves_the_caret_for_a_one_step_delete() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "abc"})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
s.run("equation.deleteSurrounding", &json!({"after": 1, "before": 0})).unwrap();
assert_eq!(linear(&s), "bc");
assert_eq!(s.math.as_ref().unwrap().pos, MathPos::new(Vec::new(), 0));
}
}
+4
View File
@@ -188,6 +188,10 @@ pub fn specs() -> Vec<CommandSpec> {
let text = p::req_str(v, "text")?;
let d = Document::from_text(text);
s.doc.body = d.body;
// The body is new even though the document id stays: a live IME composition (or an
// equation editing session at the same offset) belongs to the old content.
s.note_body_replaced();
s.math = None;
s.sel = crate::Selection::caret(s.doc.start_of(StoryRef::Body));
sel_result(s)
})
+94 -15
View File
@@ -150,16 +150,43 @@ pub fn selected_paths(s: &Session) -> Vec<wordcraft_doc::Path> {
s.doc.para_paths_in(&a, &b)
}
/// Delete the selection (respecting track changes); returns the collapsed position.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
/// What one selection deletion actually did, for IME outcome classification.
#[derive(Clone, Debug, Default)]
pub(crate) struct SelectionDelete {
pub at: Pos,
pub content_changed: bool,
pub state_changed: bool,
}
/// Delete the selection (respecting track changes), reporting whether committed content changed.
///
/// Under Track Changes an all-already-deleted range is a real no-op: no revision record is
/// created and the effect says so.
pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete, CmdError> {
let (a, b) = s.sel.ordered();
if a == b {
return Ok(a);
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
}
controls::check_delete(s, &a, &b)?;
let at = if s.doc.settings.track_changes { track_delete(s, &a, &b)? } else { s.doc.delete_range(&a, &b)? };
s.sel = crate::Selection::caret(at.clone());
Ok(at)
let effect = if s.doc.settings.track_changes {
if !track_delete_would_change(s, &a, &b) {
SelectionDelete { at: a.clone(), content_changed: false, state_changed: false }
} else {
let rid = new_revision(s, RevisionKind::Delete);
let deleted = track_delete_as(s, &a, &b, rid)?;
SelectionDelete { at: deleted.at, content_changed: deleted.changed, state_changed: deleted.changed }
}
} else {
let at = s.doc.delete_range(&a, &b)?;
SelectionDelete { at, content_changed: true, state_changed: true }
};
s.sel = crate::Selection::caret(effect.at.clone());
Ok(effect)
}
/// Delete the selection (respecting track changes); returns the collapsed position.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
delete_selection_effect(s).map(|e| e.at)
}
/// A revision record for the current author.
@@ -217,15 +244,47 @@ pub fn remove_empty_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc:
Ok(true)
}
/// Whether a tracked deletion of `a..b` would change anything: any non-deleted run intersects
/// the range, or a removable/markable paragraph mark lies inside it.
pub(crate) fn track_delete_would_change(s: &Session, a: &Pos, b: &Pos) -> bool {
let paths = s.doc.paths_between(a, b);
for path in &paths {
let Some(p) = s.doc.para(a.story, path) else { continue };
let from = if *path == a.path { a.off } else { 0 };
let to = if *path == b.path { b.off } else { p.len() };
if p.run_ranges().any(|(r, c)| r.end > from && r.start < to && c.del.is_none()) {
return true;
}
if *path != b.path && has_next_para(s, a.story, path) && p.mark.del.is_none() {
return true;
}
}
false
}
/// What one tracked deletion did.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct TrackDeleteEffect {
pub at: Pos,
/// At least one run was removed/marked deleted, or a paragraph mark was removed/marked.
pub changed: bool,
}
/// Tracked deletion: own insertions are removed, other text is marked deleted.
///
/// Preflights first: an all-already-deleted range does nothing and creates no revision record.
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
if !track_delete_would_change(s, a, b) {
return Ok(a.clone());
}
let rid = new_revision(s, RevisionKind::Delete);
track_delete_as(s, a, b, rid)
track_delete_as(s, a, b, rid).map(|e| e.at)
}
/// [`track_delete`] marking the deleted text with revision `rid`.
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<Pos, CmdError> {
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<TrackDeleteEffect, CmdError> {
let author = s.author.clone();
let mut changed = false;
// Remove text this author inserted (it never existed for the reader); mark the rest.
let paths = s.doc.paths_between(a, b);
for path in paths.iter().rev() {
@@ -248,6 +307,7 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re
} else {
para.format(x, y, &|c| c.del = Some(rid))?;
}
changed = true;
}
// The paragraph mark is inside the range unless this is its last paragraph. Like text,
// a mark this author inserted is removed (the paragraphs join again), any other is
@@ -264,14 +324,27 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re
p.mark.del = Some(rid);
p.touch();
}
changed = true;
}
}
}
Ok(a.clone())
Ok(TrackDeleteEffect { at: a.clone(), changed })
}
/// What one text insertion/deletion commit changed, for IME outcome classification.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct InsertEffect {
pub content_changed: bool,
pub state_changed: bool,
}
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, true).map(|_| ())
}
/// [`type_text`] with the effect the insertion had.
pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result<InsertEffect, CmdError> {
insert_text(s, text, true)
}
@@ -279,30 +352,32 @@ pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
/// being transferred, not Enter key presses: an empty list item between them stays (Enter on
/// one would end the list).
pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, false)
insert_text(s, text, false).map(|_| ())
}
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> {
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect, CmdError> {
let mut props = s.typing_props();
if s.sel.is_collapsed() {
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
}
// Content controls: a placeholder is replaced, locked ones refuse.
if let Some(p) = controls::prepare_edit(s)? {
let prepared = controls::prepare_edit(s)?;
let placeholder_changed = prepared.is_some();
if let Some(p) = prepared {
props = p;
}
// Pasted text is text: pending properties left by AutoCorrect don't give it a symbol's font (#2036).
if !enter {
props = props.without_symbol();
}
delete_selection(s)?;
let deletion = delete_selection_effect(s)?;
// Typed text is new: never deleted, and not a formatting change of the text around it.
props.fmt_change = None;
// Its direction comes from its own letters, as on screen, not from the run it's typed into:
// Latin typed into reopened Hebrew must not be saved as right-to-left (#1543). The DOCX
// writer marks the right-to-left pieces itself.
props.rtl = None;
if s.doc.settings.track_changes {
if s.doc.settings.track_changes && !text.is_empty() {
props.ins = Some(new_revision(s, RevisionKind::Insert));
props.del = None;
} else {
@@ -311,13 +386,16 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
}
let mut at = s.sel.focus.clone();
let mut first = true;
let mut inserted = false;
for part in text.split(['\r', '\u{2029}']) {
if !first {
at = split_para_as(s, &at, enter)?;
inserted = true;
}
first = false;
let part = part.replace('\u{000B}', "\n");
if !part.is_empty() {
inserted = true;
let starts_para = direction_unset(s, &at);
at = s.doc.insert_text(&at, &part, &props)?;
// Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing
@@ -334,7 +412,8 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
}
s.sel = crate::Selection::caret(at);
s.goal_x = None;
Ok(())
let content_changed = deletion.content_changed || inserted;
Ok(InsertEffect { content_changed, state_changed: deletion.state_changed || content_changed || placeholder_changed })
}
/// Is the paragraph at `at` still without letters (empty, or only spaces, digits and
+1 -1
View File
@@ -58,7 +58,7 @@ pub fn cut_tracked(s: &mut Session) -> Result<bool, CmdError> {
}
s.doc.revisions.push(Revision::new(RevisionKind::Delete, s.author.clone(), now_iso()));
let rid = (s.doc.revisions.len() - 1) as u32;
let at = track_delete_as(s, &a, &b, rid)?;
let at = track_delete_as(s, &a, &b, rid)?.at;
s.sel = Selection::caret(at);
// Only the author's own insertions were cut: they are gone, not moved.
if in_use(s, rid) {
+64 -13
View File
@@ -5,7 +5,7 @@ use wordcraft_doc::para::{COLUMN_BREAK, NB_HYPHEN, NBSP, OBJ, PAGE_BREAK, SOFT_H
use wordcraft_doc::props::{Border, BorderStyle, NumRef};
use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table};
use super::{delete_selection, non_mac, sel_result, split_para, type_text};
use super::{delete_selection, delete_selection_effect, non_mac, sel_result, split_para, type_text, type_text_effect};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> {
@@ -100,10 +100,18 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
let text = p::req_str(v, "text")?;
let raw = p::bool(v, "raw").unwrap_or(false);
typing_options(s, text);
let t = if raw { None } else { autoformat(s, text) };
type_text(s, t.as_deref().unwrap_or(text))?;
let smart_quote = if raw { None } else { autoformat(s, text) };
let effect = type_text_effect(s, smart_quote.as_deref().unwrap_or(text))?;
let mut outcome = crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: smart_quote.is_some(),
selection_changed: false,
};
let trigger = text.chars().count() == 1 && text.chars().all(|c| c == ' ' || ",.;:!?".contains(c));
if raw || !trigger {
s.set_ime_edit_outcome(outcome);
return sel_result(s);
}
// The paragraph as typed, so an automatic change can be undone on its own.
@@ -114,8 +122,19 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
if !listed {
super::tools::autocorrect(s).inspect_err(|_| s.autocap = None)?;
}
if text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes {
dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
let dashed = text == " "
&& !listed
&& s.prefs.autocorrect.enabled
&& s.prefs.autocorrect.dashes
&& dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
// Compare the paragraph with the one exactly as typed. This is the actual effect of
// AutoCorrect (including formatting-only superscripts and links), not an undo label that
// may have been left by an earlier step.
let rewritten = typed.as_ref().is_some_and(|(para, _)| s.doc.para_at(&f) != Some(para));
if listed || dashed || rewritten {
outcome.content_changed = true;
outcome.state_changed = true;
outcome.transformed = true;
}
if let Some((para, sel)) = typed
&& s.doc.para_at(&f) != Some(&para)
@@ -133,6 +152,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
}
}
s.autocap.take_if(|c| c.depth == u64::MAX);
s.set_ime_edit_outcome(outcome);
sel_result(s)
}
@@ -188,10 +208,10 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
}
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
fn dash_autoformat(s: &mut Session) -> Result<bool, CmdError> {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
let Some(before) = para.text.get(..f.off) else { return Ok(()) };
let Some(para) = s.doc.para_at(&f) else { return Ok(false) };
let Some(before) = para.text.get(..f.off) else { return Ok(false) };
if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind(" -- ")
&& before.trim_end().get(i + 4..).is_some_and(|w| !w.is_empty() && !w.contains(' '))
@@ -202,6 +222,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
p.delete(i + 1, i + 3)?;
p.insert_text(i + 1, "\u{2013}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
return Ok(true);
} else if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind(" - ")
&& !before.trim_end()[i + 3..].contains(' ')
@@ -211,6 +232,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
p.delete(i + 1, i + 2)?;
p.insert_text(i + 1, "\u{2013}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 2, ..f });
return Ok(true);
} else if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind("--")
&& i > 0
@@ -221,8 +243,9 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
p.delete(i, i + 2)?;
p.insert_text(i, "\u{2014}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
return Ok(true);
}
Ok(())
Ok(false)
}
/// An AutoFormat that Enter applies to the paragraph just finished.
@@ -689,7 +712,14 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
s.goal_x = None;
s.pending = None;
if !s.sel.is_collapsed() {
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
let f = s.sel.focus.clone();
@@ -700,7 +730,14 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
c.note_backspace(&a, &f);
}
s.sel = Selection { anchor: a, focus: f };
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
// At the start of a paragraph.
@@ -779,7 +816,14 @@ fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
s.goal_x = None;
s.pending = None;
if !s.sel.is_collapsed() {
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
let f = s.sel.focus.clone();
@@ -787,7 +831,14 @@ fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
if f.off < para.len() {
let b = Pos { off: para.next_boundary(f.off), ..f.clone() };
s.sel = Selection { anchor: f, focus: b };
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
// At the end: join with the next paragraph in the same container.
+8
View File
@@ -490,6 +490,10 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc.settings.protection = protection;
s.doc.settings.protect_hash = protect_hash;
s.doc.settings.protect_formatting = protect_formatting;
// The restored document reuses the open document's id; the body and any equation
// editing session are replaced.
s.note_body_replaced();
s.math = None;
s.touch();
s.clamp_selection();
}
@@ -1205,6 +1209,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
s.doc = doc;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
}
let level = p::str(v, "level").unwrap_or("word");
let opts = CompareOptions {
@@ -1285,6 +1291,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
}
s.doc.body = out;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
if show_in != "original" || p::str(v, "original").is_some() {
// The result is a new, untitled document: saving it never overwrites either file.
s.path = None;
+31 -1
View File
@@ -28,8 +28,38 @@ pub use io::Password;
pub use keymap::KeyMap;
pub use ribbon::RibbonLayout;
pub use session::{
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale,
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document,
next_math_session, set_system_locale,
};
/// The outcome a command reports for IME receipt classification.
///
/// A command that can be called by an input method must classify its committed effects. Only a
/// classified content change without an automatic rewrite may mint an IME receipt; any other
/// committed state change still advances the IME state revision and therefore makes a mixed
/// frame `Other` rather than certifying it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ImeEditOutcome {
/// The handler explicitly classified the committed effects.
pub classified: bool,
/// IME-visible content changed. Only this gates an IME receipt.
pub content_changed: bool,
/// Any committed document state changed, including style or representation changes.
pub state_changed: bool,
/// An automatic engine rewrite was applied (AutoCorrect, build-up, AutoFormat).
pub transformed: bool,
/// The caret or selection changed. Filled in by [`Session::run`].
pub selection_changed: bool,
}
/// The largest text one `equation.type` call inserts, in characters. The engine truncates to this
/// in both linear and built-up modes; the UI refuses an IME commit above it instead of inserting a
/// prefix.
pub const MAX_EQUATION_TYPE_CHARS: usize = 10_000;
/// The largest number of unit steps one `equation.deleteSurrounding` request may take on either
/// side of the caret. A hostile IME count must not become an unbounded command.
pub const MAX_EQUATION_DELETE_UNITS: usize = 64;
pub use wordcraft_doc as doc;
pub use wordcraft_geom::Region;
pub use wordcraft_layout as layout;
+403 -7
View File
@@ -23,7 +23,7 @@ use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, PartKind, Path, Pos, StoryRef, Table};
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewMode};
use crate::{CmdError, Registry};
use crate::{CmdError, ImeEditOutcome, Registry};
/// A selection: anchor (where it started) and focus (where the caret is).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -323,8 +323,9 @@ struct Undo {
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the
/// open typing group, a pending [`Session::join_next_undo`] and the dirty flag. See
/// [`Session::edit_snapshot`].
/// open typing group, the pending
/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises
/// and the dirty flag. See [`Session::edit_snapshot`].
pub struct EditSnapshot {
doc: Document,
sel: Selection,
@@ -337,7 +338,13 @@ pub struct EditSnapshot {
redo: Vec<Arc<Undo>>,
typing_open: bool,
join_next: bool,
equation_join_next: bool,
/// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects.
ime_text_join: Option<Selection>,
dirty: bool,
/// The body's replacement epoch then: a previewed replacement that is rolled back is not a
/// replacement after all.
ime_body_epoch: u64,
}
/// Undo steps that the commands run between [`Session::edit_snapshot`] and [`Session::restore`]
@@ -416,6 +423,14 @@ pub struct Session {
typing_open: bool,
/// The next mutating command joins the previous undo step (later frames of a drag).
join_next: bool,
/// The next equation typing command joins a successful equation `deleteSurrounding`'s undo
/// step. Unlike [`Self::join_next`], any other command consumes and discards it, so an
/// unrelated Bold or document replacement can never be merged into the deletion.
equation_join_next: bool,
/// The next `text.insert` at this exact selection joins a successful body
/// `DeleteSurrounding`'s undo step. Any other command consumes and discards it, so an
/// intervening edit owns its own checkpoint instead of being merged into the deletion.
ime_text_join: Option<Selection>,
/// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857).
pub(crate) autocap: Option<crate::cmd::tools::AutoCap>,
rev: u64,
@@ -426,6 +441,31 @@ pub struct Session {
/// Which document this is: changes when [`Session::set_document`] replaces it (open, new,
/// mail merge, recover), never on an edit.
document_id: u64,
/// How many times the document's body was replaced under the same [`Self::document_id`]
/// (`document.setText`, version restore, compare, Undo/Redo). It is the body's IME
/// replacement epoch: a composition must not survive the swap, even when the new body has
/// the same text or the same bounded surrounding window.
ime_body_epoch: u64,
/// Engine-owned, checked, process-monotonic revision for IME receipts whose target cannot
/// provide surrounding text. `None` means the allocator is exhausted (fail closed).
ime_state_revision: Option<u64>,
/// The caret/equation-session token the revision was last allocated for. It covers selection
/// and caret changes without walking the document.
ime_state_seen: Option<ImeCaretState>,
/// The current command's explicit IME effect classification.
ime_edit_outcome: ImeEditOutcome,
/// Simulation of a globally exhausted IME revision allocator for front-end producer tests.
/// Only [`Self::exhaust_ime_state_revision`] sets it; production builds do not compile it,
/// and it only makes receipt classification fail closed.
#[cfg(any(test, feature = "test-support"))]
ime_state_exhausted: bool,
/// A successful non-IME command changed document state since the last IME observation. It is
/// drained before publishing a receipt and at observation time; it is never cleared by a later
/// command. It covers mixed formatting/IME frames when available stamps ignore the revision.
pending_ime_application_change: bool,
/// Whether the current command was entered through [`Session::run_ime`]. Nested commands
/// inherit the origin.
ime_origin: bool,
cache: LayoutCache,
/// (revision, width, read height, view, read line spacing, layout, proofing, outline,
/// (deletions left out, deletions marked Hidden, body deletions in balloons), numeral display, the word being typed).
@@ -515,13 +555,43 @@ pub struct Session {
/// The number of edit locations Go Back cycles through (Word: four).
const EDIT_MARKS: usize = 4;
/// The caret identity used for the process-monotonic IME state revision.
#[derive(Clone, Debug, PartialEq, Eq)]
enum ImeCaretState {
Body(Selection),
Equation { session: u64, pos: wordcraft_doc::math_edit::MathPos },
}
/// One checked allocation from a process-monotonic IME revision counter.
fn allocate_ime_state_revision(counter: &std::sync::atomic::AtomicU64) -> Option<u64> {
counter.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok()
}
static IME_STATE_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn next_ime_state_revision() -> Option<u64> {
allocate_ime_state_revision(&IME_STATE_REVISION)
}
/// The equation being edited.
#[derive(Clone, Debug, PartialEq)]
pub struct MathEdit {
/// The equation object (its U+FFFC).
/// The equation object (its U+FFFC). This moves as the document is edited, so it is not an
/// IME owner; [`Self::session`] is.
pub at: Pos,
/// The caret inside it.
pub pos: wordcraft_doc::math_edit::MathPos,
/// A stable token for this editing session, unique across documents and re-entry.
pub session: u64,
}
/// Next stable token for an equation editing session, or `None` when the counter is exhausted.
///
/// Exhaustion fails closed: a repeated token could let a cached IME context outlive the
/// equation it described, so the caller refuses to start editing instead.
pub fn next_math_session() -> Option<u64> {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok()
}
/// Editing preferences that persist between runs (the front end saves and restores them).
@@ -704,10 +774,20 @@ impl Session {
redo: Vec::new(),
typing_open: false,
join_next: false,
equation_join_next: false,
ime_text_join: None,
autocap: None,
rev: 1,
protect_facts: std::sync::Mutex::new(None),
document_id: 1,
ime_body_epoch: 0,
ime_state_revision: None,
ime_state_seen: None,
ime_edit_outcome: ImeEditOutcome::default(),
#[cfg(any(test, feature = "test-support"))]
ime_state_exhausted: false,
pending_ime_application_change: false,
ime_origin: false,
cache: LayoutCache::new(),
layout: None,
typing: None,
@@ -814,6 +894,29 @@ impl Session {
pub fn document_id(&self) -> u64 {
self.document_id
}
/// How many times the body was replaced under this [`Self::document_id`].
///
/// Front ends use it as the body's IME replacement epoch and to tell a body swap from an
/// ordinary edit; it advances for `document.setText`, a version restore, a compare and
/// Undo/Redo, never for typing.
pub fn ime_body_epoch(&self) -> u64 {
self.ime_body_epoch
}
/// Note that a command replaced the document or its whole body under this document id.
///
/// The counter fails closed: if it were ever exhausted (unreachable in practice), the
/// document owner id advances too, so a cached IME context still cannot be reused.
pub fn note_body_replaced(&mut self) {
self.ime_body_epoch = match self.ime_body_epoch.checked_add(1) {
Some(epoch) => epoch,
None => {
self.document_id = self.document_id.saturating_add(1);
u64::MAX
}
};
}
pub fn touch(&mut self) {
self.rev = self.rev.wrapping_add(1);
self.dirty = true;
@@ -1051,6 +1154,36 @@ impl Session {
pub fn join_next_undo(&mut self) {
self.join_next = true;
}
/// Make the next equation typing command (`equation.type` or `equation.backspace`) join the
/// previous equation deletion's undo step. Unlike [`Self::join_next_undo`], the promise is
/// not generic: any other command consumes and discards it, so unrelated edits get their own
/// checkpoint. A deletion-only IME request therefore cannot leak a join into the next edit.
pub fn join_next_equation_edit(&mut self) {
self.equation_join_next = true;
}
/// Discard an equation replacement join that received no replacement (for example an empty
/// commit), so it cannot join a later composition's typing.
pub fn clear_equation_join(&mut self) {
self.equation_join_next = false;
}
/// Make the next `text.insert` at the current selection join a successful body
/// `DeleteSurrounding`'s undo step. Unlike [`Self::join_next_undo`], the promise is scoped to
/// that exact replacement: any other command consumes and discards it, so an intervening
/// ordinary edit gets its own checkpoint.
pub fn join_next_ime_text_replacement(&mut self) {
self.ime_text_join = Some(self.sel.clone());
}
/// Discard a body replacement join that received no replacement (an empty or refused
/// commit, a new request or a refusal), so it cannot join a later composition.
pub fn clear_ime_text_join(&mut self) {
self.ime_text_join = None;
}
/// Discard every pending IME replacement join. A refused surrounding request supersedes the
/// composition it interrupts, including any promise from an earlier accepted request.
pub fn clear_ime_joins(&mut self) {
self.equation_join_next = false;
self.ime_text_join = None;
}
/// Stamp the document's save metadata (modified time, last modified by, revision; created
/// when it has none) the way Word does on every save. `file.save` calls it, and so does any
/// front end that writes the bytes itself (the web build's download, #262). Like Word, the
@@ -1145,6 +1278,9 @@ impl Session {
column: std::mem::replace(&mut self.column, u.column),
};
self.redo.push(Arc::new(cur));
// Undo swaps the whole body back: a composition belongs to the text it showed.
self.note_body_replaced();
self.refresh_math_session();
self.touch();
true
}
@@ -1164,9 +1300,28 @@ impl Session {
column: std::mem::replace(&mut self.column, u.column),
};
self.history.push(Arc::new(cur));
self.note_body_replaced();
self.refresh_math_session();
self.touch();
true
}
/// An equation's editing session cannot ride across Undo/Redo: the equation tree, or the
/// object at that offset, may be a different one now. A new session token starts a new IME
/// context; if tokens are exhausted, the equation is left (fail closed).
fn refresh_math_session(&mut self) {
if self.math.is_none() {
return;
}
match next_math_session() {
Some(session) => {
if let Some(m) = self.math.as_mut() {
m.session = session;
}
}
None => self.math = None,
}
}
/// Drop history (after open / new).
pub fn reset_history(&mut self) {
self.autocap = None;
@@ -1220,12 +1375,16 @@ impl Session {
/// Replace the document (open/new).
pub fn set_document(&mut self, doc: Document) {
self.autocap = None;
self.document_id = self.document_id.wrapping_add(1);
// Never reuse an id after wrapping: the IME context is keyed by it.
self.document_id = self.document_id.saturating_add(1);
self.note_body_replaced();
self.bib_style = doc_bib_style(&doc);
self.doc = doc;
self.doc.ensure_nonempty();
self.password = None;
self.file_name = None;
// The old document's equation is gone: its editing session cannot carry over.
self.math = None;
self.sel = Selection::caret(self.doc.start_of(StoryRef::Body));
self.pending = None;
self.typing = None;
@@ -1249,7 +1408,8 @@ impl Session {
/// may want to take it back without a trace (cheap: the document's blocks are shared).
///
/// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near
/// the limit), `redo`, the open typing group, a pending `join_next_undo` and `dirty`. `rev`,
/// the limit), `redo`, the open typing group, the pending `join_next_undo` and
/// `join_next_equation_edit` promises and `dirty`. `rev`,
/// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not
/// covered: a caller that lets the command change them (the view, the find state, the
/// clipboard, macros, the file path…) saves and puts them back itself.
@@ -1294,6 +1454,8 @@ impl Session {
redo,
typing_open,
join_next,
equation_join_next,
ime_text_join,
autocap: _,
rev: _,
protect_facts: _,
@@ -1313,6 +1475,16 @@ impl Session {
merge: _,
ui_requests: _,
document_id: _,
ime_body_epoch,
// The IME revision and its caret token are process-global evidence, not document
// state: a rollback must not reuse a revision.
ime_state_revision: _,
ime_state_seen: _,
ime_edit_outcome: _,
#[cfg(any(test, feature = "test-support"))]
ime_state_exhausted: _,
pending_ime_application_change: _,
ime_origin: _,
read_aloud: _,
prefs: _,
system_region: _,
@@ -1348,7 +1520,10 @@ impl Session {
redo: redo.clone(),
typing_open: *typing_open,
join_next: *join_next,
equation_join_next: *equation_join_next,
ime_text_join: ime_text_join.clone(),
dirty: *dirty,
ime_body_epoch: *ime_body_epoch,
}
}
@@ -1356,7 +1531,20 @@ impl Session {
/// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout
/// is recomputed.
pub fn restore(&mut self, snap: EditSnapshot) {
let EditSnapshot { doc, sel, history_len, evicted, head, redo, typing_open, join_next, dirty } = snap;
let EditSnapshot {
doc,
sel,
history_len,
evicted,
head,
redo,
typing_open,
join_next,
equation_join_next,
ime_text_join,
dirty,
ime_body_epoch,
} = snap;
let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX);
if gone > 0 {
let back = gone.min(head.len());
@@ -1371,6 +1559,9 @@ impl Session {
self.redo = redo;
self.typing_open = typing_open;
self.join_next = join_next;
self.equation_join_next = equation_join_next;
self.ime_text_join = ime_text_join;
self.ime_body_epoch = ime_body_epoch;
self.touch();
self.dirty = dirty;
}
@@ -1417,13 +1608,105 @@ impl Session {
f
}
/// The current command's explicit IME effect classification.
pub fn ime_edit_outcome(&self) -> ImeEditOutcome {
self.ime_edit_outcome
}
/// Set the effect classification from a command handler. [`Session::run`] fills
/// `selection_changed` and enforces the content/state invariant after the command.
pub(crate) fn set_ime_edit_outcome(&mut self, outcome: ImeEditOutcome) {
self.ime_edit_outcome = outcome;
}
/// Take the pending application-origin change signal. The UI owns one session, so a simple
/// bool is enough; it deliberately survives later commands until drained.
pub fn take_pending_ime_application_change(&mut self) -> bool {
std::mem::take(&mut self.pending_ime_application_change)
}
/// Simulate a globally exhausted IME revision allocator in this session (test support).
///
/// This only ever makes receipt classification fail closed. It exists so front-end producer
/// tests can exercise the real `ime_stamp` path instead of hand-building an `Unavailable`
/// stamp; production builds do not compile it.
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub fn exhaust_ime_state_revision(&mut self) {
self.ime_state_exhausted = true;
self.ime_state_revision = None;
}
/// The checked, process-monotonic revision for IME receipts with unavailable surrounding
/// text. Selection/caret motion allocates on the next call; `None` means exhausted.
pub fn ime_state_revision(&mut self) -> Option<u64> {
self.sync_ime_caret_state();
self.ime_state_revision
}
fn current_ime_caret_state(&self) -> ImeCaretState {
match &self.math {
Some(m) => ImeCaretState::Equation { session: m.session, pos: m.pos.clone() },
None => ImeCaretState::Body(self.sel.clone()),
}
}
/// Allocate the next IME state revision, or store `None` when the allocator is exhausted.
/// Test support can pin exhaustion for this session so a front-end producer test sees the
/// real `ime_stamp`/receipt path fail closed.
fn reset_ime_state_revision(&mut self) {
#[cfg(any(test, feature = "test-support"))]
if self.ime_state_exhausted {
self.ime_state_revision = None;
return;
}
// v5: allocation failure must store `None`, never retain a stale revision.
self.ime_state_revision = next_ime_state_revision();
}
fn sync_ime_caret_state(&mut self) {
let state = self.current_ime_caret_state();
if self.ime_state_seen.as_ref() != Some(&state) {
self.ime_state_seen = Some(state);
self.reset_ime_state_revision();
}
}
/// Run one command as the input method's own transaction.
///
/// Commands run inside this scope are IME-origin: a successful command run *outside* this
/// scope sets the pending application-change signal, but nothing inside it does. Nested commands inherit
/// the origin. The previous origin is restored on every return, including unknown, disabled or
/// refused commands.
pub fn run_ime(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
let previous = std::mem::replace(&mut self.ime_origin, true);
let result = self.run(id, params);
self.ime_origin = previous;
result
}
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
/// control channel). Mutating commands are undoable; a failed command leaves the document as
/// it was; a panic inside a command becomes an error.
pub fn run(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
// Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled,
// protected) leave it at the default.
self.ime_edit_outcome = ImeEditOutcome::default();
// A rolled-back transaction must not leave a successful application-change signal from a
// nested command that ran before the failure.
let pending_application_change_before = self.pending_ime_application_change;
let join = std::mem::take(&mut self.join_next);
// The equation replacement promise is not a generic undo join: only the command that
// actually types into the equation may consume it. Any other command discards it.
let equation_join = std::mem::take(&mut self.equation_join_next);
// The body replacement promise is likewise scoped: only the `text.insert` at the
// selection the deletion left may consume it.
let ime_text_join = self.ime_text_join.take();
let reg = self.registry.clone();
let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) };
let join = join
|| (equation_join && matches!(id, "equation.type" | "equation.backspace"))
|| (id == "text.insert" && ime_text_join.as_ref().is_some_and(|sel| sel == &self.sel));
if let Some(why) = (spec.enabled)(self) {
return Err(CmdError::Disabled(format!("{id}: {why}")));
}
@@ -1461,6 +1744,10 @@ impl Session {
}
// The undo stacks are snapshotted whole, not as a length: commands run nested commands,
// whose checkpoints, undos and redos (and evictions at the limit) must be put back too.
let ime_epoch_before = spec.mutates.then_some(self.ime_body_epoch);
// Commands may leave an equation (a whole-body replacement) even after they fail; put
// the editing session back with the document.
let math_before = spec.mutates.then(|| self.math.clone());
let before_doc = if spec.mutates {
Some((
self.doc.clone(),
@@ -1515,6 +1802,7 @@ impl Session {
let default_language_before = self.prefs.default_language.clone();
let run = spec.run;
let mutates = spec.mutates;
let ime_caret_before = self.current_ime_caret_state();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
let mut result = match result {
Ok(r) => r,
@@ -1547,6 +1835,25 @@ impl Session {
}
}
self.clamp_selection();
let mut ime_outcome = self.ime_edit_outcome;
if ime_outcome.content_changed {
ime_outcome.state_changed = true;
}
// An unclassified mutating command is conservatively a state change. It can make
// a frame Other, but it can never mint a receipt because `classified` is false.
if !ime_outcome.classified && mutates {
ime_outcome.state_changed = true;
}
ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before;
self.ime_edit_outcome = ime_outcome;
if ime_outcome.state_changed {
let caret = self.current_ime_caret_state();
self.ime_state_seen = Some(caret);
self.reset_ime_state_revision();
}
if !self.ime_origin && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) {
self.pending_ime_application_change = true;
}
if sel_before.focus.story == StoryRef::Body && self.in_header_footer() {
self.hf_return = Some(sel_before.clone());
}
@@ -1569,8 +1876,16 @@ impl Session {
self.note_typing(id, before_doc.as_ref().map(|b| &b.0), &sel_before);
}
Err(e) => {
self.ime_edit_outcome = ImeEditOutcome::default();
self.pending_ime_application_change = pending_application_change_before;
self.autocap = None;
self.prefs.default_language = default_language_before;
if let Some(epoch) = ime_epoch_before {
self.ime_body_epoch = epoch;
}
if let Some(math) = math_before {
self.math = math;
}
if let Some(versions) = versions_before {
self.versions = versions;
}
@@ -1682,3 +1997,84 @@ pub fn blank_document(region: &wordcraft_geom::Region) -> Document {
d.last_section.page_h = h;
d
}
#[cfg(test)]
mod ime_revision_tests {
use super::*;
use serde_json::json;
#[test]
fn checked_revision_allocation_refuses_to_wrap() {
let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1);
assert_eq!(allocate_ime_state_revision(&counter), Some(u64::MAX - 1));
assert_eq!(allocate_ime_state_revision(&counter), None, "exhaustion fails closed");
assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), u64::MAX);
}
#[test]
fn selection_and_caret_motion_advance_the_unavailable_revision() {
let mut s = Session::new(Document::from_text("hello"));
let first = s.ime_state_revision().expect("a revision");
assert_eq!(s.ime_state_revision(), Some(first), "an unchanged caret reuses its revision");
s.sel = Selection::caret(Pos::body(0, 2));
let moved = s.ime_state_revision().expect("a revision");
assert!(moved > first, "a direct selection assignment is caught");
}
#[test]
fn successful_state_changes_allocate_but_selection_only_commands_do_not() {
let mut s = Session::new(Document::from_text("hello"));
let first = s.ime_state_revision().expect("a revision");
s.run("select.all", &json!({})).unwrap();
// `run` does not allocate for selection-only work; the next stamp sees the new caret.
assert!(s.ime_state_revision() > Some(first));
let selected = s.ime_state_revision().expect("a revision");
s.run("para.alignCenter", &json!({})).unwrap();
assert!(s.ime_state_revision() > Some(selected), "a committed state change advances");
}
#[test]
fn exhausted_allocator_keeps_a_real_equation_commit_uncertifiable() {
let mut s = Session::new(Document::new());
s.run("insert.equation", &json!({})).unwrap();
s.exhaust_ime_state_revision();
assert_eq!(s.ime_state_revision(), None, "exhaustion fails closed");
s.run_ime("equation.type", &json!({"text": "x"})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}");
assert_eq!(s.ime_state_revision(), None, "a real producer command cannot mint a revision");
}
#[test]
fn application_origin_signal_is_taken_once_and_ime_commands_do_not_set_it() {
let mut s = Session::new(Document::from_text("hello"));
s.run("para.alignCenter", &json!({})).unwrap();
assert!(s.take_pending_ime_application_change(), "a normal command signals");
assert!(!s.take_pending_ime_application_change(), "taking clears it");
s.run_ime("text.insert", &json!({"text": "x"})).unwrap();
assert!(!s.take_pending_ime_application_change(), "an IME transaction is not an application change");
s.run("text.insert", &json!({"text": "y"})).unwrap();
assert!(s.take_pending_ime_application_change());
}
#[test]
fn ime_origin_is_restored_and_nested_commands_inherit_it() {
let mut s = Session::new(Document::from_text("hello"));
s.run("tools.macros", &json!({"define": {"name": "centre", "steps": [{"command": "para.alignCenter", "params": {}}]}})).unwrap();
s.take_pending_ime_application_change();
// Unknown and refused commands restore the normal application origin; the failed run
// itself leaves no application-change signal.
assert!(s.run_ime("no.such.command", &json!({})).is_err());
assert!(!s.take_pending_ime_application_change(), "an unknown IME command is not an application change");
assert!(s.run_ime("equation.backspace", &json!({})).is_err());
assert!(!s.take_pending_ime_application_change(), "a refused IME command is not an application change");
// A nested `run` inherits the IME origin from `run_ime`; the same macro run normally is
// an application change.
s.run_ime("tools.macros", &json!({"run": "centre"})).unwrap();
assert!(!s.take_pending_ime_application_change(), "the nested command inherits the IME origin");
s.run("tools.macros", &json!({"run": "centre"})).unwrap();
assert!(s.take_pending_ime_application_change(), "a normal macro run is application-origin");
}
}
+96
View File
@@ -9536,3 +9536,99 @@ fn export_png_refuses_a_path_that_is_not_png() {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// The body's IME replacement epoch advances when the whole body is swapped (setText, Undo/Redo)
/// and stays put for ordinary edits; an equation editing session cannot ride across a replacement.
#[test]
fn ime_body_epoch_marks_body_replacements_not_edits() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "one"}));
let first = s.ime_body_epoch();
run(&mut s, "text.insert", json!({"text": " two"}));
assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement");
run(&mut s, "document.setText", json!({"text": "same text"}));
let replaced = s.ime_body_epoch();
assert!(replaced > first, "setText replaces the body under the same document id");
assert_eq!(s.document_id(), 1, "the document id itself is unchanged");
// The same text again still replaces the body: a composition must not survive on content
// equality.
run(&mut s, "document.setText", json!({"text": "same text"}));
assert!(s.ime_body_epoch() > replaced);
// Undo swaps the body back.
let before_undo = s.ime_body_epoch();
assert!(s.undo());
assert!(s.ime_body_epoch() > before_undo, "Undo replaces the body");
}
#[test]
fn a_body_replacement_ends_equation_editing() {
let mut s = s();
run(&mut s, "insert.equation", json!({"linear": "x+y"}));
assert!(s.math.is_some());
run(&mut s, "document.setText", json!({"text": "x+y"}));
assert!(s.math.is_none(), "the old equation's editing session is gone");
}
/// A failed command that cleared the equation session puts it back with the document.
#[test]
fn a_failed_replacement_keeps_equation_editing() {
let mut s = s();
run(&mut s, "insert.equation", json!({"linear": "x+y"}));
let session = s.math.as_ref().map(|m| m.session);
assert!(session.is_some());
// Missing `text` is rejected before any mutation.
assert!(s.run("document.setText", &json!({})).is_err());
assert_eq!(s.math.as_ref().map(|m| m.session), session);
}
/// Track Changes: deleting a range whose text is already marked deleted is a real no-op. It
/// creates no revision record, no undo-visible change and no IME revision.
#[test]
fn deleting_an_already_deleted_tracked_range_is_a_classified_noop() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "abc"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
run(&mut s, "text.backspace", json!({}));
let revisions = s.doc.revisions.len();
let revision = s.ime_state_revision().expect("an IME revision");
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
run(&mut s, "text.backspace", json!({}));
let outcome = s.ime_edit_outcome();
assert!(outcome.classified, "{outcome:?}");
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
assert_eq!(s.doc.revisions.len(), revisions, "no unused Delete revision is appended");
assert_eq!(s.ime_state_revision(), Some(revision), "the no-op did not move the settled caret");
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
assert!(s.ime_state_revision() > Some(revision), "re-selecting moves the caret, not the document");
}
/// `text.insert` classifies the automatic rewrites its own handler performs: smart quotes,
/// list autoformat and AutoCorrect are `transformed`, not the IME's literal insert.
#[test]
fn text_insert_marks_automatic_rewrites_as_transformed() {
let mut session = s();
run(&mut session, "text.insert", json!({"text": "\""}));
assert!(session.ime_edit_outcome().transformed, "smart quote: {:?}", session.ime_edit_outcome());
assert_eq!(text(&session), "\u{201C}");
let mut session = s();
for c in ["W", "o", "r", "d", " ", "(", "c", ")", " "] {
run(&mut session, "text.insert", json!({"text": c}));
}
assert!(session.ime_edit_outcome().transformed, "AutoCorrect: {:?}", session.ime_edit_outcome());
let mut session = s();
run(&mut session, "text.insert", json!({"text": "*"}));
run(&mut session, "text.insert", json!({"text": " "}));
assert!(session.ime_edit_outcome().transformed, "list AutoFormat: {:?}", session.ime_edit_outcome());
let mut session = s();
run(&mut session, "text.insert", json!({"text": "A--b"}));
run(&mut session, "text.insert", json!({"text": " "}));
assert!(session.ime_edit_outcome().transformed, "dash AutoFormat: {:?}", session.ime_edit_outcome());
assert_eq!(text(&session), "A\u{2014}b ");
}
+10 -1
View File
@@ -1998,10 +1998,19 @@ mod tests {
}
// A Thai font with OpenType tables, as the per-character fallback prefers.
assert!(f.has_layout_tables(), "{} shapes Thai", f.family);
// Without system fonts no bundled face covers Thai: the caller keeps the per-character path.
}
#[test]
fn a_run_without_a_covering_face_keeps_the_per_character_path() {
// Use only this repository's Latin faces. Optional craft-fonts inputs can contain a
// complete Thai face even when system font loading is disabled.
let bundled = FontDb::with_font_dirs(Vec::new());
bundled.faces.write().unwrap().retain(|face| BUNDLED.iter().any(|bytes| std::ptr::eq(face.data(), *bytes)));
bundled.set_system_fallback(false);
let latin = bundled.face(FALLBACK_FAMILY, "Regular");
let chars: Vec<char> = THAI_RUN.chars().filter(|c| !c.is_whitespace()).collect();
assert!(!bundled.faces.read().unwrap().is_empty(), "the negative fixture retains real Latin faces");
assert!(bundled.faces.read().unwrap().iter().all(|face| !face.covers('น')));
assert!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_none());
}
+73 -12
View File
@@ -129,7 +129,8 @@ pub enum ClKind {
Marker,
}
/// The smallest unit of layout: a grapheme cluster (or object, tab, break).
/// One drawing segment of a grapheme cluster (or object, tab, break).
/// Font/style changes may split its drawing; paragraph edit/wrap boundaries keep it atomic.
#[derive(Clone, Debug)]
pub struct Cluster {
pub start: usize,
@@ -357,6 +358,9 @@ pub struct ParaLayout {
/// Sum of line heights (without space before/after).
pub height: f32,
pub text_len: usize,
/// Drawing segments that start inside a whole-paragraph grapheme/complex-script cluster.
/// Font or run formatting can split the drawing, but cannot introduce an edit/wrap stop.
joined_cluster_starts: Vec<usize>,
/// Paragraph contains fields whose text depends on the page.
pub has_page_fields: bool,
/// Note references (part id) in this paragraph, by cluster index.
@@ -1893,6 +1897,19 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
Label { text: shown(text), ascent, style, glyphs, runs, x: 0.0, width: x }
});
// Shaping happens separately per font/style segment. Segment-local grapheme starts can
// therefore land inside a paragraph grapheme (e.g. an emoji whose components use different
// fallback faces). Retain the drawing segments and record only those illegal edit stops.
let joined_cluster_starts = if p.text.is_ascii() {
Vec::new()
} else {
let boundaries = wordcraft_doc::cluster::cluster_starts(&p.text);
let mut joined: Vec<usize> =
b.clusters.iter().filter_map(|c| (c.start < p.text.len() && boundaries.binary_search(&c.start).is_err()).then_some(c.start)).collect();
joined.sort_unstable();
joined.dedup();
joined
};
let mut pl = ParaLayout {
rp,
styles: b.styles,
@@ -1903,6 +1920,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
label,
height: 0.0,
text_len: p.text.len(),
joined_cluster_starts,
has_page_fields,
notes,
issues: env.proofing.map(|o| proof_issues(p, &runs, &o, env.typing_word, is_list_item(doc, p))).unwrap_or_default(),
@@ -1927,6 +1945,11 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let mark_left_out = env.hide_deleted.hides(doc, mark_rc.del) || (mark_rc.hidden && !env.show_hidden);
let gone = mark_left_out && (p.text.is_empty() || left.first() == Some(&(0..p.text.len())));
pl.left_out = left;
// Neither ordinary nor hyphenated wrapping may split a drawing group inside a grapheme.
for c in &mut pl.clusters {
c.break_after &= pl.joined_cluster_starts.binary_search(&c.end).is_err();
}
pl.hyph_after.retain(|&k| pl.clusters.get(k as usize).is_some_and(|c| pl.joined_cluster_starts.binary_search(&c.end).is_err()));
for k in pl.hyph_after.clone() {
if let Some(c) = pl.clusters.get_mut(k as usize) {
c.break_after = true;
@@ -2253,24 +2276,27 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
end = LineEnd::Wrap;
break;
}
// A grapheme can have several drawing segments; fit it as one unit while keeping
// each segment's font, glyphs and position for painting.
let advance = pl.atomic_advance(j);
// Text after a right/centre tab grows leftwards into the tab's space first.
let absorbs = pending_tab.is_some_and(|(tj, stop, _)| {
let room = pl.clusters.get(tj).map(|t| t.adv).unwrap_or(0.0);
match stop.align {
TabAlign::Right | TabAlign::Decimal => room >= c.adv,
TabAlign::Center => room >= c.adv / 2.0 && x + c.adv / 2.0 <= right_edge + 0.01,
TabAlign::Right | TabAlign::Decimal => room >= advance,
TabAlign::Center => room >= advance / 2.0 && x + advance / 2.0 <= right_edge + 0.01,
_ => false,
}
});
let shrink = if shrink_spaces && !has_tab { space_room(space_w, x + c.adv - pl.auto_space_after(j) - word_from) } else { 0.0 };
let shrink = if shrink_spaces && !has_tab { space_room(space_w, x + advance - pl.auto_space_after(j) - word_from) } else { 0.0 };
let fits =
absorbs || x + c.adv - pl.auto_space_after(j) <= right_edge + shrink + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
absorbs || x + advance - pl.auto_space_after(j) <= right_edge + shrink + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
// A line's first item stays on it even when it doesn't fit; markers before it
// (a content control's start, a bookmark) and floating objects' anchors take no room
// and don't count (Word: a picture a little wider than the line, after an anchor,
// stays on the first line rather than leaving the anchor a line of its own).
let takes_room = |c: &Cluster| c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0);
if !fits && (c0..j).any(|k| pl.clusters.get(k).is_some_and(takes_room)) {
if !fits && pl.is_edit_boundary(c.start) && (c0..j).any(|k| pl.clusters.get(k).is_some_and(takes_room)) {
// Wrap: back up to the last break opportunity on this line.
end = LineEnd::Wrap;
// Hyphenate only when the plain break leaves more than the hyphenation zone empty.
@@ -2305,11 +2331,14 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
// hyphenation point.
let full = base_right - row_lo;
skip = narrowed && skipped < 50 && widths.get(c0).is_some_and(|w| *w <= full + 0.01);
// Never split a ligature's letters across lines: the glyph sits on the first
// letter, so back up to it (unless it starts the line).
// Never split a ligature's letters or a grapheme's drawing segments across
// lines: back up to their first segment (unless it starts the line).
let glyphless = |k: usize| pl.clusters.get(k).is_some_and(|c| c.kind == ClKind::Text && c.g1 <= c.g0);
let mut b = j;
while b > c0 + 1 && glyphless(b) {
while b > c0 + 1 && (glyphless(b) || pl.clusters.get(b).is_some_and(|c| !pl.is_edit_boundary(c.start))) {
b -= 1;
}
while b > c0 && pl.clusters.get(b).is_some_and(|c| !pl.is_edit_boundary(c.start)) {
b -= 1;
}
if b < j
@@ -3001,6 +3030,27 @@ fn resolve_tab(pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>,
}
impl ParaLayout {
fn is_edit_boundary(&self, off: usize) -> bool {
self.joined_cluster_starts.binary_search(&off).is_err()
}
/// At a legal group start, include every following drawing segment of that grapheme.
/// Interior segments keep their own advance; they are never independent wrap candidates.
fn atomic_advance(&self, k: usize) -> f32 {
let Some(c) = self.clusters.get(k) else { return 0.0 };
let mut advance = c.adv;
if self.joined_cluster_starts.is_empty() || !self.is_edit_boundary(c.start) {
return advance;
}
for next in self.clusters.get(k + 1..).into_iter().flatten() {
if self.is_edit_boundary(next.start) {
break;
}
advance += next.adv;
}
advance
}
/// The automatic space in cluster `k`'s advance ([`auto_space`]), 0 without one.
fn auto_space_after(&self, k: usize) -> f32 {
self.auto_space.binary_search_by_key(&k, |a| a.0).ok().and_then(|i| self.auto_space.get(i)).map_or(0.0, |a| a.1)
@@ -3018,6 +3068,13 @@ impl ParaLayout {
/// x of byte offset `off` within line `li` (clamped to the line).
pub fn x_of(&self, li: usize, off: usize) -> Option<f32> {
let l = self.lines.get(li)?;
let off = if self.is_edit_boundary(off) {
off
} else {
// Explicit offsets at a font boundary inside a grapheme use its leading edge too.
let k = self.clusters.partition_point(|c| c.start < off);
self.clusters.get(..k).into_iter().flatten().rev().find(|c| self.is_edit_boundary(c.start)).map_or(0, |c| c.start)
};
if !l.vis.is_empty() {
return self.x_of_bidi(l, off);
}
@@ -3095,11 +3152,12 @@ impl ParaLayout {
}
}
/// Caret offsets a click or arrow key can reach on line `li`: each cluster start and the line
/// end, except after a line-ending break or past a wrapped line's trailing space.
/// Caret offsets a click or arrow key can reach on line `li`: whole-grapheme starts and the
/// line end, except after a line-ending break or past a wrapped line's trailing space.
pub fn line_offsets(&self, li: usize) -> Vec<usize> {
let Some(l) = self.lines.get(li) else { return Vec::new() };
let mut v: Vec<usize> = (l.c0..l.c1).filter_map(|k| self.clusters.get(k).map(|c| c.start)).collect();
let mut v: Vec<usize> =
(l.c0..l.c1).filter_map(|k| self.clusters.get(k).filter(|c| self.is_edit_boundary(c.start)).map(|c| c.start)).collect();
let last_break =
l.c1 > l.c0 && self.clusters.get(l.c1 - 1).is_some_and(|c| matches!(c.kind, ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak));
let wrapped = l.c1 > l.c0 && l.end == LineEnd::Wrap && self.lines.get(li + 1).is_some();
@@ -3127,6 +3185,9 @@ impl ParaLayout {
for k in l.c0..=l.c1 {
let Some(cx) = l.xs.get(k - l.c0) else { break };
let off = if k < l.c1 { self.clusters.get(k).map(|c| c.start).unwrap_or(l.stop) } else { l.stop };
if !self.is_edit_boundary(off) {
continue;
}
// Don't place the caret after a line-ending break or wrap-trailing space on a wrapped line.
if k == l.c1 && k > l.c0 {
let lastc = self.clusters.get(k - 1);
+344 -26
View File
@@ -11,6 +11,168 @@ fn lay(doc: &Document) -> DocLayout {
layout(doc, &mut c, &LayoutOptions::default())
}
/// These pagination fixtures test physical boundaries, not the host's Aptos substitution.
/// Keep their explicit spacing local; ordinary document/layout defaults remain unchanged.
pub(super) fn pagination_fixture_document(text: &str) -> Document {
let mut doc = Document::from_text(text);
doc.styles.default_chr.font = Some(WRAP_TEST_FONT.into());
doc.styles.default_chr.size = Some(12.0);
for block in &mut doc.body {
if let Block::Para(para) = Arc::make_mut(block) {
para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0));
para.props.space_before = Some(0.0);
para.props.space_after = Some(8.0);
}
}
doc
}
pub(super) fn assert_pagination_fixture_font(layout: &DocLayout) {
let mut paragraphs = 0;
for page in &layout.pages {
for item in &page.items {
if let Placed::Lines { para, .. } = item {
paragraphs += 1;
for style in &para.styles {
assert_eq!(style.rc.font, WRAP_TEST_FONT, "the fixture requests its bundled face");
assert_eq!(style.face.family, WRAP_TEST_FONT, "the fixture must not silently substitute another family");
assert_eq!(style.face.style, "Regular", "the fixture must resolve the intended regular face");
assert_eq!(style.size, 12.0, "the fixture keeps its explicit point size");
assert!(
style.face.data() == include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").as_slice(),
"the selected face must be the exact already-bundled regular font bytes"
);
}
}
}
}
assert!(paragraphs > 0, "font assertions must inspect actual laid-out paragraphs");
}
/// Preserve the 22 short filler paragraphs while giving the following row a measured boundary.
fn pagination_filler_leaving(room: f32) -> Document {
let lines: Vec<String> = (1..=22).map(|i| format!("Filler {i}")).collect();
let mut doc = pagination_fixture_document(&lines.join("\n"));
let after = doc.last_section.text_height() - room - 22.0 * 14.0 - 21.0 * 8.0;
assert!(after >= 8.0, "the last gap may add room-taking space, not remove filler content");
doc.para_mut(StoryRef::Body, &Path::top(21)).unwrap().props.space_after = Some(after);
let probe = lay(&doc);
assert_pagination_fixture_font(&probe);
assert_eq!(probe.pages.len(), 1, "all filler must precede the row on page one");
let lines: Vec<_> = probe.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, para, y, l0, l1, .. } if path.0.len() == 1 => Some((para, *y, l1 - l0)),
_ => None,
})
.collect();
assert_eq!(lines.len(), 22, "every filler paragraph remains");
assert!(lines.iter().all(|(_, _, count)| *count == 1), "filler text must remain one line per paragraph");
let (last, y, _) = lines.last().unwrap();
let remaining = probe.pages[0].body.bottom() - y - last.height - last.rp.space_after;
assert!((remaining - room).abs() < 0.02, "requested {room}pt of actual row room, got {remaining}pt");
assert!(remaining > 0.0 && (remaining < 63.6) == (room < 63.6), "the measured room must be on the intended side of the row boundary");
doc
}
/// Failure-only evidence for the selected pagination fixtures below. Inspect the already produced
/// styles and placements, not a second run.
pub(super) fn pagination_fixture_diagnostics(label: &str, doc: Option<&Document>, layout: &DocLayout) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "\npagination fixture {label}: {} pages", layout.pages.len());
for (pi, page) in layout.pages.iter().enumerate().take(8) {
let _ = writeln!(
out,
"page={pi} body={:?} first_block={} floats={:?} items={}",
page.body,
page.first_block,
page.float_tables,
page.items.len()
);
let mut body_bottom = None::<(f32, f32)>;
for item in &page.items {
if let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, y, .. } = item
&& path.0.len() == 1
&& let (Some(first), Some(last)) = (para.lines.get(*l0), para.lines.get(l1.saturating_sub(1)))
{
let bottom = y + last.top + last.height - first.top;
if body_bottom.is_none_or(|(prior, _)| bottom > prior) {
body_bottom = Some((bottom, para.rp.space_after));
}
}
}
if let Some((bottom, after)) = body_bottom {
let _ = writeln!(
out,
" top-level body text bottom={bottom:.3} trailing_space_after={after:.3} remaining_to_body_bottom={:.3} remaining_after_unadjusted_space={:.3}",
page.body.bottom() - bottom,
page.body.bottom() - bottom - after
);
}
for (ii, item) in page.items.iter().enumerate().filter(|(i, _)| *i < 32 || i.saturating_add(16) >= page.items.len()) {
match item {
Placed::Lines { story, path, para, l0, l1, x, y, turn } => {
let text = doc.and_then(|d| d.para(*story, path)).map(|p| p.text.chars().take(96).collect::<String>());
let _ = writeln!(
out,
" item={ii} Lines story={story:?} path={path:?} text={text:?} xy=({x:.3},{y:.3}) turn={turn:?} lines={l0}..{l1}/{} para_height={:.3} before={:.3} after={:.3} spacing={:?}",
para.lines.len(),
para.height,
para.rp.space_before,
para.rp.space_after,
para.rp.line_spacing
);
for (li, line) in
para.lines.iter().enumerate().filter(|(i, _)| *i >= *l0 && *i < *l1 && (*i == *l0 || i.saturating_add(1) == *l1))
{
let _ = writeln!(
out,
" line={li} bytes={}..{} top={:.3} height={:.3} baseline={:.3} text_asc={:.3} text_desc={:.3} end={:?}",
line.start, line.stop, line.top, line.height, line.baseline, line.text_asc, line.text_desc, line.end
);
}
for (si, st) in
para.styles.iter().enumerate().filter(|(i, _)| *i == 0 || para.clusters.iter().any(|c| c.style as usize == *i)).take(4)
{
let (asc, desc) = wordcraft_fonts::word::line_metrics(&st.face);
let raw_height = (asc + desc) * f64::from(st.size) / st.face.upem;
let _ = writeln!(
out,
" style={si} requested={:?} actual={:?}/{:?} face_id={} upem={} size={} word_asc={} word_desc={} raw_height_pt={raw_height:.3} resolved_asc={:.3} resolved_desc={:.3}",
st.rc.font,
st.face.family,
st.face.style,
st.face.id(),
st.face.upem,
st.size,
asc,
desc,
st.ascent,
st.descent
);
}
}
Placed::Cell { rect, table, row, cell, story, clip, .. } => {
let _ = writeln!(out, " item={ii} Cell table={table:?} story={story:?} row={row} cell={cell} rect={rect:?} clip={clip}");
}
Placed::Object { rect, story, path, off, text_box, .. } => {
let _ = writeln!(out, " item={ii} Object story={story:?} path={path:?} off={off} textbox={text_box:?} rect={rect:?}");
}
_ => {}
}
}
if page.items.len() > 48 {
let _ = writeln!(out, " omitted {} middle items (first32/last16 retained)", page.items.len() - 48);
}
}
if layout.pages.len() > 8 {
let _ = writeln!(out, "omitted {} later pages", layout.pages.len() - 8);
}
out
}
#[derive(Clone, Copy)]
enum SpacingLocation {
Cell,
@@ -4697,8 +4859,6 @@ fn exact_rows_add_only_the_bottom_margin() {
#[test]
fn exact_row_moves_whole() {
use wordcraft_doc::props::HeightRule;
let lines: Vec<String> = (1..=200).map(|i| format!("Filler {i}")).collect();
let one_page = lay(&Document::from_text(&lines.join("\n"))).pages[0].items.iter().filter(|i| matches!(i, Placed::Lines { .. })).count();
let mut t = Table::new(1, 1, 400.0);
t.rows[0].props.height = Some(63.6);
t.rows[0].props.height_rule = HeightRule::Exact;
@@ -4707,12 +4867,27 @@ fn exact_row_moves_whole() {
p.insert_object(0, picture(30.0, height, Default::default()), &Default::default()).unwrap();
t.rows[0].cells[0].blocks.push(wordcraft_doc::para_block(p));
}
let mut d = Document::from_text(&lines[..one_page - 2].join("\n"));
let end = d.body.len();
d.insert_block(StoryRef::Body, &Path::top(end), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let row_page = l.pages.iter().position(|p| p.items.iter().any(|i| matches!(i, Placed::Cell { row: 0, .. })));
assert_eq!(row_page, Some(1), "Word keeps an exact row together when it fits on the next page");
// The same intact row moves with only 50pt left, and stays when 90pt is available.
for (room, expected_page) in [(50.0, 1), (90.0, 0)] {
assert_eq!(room < 63.6, expected_page == 1);
let mut d = pagination_filler_leaving(room);
let end = d.body.len();
d.insert_block(StoryRef::Body, &Path::top(end), wordcraft_doc::Block::Table(t.clone())).unwrap();
let l = lay(&d);
assert_pagination_fixture_font(&l);
let row_pages: Vec<_> = l
.pages
.iter()
.enumerate()
.flat_map(|(page, p)| p.items.iter().filter_map(move |i| matches!(i, Placed::Cell { row: 0, .. }).then_some(page)))
.collect();
assert_eq!(
row_pages,
vec![expected_page],
"Word keeps an exact row whole; available={room} height=63.6{}",
pagination_fixture_diagnostics("exact_row_moves_whole", Some(&d), &l)
);
}
}
/// `cell_margins` is what the layout uses: a cell's own margins, else the table's, else Word's;
@@ -5317,9 +5492,35 @@ fn a_floating_table_ignores_a_paragraphs_empty_lines() {
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
let mut doc = Document::from_text(&[""; 9].join("\n"));
let mut doc = pagination_fixture_document(&[""; 9].join("\n"));
// Visible's line breaks belong inside its paragraph, not separate body paragraphs.
doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Visible\n\n\n", Default::default())));
doc.body.extend((0..14).map(|_| wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())));
for block in &mut doc.body {
if let Block::Para(para) = Arc::make_mut(block) {
para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0));
para.props.space_before = Some(0.0);
para.props.space_after = Some(8.0);
}
}
let before_table = lay(&doc);
assert_pagination_fixture_font(&before_table);
assert_eq!(doc.body.len(), 24, "all original body paragraphs remain");
assert_eq!(before_table.pages.len(), 1, "the table's preceding body/anchor must be on page zero");
let preceding: Vec<_> = before_table.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, para, y, .. } if path.0.len() == 1 => Some((path.0[0], *y + para.height)),
_ => None,
})
.collect();
assert_eq!(preceding.iter().map(|(block, _)| *block).collect::<Vec<_>>(), (0..24).collect::<Vec<_>>());
assert!(preceding.last().unwrap().1 < before_table.pages[0].body.bottom(), "the final preceding paragraph actually fits the anchor page");
assert!(
before_table.pages[0].items.iter().any(|it| matches!(it, Placed::Lines { path, para, .. } if path.0 == [9] && para.lines.len() == 4)),
"the visible paragraph retains all three trailing empty lines"
);
let mut table = Table::new(12, 1, 300.0);
for (i, row) in table.rows.iter_mut().enumerate() {
row.cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Approval {i}"), Default::default()))];
@@ -5329,6 +5530,18 @@ fn a_floating_table_ignores_a_paragraphs_empty_lines() {
doc.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(table)).unwrap();
let layout = lay(&doc);
assert_pagination_fixture_font(&layout);
let cells: Vec<_> = layout
.pages
.iter()
.flat_map(|page| &page.items)
.filter_map(|it| match it {
Placed::Cell { row, rect, .. } => Some((*row, *rect)),
_ => None,
})
.collect();
assert_eq!(cells.iter().map(|(row, _)| *row).collect::<Vec<_>>(), (0..12).collect::<Vec<_>>(), "every floating row remains once");
assert!((cells[0].1.y - (before_table.pages[0].body.y + 50.0)).abs() < 0.01, "the table keeps its margin-relative 50pt anchor");
let table_pages: Vec<usize> = layout
.pages
.iter()
@@ -5340,7 +5553,11 @@ fn a_floating_table_ignores_a_paragraphs_empty_lines() {
})
})
.collect();
assert!(!table_pages.is_empty() && table_pages.iter().all(|page| *page == 0), "table rows on pages {table_pages:?}");
assert!(
!table_pages.is_empty() && table_pages.iter().all(|page| *page == 0),
"table rows on pages {table_pages:?}; table_block={at} margin_anchor_y=50{}",
pagination_fixture_diagnostics("a_floating_table_ignores_a_paragraphs_empty_lines", Some(&doc), &layout)
);
}
#[test]
@@ -6143,25 +6360,42 @@ fn an_inherited_footer_sizes_its_percentage_table_by_its_own_section() {
#[test]
fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() {
use wordcraft_doc::props::HeightRule;
// Measured in Word: with 49 pt left on the page, a row of eight lines splits, but the same row
// with an at-least height of 63.6 pt goes to the next page whole.
// Exactly 50pt of measured room remains. The same eight paragraphs split without a minimum,
// but an at-least height of 63.6pt moves the row to the next page whole.
let first_row_line_page = |min: Option<f32>| {
let lines: Vec<String> = (1..=200).map(|i| format!("Filler {i}")).collect();
let one_page = lay(&Document::from_text(&lines.join("\n"))).pages[0].items.iter().filter(|i| matches!(i, Placed::Lines { .. })).count();
let mut d = pagination_filler_leaving(50.0);
let mut t = Table::new(1, 1, 400.0);
t.rows[0].props.height = min;
t.rows[0].props.height_rule = HeightRule::AtLeast;
t.rows[0].cells[0].blocks =
(1..=8).map(|k| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Row line {k}"), Default::default()))).collect();
// Fill the page, leaving two filler paragraphs' room (less than 63.6 pt) for the row.
let mut d = Document::from_text(&lines[..one_page - 2].join("\n"));
let end = d.body.len();
d.insert_block(StoryRef::Body, &Path::top(end), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
l.pages.iter().position(|p| p.items.iter().any(|i| matches!(i, Placed::Lines { path, .. } if path.0.first() == Some(&(end as u32)))))
assert_pagination_fixture_font(&l);
let row_lines: Vec<_> = l
.pages
.iter()
.enumerate()
.flat_map(|(page, p)| {
p.items.iter().filter_map(move |i| match i {
Placed::Lines { path, l0, l1, .. } if path.0.first() == Some(&(end as u32)) => Some((page, l1 - l0)),
_ => None,
})
})
.collect();
assert_eq!(row_lines.iter().map(|(_, count)| count).sum::<usize>(), 8, "all eight row paragraphs remain");
(row_lines.first().map(|(page, _)| *page), d, l)
};
assert_eq!(first_row_line_page(None), Some(0), "a row without a minimum height splits");
assert_eq!(first_row_line_page(Some(63.6)), Some(1), "a row with a minimum height that doesn't fit moves on");
let (page, d, l) = first_row_line_page(None);
assert_eq!(page, Some(0), "a row without a minimum height splits{}", pagination_fixture_diagnostics("at_least_row/no_minimum", Some(&d), &l));
let (page, d, l) = first_row_line_page(Some(63.6));
assert_eq!(
page,
Some(1),
"a row with a minimum height that doesn't fit moves on; available=50 height=63.6{}",
pagination_fixture_diagnostics("at_least_row/minimum", Some(&d), &l)
);
}
/// #1742: a paragraph that leaves `room` points of the letter page's 648 pt body, then the table
@@ -6169,13 +6403,66 @@ fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() {
/// table comes first, at the top of the page. Returns the layout and, per page, the table rows
/// that have a cell there.
fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayout, Vec<Vec<usize>>) {
let mut d = Document::from_text("Text above the table");
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(648.0 - room - 14.0);
let mut d = pagination_fixture_document("Text above the table");
let at = if room >= 648.0 { 0 } else { 1 };
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(if at == 0 { 0.0 } else { 648.0 - room - 14.0 });
let before_table = lay(&d);
assert_pagination_fixture_font(&before_table);
assert_eq!(before_table.pages.len(), 1);
if at == 1 {
let (para, y) = before_table.pages[0]
.items
.iter()
.find_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, para, y, .. } if path.0 == [0] => Some((para, *y)),
_ => None,
})
.unwrap();
let remaining = before_table.pages[0].body.bottom() - y - para.height - para.rp.space_after;
assert!((remaining - room).abs() < 0.02, "the measured filler leaves {remaining}pt, expected {room}pt");
}
let mut t = Table::new(rows, 1, 468.0);
build(&mut t);
let at = if room >= 648.0 { 0 } else { 1 };
for row in &mut t.rows {
for cell in &mut row.cells {
for block in &mut cell.blocks {
if let Block::Para(para) = Arc::make_mut(block) {
para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0));
para.props.space_before = Some(0.0);
para.props.space_after = Some(0.0);
}
}
}
}
d.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
assert_pagination_fixture_font(&l);
let trailing: Vec<_> = l
.pages
.iter()
.enumerate()
.flat_map(|(page, p)| {
p.items.iter().filter_map(move |it| match it {
Placed::Lines { story: StoryRef::Body, path, .. } if path.0 == [if at == 0 { 1 } else { 0 }] => Some(page),
_ => None,
})
})
.collect();
assert_eq!(trailing.len(), 1, "the real paragraph before/after the table is retained exactly once");
assert_eq!(d.para(StoryRef::Body, &Path::top(if at == 0 { 1 } else { 0 })).unwrap().text, "Text above the table");
if at == 0 {
let first_cell = l.pages[0]
.items
.iter()
.find_map(|it| match it {
Placed::Cell { row: 0, rect, .. } => Some(rect),
_ => None,
})
.unwrap();
assert!((first_cell.y - l.pages[0].body.y).abs() < 0.01, "the tested table really begins at the page top");
let last_table_page = l.pages.iter().rposition(|p| p.items.iter().any(|it| matches!(it, Placed::Cell { .. }))).unwrap();
assert!(trailing[0] >= last_table_page, "the retained paragraph follows the table");
}
let pages = l
.pages
.iter()
@@ -6255,12 +6542,34 @@ fn an_at_least_row_at_the_top_of_a_page_splits_instead_of_moving_on() {
use wordcraft_doc::props::HeightRule;
// At the top of a page (or under the header rows repeated there) moving on gains nothing: a
// row taller than the room splits, and pagination ends.
let (_, pages) = at_least_rows(648.0, 1, |t| {
let (l, pages) = at_least_rows(648.0, 1, |t| {
t.rows[0].props.height = Some(700.0);
t.rows[0].props.height_rule = HeightRule::AtLeast;
t.rows[0].cells[0].blocks = lines_in_cell(80, "Line");
});
assert!(pages.len() >= 2 && pages.iter().all(|p| p == &vec![0]), "{pages:?}");
assert!(
pages.len() >= 2 && pages.iter().all(|p| p == &vec![0]),
"{pages:?}; helper retains body block1 \"Text above the table\" after table block0{}",
pagination_fixture_diagnostics("at_least_row/top_of_page", None, &l)
);
let data_lines = |layout: &DocLayout, row: u32| {
let mut paragraphs = std::collections::BTreeSet::new();
let count = layout
.pages
.iter()
.flat_map(|p| &p.items)
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, l0, l1, .. } if path.0.len() == 4 && path.0[..3] == [0, row, 0] => {
paragraphs.insert(path.0[3]);
Some(l1 - l0)
}
_ => None,
})
.sum::<usize>();
assert_eq!(paragraphs.into_iter().collect::<Vec<_>>(), (0..80).collect::<Vec<_>>(), "all eighty original cell paragraphs remain");
assert_eq!(count, 80, "every short cell paragraph is drawn exactly once");
};
data_lines(&l, 0);
// A 100 pt header row, then a row at least 600 pt with 80 lines: under the header only 548 pt
// are left on each page, so the row splits there instead of running past the bottom margin.
let (l, pages) = at_least_rows(648.0, 2, |t| {
@@ -6271,10 +6580,19 @@ fn an_at_least_row_at_the_top_of_a_page_splits_instead_of_moving_on() {
t.rows[1].props.height_rule = HeightRule::AtLeast;
t.rows[1].cells[0].blocks = lines_in_cell(80, "Line");
});
assert!(pages.len() >= 3 && pages.iter().all(|p| p == &vec![0, 1]), "{pages:?}");
assert!(
pages.len() >= 3 && pages.iter().all(|p| p == &vec![0, 1]),
"{pages:?}; repeated100pt header plus minimum600pt row{}",
pagination_fixture_diagnostics("at_least_row/repeated_header", None, &l)
);
data_lines(&l, 1);
for p in &l.pages {
let bottom = p.items.iter().filter_map(|i| if let Placed::Cell { rect, .. } = i { Some(rect.y + rect.h) } else { None }).fold(0.0, f32::max);
assert!(bottom <= 720.0 + 0.5, "a row part runs past the bottom margin to {bottom}");
assert!(
bottom <= 720.0 + 0.5,
"a row part runs past the bottom margin to {bottom}{}",
pagination_fixture_diagnostics("at_least_row/repeated_header_bottom", None, &l)
);
}
}
+92
View File
@@ -171,6 +171,98 @@ fn an_emoji_zwj_sequence_stays_one_cluster() {
assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}");
}
/// Font/style fragments still draw independently, but every user-facing stop and emergency
/// wrap must be at a whole paragraph grapheme boundary, in LTR and bidirectional lines alike.
fn assert_atomic_drawing_group(d: &Document, legal: &[usize], start: usize, inside: usize, end: usize) {
let l = lay(d);
let pl = para_layout(&l);
assert!(pl.clusters.iter().any(|c| c.start == inside), "fixture must actually split the drawing inside the grapheme");
let li = pl.line_of(start);
assert_eq!(pl.x_of(li, inside), pl.x_of(li, start), "an explicit internal offset uses the grapheme's leading edge");
let mut reachable: Vec<_> = (0..pl.lines.len()).flat_map(|i| pl.line_offsets(i)).collect();
reachable.sort_unstable();
reachable.dedup();
assert_eq!(reachable, legal, "every expected grapheme boundary remains reachable");
if d.last_section.text_width() <= 18.01 {
assert!(pl.lines.len() > 1, "the narrow fixture must actually exercise wrapping");
}
for (li, line) in pl.lines.iter().enumerate() {
assert!(legal.contains(&line.start) && legal.contains(&line.stop), "wrap cannot split a grapheme: {}..{}", line.start, line.stop);
assert!(pl.line_offsets(li).iter().all(|o| legal.contains(o)), "only legal keyboard stops");
let left = (line.c0..line.c1).filter_map(|k| line.cl_left(k)).fold(line.left, f32::min);
let right = (line.c0..line.c1).filter_map(|k| line.cl_right(k)).fold(line.right, f32::max);
for sample in 0..=128 {
let x = left + (right - left) * sample as f32 / 128.0;
let off = pl.off_at_x(li, x);
assert!(legal.contains(&off), "click at {x} reached internal offset {off}");
}
}
// The whole group's selection covers every differently drawn component without gaps.
let rects = l.selection_rects(d, &Pos::body(0, start), &Pos::body(0, end), 0);
let width: f32 = rects.iter().map(|(_, r)| r.w).sum();
let expected = (pl.x_of(li, end).unwrap() - pl.x_of(li, start).unwrap()).abs();
assert!(width > 0.0 && (width - expected).abs() < 0.05, "whole-grapheme selection: {width}, expected {expected}");
}
#[test]
fn a_mixed_style_emoji_grapheme_has_atomic_caret_hit_and_wrap_boundaries() {
for bidi in [false, true] {
let mut d = Document::from_text("x\u{1F469}\u{200D}\u{1F4BB}y");
d.format_range(&Pos::body(0, 0), &Pos::body(0, 13), &|c| {
c.font = Some("Source Sans 3".into());
c.size = Some(24.0);
})
.unwrap();
d.format_range(&Pos::body(0, 8), &Pos::body(0, 12), &|c| c.size = Some(48.0)).unwrap();
d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi = Some(bidi);
let wide = lay(&d);
let pl = para_layout(&wide);
if bidi {
assert!(pl.lines.iter().any(|line| !line.vis.is_empty()), "exercise the bidi hit/caret path");
}
let styles: Vec<_> = pl.clusters.iter().filter(|c| c.start >= 1 && c.end <= 12).map(|c| pl.styles[c.style as usize].size).collect();
assert!(styles.contains(&24.0) && styles.contains(&48.0), "both drawing sizes must survive: {styles:?}");
assert_atomic_drawing_group(&d, &[0, 1, 12, 13], 1, 8, 12);
for width in [18.0, 32.0, 48.0, 96.0] {
d.last_section.page_w = 144.0 + width;
assert_atomic_drawing_group(&d, &[0, 1, 12, 13], 1, 8, 12);
}
}
}
#[test]
fn a_mixed_bundled_font_combining_grapheme_preserves_drawing_and_atomic_boundaries() {
for bidi in [false, true] {
let mut d = Document::from_text("xa\u{301}b");
d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| {
c.font = Some("Source Sans 3".into());
c.size = Some(24.0);
})
.unwrap();
d.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| {
c.font = Some("Source Serif 4".into());
c.size = Some(48.0);
})
.unwrap();
d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi = Some(bidi);
let wide = lay(&d);
let pl = para_layout(&wide);
if bidi {
assert!(pl.lines.iter().any(|line| !line.vis.is_empty()), "exercise the bidi hit/caret path");
}
let base = pl.clusters.iter().find(|c| c.start == 1).unwrap();
let mark = pl.clusters.iter().find(|c| c.start == 2).unwrap();
assert_eq!(pl.styles[base.style as usize].face.family, "Source Sans 3");
assert_eq!(pl.styles[mark.style as usize].face.family, "Source Serif 4");
assert!(base.g1 > base.g0 && mark.g1 > mark.g0, "both independently shaped pieces keep their glyphs");
assert_atomic_drawing_group(&d, &[0, 1, 4, 5], 1, 2, 4);
for width in [18.0, 32.0, 48.0, 96.0] {
d.last_section.page_w = 144.0 + width;
assert_atomic_drawing_group(&d, &[0, 1, 4, 5], 1, 2, 4);
}
}
}
#[test]
fn an_indic_conjunct_stays_one_cluster() {
// क्ष: ka, virama, ssa — one conjunct; the caret never stops inside it.
+95 -14
View File
@@ -186,25 +186,106 @@ fn inline_box_that_resizes_to_fit_takes_its_texts_height() {
let brk = wordcraft_doc::para::PAGE_BREAK;
let doc = |auto_fit: bool| {
let filler: Vec<String> = (1..=FILLER).map(|i| format!("Filler line {i}")).collect();
let mut d = Document::from_text(&format!("{}\n\n\n{brk}\nSecond page text.", filler.join("\n")));
let mut d = super::tests::pagination_fixture_document(&format!("{}\n\n\n{brk}\nSecond page text.", filler.join("\n")));
// Exact spacing is only for the filler/short text. The object paragraph must retain
// automatic spacing so its inline box really contributes its physical height.
d.para_mut(StoryRef::Body, &Path::top(FILLER)).unwrap().props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.15));
let probe = lay(&d);
super::tests::assert_pagination_fixture_font(&probe);
let body_para = |layout: &DocLayout, index: usize| {
layout
.pages
.iter()
.enumerate()
.find_map(|(page, p)| {
p.items.iter().find_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, para, y, .. } if path.0 == [index as u32] => {
Some((page, *y, para.height, para.rp.space_after))
}
_ => None,
})
})
.unwrap()
};
let (anchor_page, anchor_y, _, _) = body_para(&probe, FILLER);
assert_eq!(anchor_page, 0, "all twenty-two original filler paragraphs must precede the box on page one");
assert_eq!(d.body.len(), FILLER + 4, "all original blank, break and following paragraphs remain");
assert_eq!(d.para(StoryRef::Body, &Path::top(FILLER + 1)).unwrap().text, "");
assert_eq!(d.para(StoryRef::Body, &Path::top(FILLER + 2)).unwrap().text, brk.to_string());
let available = probe.pages[0].body.bottom() - anchor_y;
let (_, _, blank_h, blank_after) = body_para(&probe, FILLER + 1);
let (_, _, break_h, break_after) = body_para(&probe, FILLER + 2);
let id = text_box(&mut d, FILLER, "", 453.5, 283.46, TextBody { auto_fit, anchor: VAlign::Center, ..Default::default() });
d.parts.get_mut(&id).unwrap().blocks =
(1..=3).map(|i| para_block(Paragraph::with_text(&format!("Text box line {i}"), Default::default()))).collect();
d.parts.get_mut(&id).unwrap().blocks = (1..=3)
.map(|i| {
let mut p = Paragraph::with_text(&format!("Text box line {i}"), Default::default());
p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0));
p.props.space_before = Some(0.0);
p.props.space_after = Some(8.0);
para_block(p)
})
.collect();
let l = lay(&d);
let r = l.pages.iter().find_map(|p| {
p.items.iter().find_map(|it| match it {
Placed::Object { rect, text_box: Some(s), .. } if *s == id => Some(*rect),
super::tests::assert_pagination_fixture_font(&l);
let (object_page, r) = l
.pages
.iter()
.enumerate()
.find_map(|(page, p)| {
p.items.iter().find_map(|it| match it {
Placed::Object { rect, text_box: Some(s), .. } if *s == id => Some((page, *rect)),
_ => None,
})
})
.unwrap();
let (_, _, object_line_h, object_after) = body_para(&l, FILLER);
let needed = object_line_h + object_after + blank_h + blank_after + break_h + break_after;
assert!(available > 0.0, "the box anchor must have measured space available");
if auto_fit {
assert!(needed < available - 1.0, "auto-fit box plus blank and break need {needed}pt, only {available}pt available");
} else {
assert!(r.h > available + 1.0, "the fixed 10cm box itself must exceed the {available}pt remaining");
}
let shown: Vec<_> = l
.pages
.iter()
.flat_map(|p| &p.items)
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Part(s), path, l0, l1, .. } if *s == id => Some((path.0.clone(), l1 - l0)),
_ => None,
})
});
(l.pages.len(), r.unwrap())
.collect();
assert_eq!(shown.iter().map(|(_, count)| count).sum::<usize>(), 3, "all three original text-box paragraphs remain visible");
assert_eq!(shown.iter().map(|(path, _)| path.clone()).collect::<Vec<_>>(), vec![vec![0], vec![1], vec![2]]);
assert_eq!(object_page, usize::from(!auto_fit), "the real inline box moves only when its saved height cannot fit");
assert_eq!(body_para(&l, FILLER + 2).0, usize::from(!auto_fit), "the explicit page break follows the box on its page");
assert_eq!(body_para(&l, FILLER + 3).0, if auto_fit { 1 } else { 2 }, "the following text stays after the explicit break");
(l.pages.len(), r, d, l)
};
let (pages, r) = doc(true);
assert!(r.h > 3.0 * 12.0 && r.h < 80.0, "the box fits its three lines: {r:?}");
assert_eq!(pages, 2, "the page break stays on the first page");
let (pages, saved) = doc(false);
assert_eq!(pages, 3, "a box keeping its 10 cm pushes the page break over");
assert!((saved.h - 283.46).abs() < 1.0, "without auto-fit the box keeps its saved height: {saved:?}");
let (pages, r, d, l) = doc(true);
assert!(
r.h > 3.0 * 12.0 && r.h < 80.0,
"the box fits its three lines: {r:?}{}",
super::tests::pagination_fixture_diagnostics("inline_autofit/height", Some(&d), &l)
);
assert_eq!(
pages,
2,
"the page break stays on the first page; filler={FILLER} box={r:?}{}",
super::tests::pagination_fixture_diagnostics("inline_autofit/pages", Some(&d), &l)
);
let (pages, saved, d, l) = doc(false);
assert_eq!(
pages,
3,
"a box keeping its 10 cm pushes the page break over; filler={FILLER} box={saved:?}{}",
super::tests::pagination_fixture_diagnostics("inline_fixed/pages", Some(&d), &l)
);
assert!(
(saved.h - 283.46).abs() < 1.0,
"without auto-fit the box keeps its saved height: {saved:?}{}",
super::tests::pagination_fixture_diagnostics("inline_fixed/height", Some(&d), &l)
);
}
/// Filler paragraphs before the box in [`inline_box_that_resizes_to_fit_takes_its_texts_height`]:
+9 -7
View File
@@ -9,23 +9,23 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi
workspace = true
[dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" }
# Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n
# catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in
# `craft-i18n.rev`). Its own rev: lazy per-locale parsing (storytold/craft-libs#247).
craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "f7cc8a7df15e824b02cf73859567a6242d3a7de4" }
wordcraft-geom = { workspace = true }
wordcraft-doc = { workspace = true }
unicode-bidi = { workspace = true }
wordcraft-fonts = { workspace = true }
wordcraft-layout = { workspace = true }
wordcraft-render = { workspace = true }
wordcraft-engine = { workspace = true }
wordcraft-zotero = { workspace = true }
egui = { workspace = true }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
web-time = { workspace = true }
# Pictures pasted from the system clipboard arrive as pixels and are inserted as PNG (#45).
image = { workspace = true }
# The system's preferred interface languages (#8) and region (#838). Native: OS APIs; wasm:
@@ -38,12 +38,14 @@ sys-locale = { version = "0.3", features = ["js"] }
[target.'cfg(target_os = "linux")'.dependencies]
zbus = "=5.19.0"
[target.'cfg(target_arch = "wasm32")'.dependencies]
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32).
js-sys = "0.3"
[dev-dependencies]
cfb = { workspace = true }
# Producer tests simulate a globally exhausted IME revision allocator through the engine's
# explicit test-support feature; production builds of this crate never enable it.
wordcraft-engine = { workspace = true, features = ["test-support"] }
# kittest's own dev-dependencies do not propagate their font features. Declare the fonts the
# interface tests lay out explicitly: default faces plus monochrome emoji fallbacks.
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
zip = { workspace = true }
+3 -1
View File
@@ -18,7 +18,8 @@ static READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new
fn step(h: &mut egui_kittest::Harness<'_, WordApp>) -> f64 {
let mut raw = std::mem::take(h.input_mut());
h.state_mut().raw_input_hook(&mut raw);
let ctx = h.ctx.clone();
h.state_mut().raw_input_hook(&ctx, &mut raw);
*h.input_mut() = raw;
let t = Instant::now();
h.step();
@@ -108,6 +109,7 @@ fn main() {
delta: egui::vec2(0.0, dy),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
source: egui::MouseWheelSource::Unknown,
});
f.push(step(h));
}
+2 -1
View File
@@ -86,7 +86,8 @@ fn main() {
fn step(harness: &mut egui_kittest::Harness<'_, WordApp>) {
let mut raw = std::mem::take(harness.input_mut());
harness.state_mut().raw_input_hook(&mut raw);
let ctx = harness.ctx.clone();
harness.state_mut().raw_input_hook(&ctx, &mut raw);
*harness.input_mut() = raw;
harness.step();
}
+2 -1
View File
@@ -435,6 +435,7 @@ impl Builder<'_> {
Some(url) => {
let lid = lb.pid.with(("link", lb.li, lb.seg));
let mut l = Node::new(Role::Link);
l.set_label(n.value().filter(|text| !text.is_empty()).unwrap_or(url).to_owned());
l.set_url(url.clone());
l.set_bounds(bounds(r));
self.add(lid, Some(lb.pid), l);
@@ -702,7 +703,7 @@ mod tests {
let n = layout.pages.len();
let rects = vec![Rect::from_min_size(egui::pos2(f32::NAN, 0.0), egui::vec2(f32::INFINITY, 1.0)); n];
let p = Pos::body(0, 1_000_000);
let t = build(&doc, &layout, Id::new("doc"), &rects, (0, n + 5), (&p, &p));
let t = build(&doc, &layout, Id::unique("doc"), &rects, (0, n + 5), (&p, &p));
assert!(!t.nodes.is_empty() && t.nodes.len() <= MAX_NODES);
assert!(t.selection.is_some(), "an offset past the end lands at the end");
assert!(page_window(&rects, Rect::NOTHING).is_none());
+2 -2
View File
@@ -65,7 +65,7 @@ pub fn accessibility(app: &mut WordApp, ui: &mut Ui) {
/// The checker's findings, kept until the document changes.
fn issues(app: &mut WordApp, ui: &Ui) -> Vec<Value> {
let key = egui::Id::new("a11y_issues");
let key = egui::Id::unique("a11y_issues");
let stamp = (app.session.document_id(), app.session.rev());
if let Some((s, v)) = ui.data(|d| d.get_temp::<((u64, u64), Vec<Value>)>(key))
&& s == stamp
@@ -140,7 +140,7 @@ fn still_selected(app: &WordApp, pos: &Pos) -> bool {
/// A text box editing `current`; the draft lives in egui memory while it has focus (frame-local
/// buffers lose keystrokes). Returns the new text when editing ends with a change.
fn draft_box(ui: &mut Ui, key: impl std::hash::Hash + std::fmt::Debug, current: &str, enabled: bool, multiline: bool) -> Option<String> {
let key = egui::Id::new(key);
let key = egui::Id::unique(key);
let mut text = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| current.to_string());
let edit = if multiline { egui::TextEdit::multiline(&mut text).desired_rows(5) } else { egui::TextEdit::singleline(&mut text) };
let r = ui.add_enabled(enabled, edit.desired_width(f32::INFINITY));
+18 -20
View File
@@ -53,7 +53,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
// The chevron's stroke starts at the content edge.
let c = back_chevron_c(band.min.x, band.center().y, app.session.view.full_screen, app.mac_titlebar.content_left);
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, band.height())), ui.id().with("backstage_back"), Sense::click());
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, band.height())), ui.scope_id().with("backstage_back"), Sense::click());
icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE);
resp
} else {
@@ -62,7 +62,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
// not also close the File page, same as the macOS chevron fix (#2972) below.
let (r, _) = ui.allocate_exact_size(vec2(200.0, 40.0), Sense::hover());
let c = pos2(r.min.x + 26.0, r.center().y);
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, r.height())), ui.id().with("backstage_back"), Sense::click());
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, r.height())), ui.scope_id().with("backstage_back"), Sense::click());
icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE);
resp
};
@@ -76,7 +76,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
// No document open (#1129): Info, Save, Save As, Print and Export are greyed out.
let on = has_document || works_without_document(id);
let (r, resp) = ui.add_enabled_ui(on, |ui| ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click())).inner;
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tl!(label)));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!(label)));
let active = app.ui.backstage_page == id;
if active {
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(46));
@@ -124,7 +124,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
for (label, id) in [("About", "about"), ("Discord community", "discord")] {
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(label)));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(label)));
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
}
@@ -231,7 +231,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
let caption = crate::i18n::tc("template", label);
let galley = ui.painter().layout_no_wrap(caption.to_string(), regular(12.5), ui.visuals().text_color());
let (tile, resp) = ui.allocate_exact_size(vec2(galley.size().x.max(150.0), 194.0 + galley.size().y), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), caption));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), caption));
let r = Rect::from_min_size(tile.min, vec2(150.0, 194.0));
ui.painter().rect(
r,
@@ -243,7 +243,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
let ppp = ui.ctx().pixels_per_point();
let key = format!("tpl:{template}:{ppp}");
let region = app.session.region();
let tex = ui.ctx().data(|d| d.get_temp::<egui::TextureHandle>(egui::Id::new(&key))).or_else(|| {
let tex = ui.ctx().data(|d| d.get_temp::<egui::TextureHandle>(egui::Id::unique(&key))).or_else(|| {
let mut s = wordcraft_engine::Session::new(wordcraft_doc::Document::new());
// The blank tile shows the region's paper (#838).
s.system_region = region;
@@ -257,7 +257,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px),
egui::TextureOptions::LINEAR,
);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone()));
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(&key), h.clone()));
Some(h)
});
if let Some(h) = tex {
@@ -325,7 +325,7 @@ fn home_page(app: &mut WordApp, ui: &mut Ui) {
fn new_page(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
heading(ui, "New");
let id = egui::Id::new("backstage_template_search");
let id = egui::Id::unique("backstage_template_search");
let mut query = ui.ctx().data(|d| d.get_temp::<String>(id)).unwrap_or_default();
let search = ui.add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search for templates")).desired_width(360.0));
if search.changed() {
@@ -405,7 +405,7 @@ struct PersonalListing {
}
fn personal_listing_id() -> egui::Id {
egui::Id::new("backstage_personal_templates")
egui::Id::unique("backstage_personal_templates")
}
#[cfg(test)]
@@ -499,7 +499,7 @@ fn open_personal(app: &mut WordApp, path: &str) -> Result<(), String> {
}
fn personal_error_id() -> egui::Id {
egui::Id::new("backstage_personal_error")
egui::Id::unique("backstage_personal_error")
}
fn open_list(app: &mut WordApp, ui: &mut Ui) {
@@ -521,15 +521,13 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0), 40.0), Sense::click());
// Two documents with the same file name are told apart by their path, as on screen.
let spoken = if names.iter().filter(|n| *n == name).count() > 1 { format!("{name}, {p}") } else { name.clone() };
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &spoken));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &spoken));
if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(22.0, 22.0)), "printLayout", t.icon, t.accent);
let display_name = crate::canvas::bidi_display(name);
let display_p = crate::canvas::bidi_display(p);
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, display_name.as_ref(), medium(13.0), t.text);
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, display_p.as_ref(), regular(11.0), t.text_dim);
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, name, medium(13.0), t.text);
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, p, regular(11.0), t.text_dim);
if resp.clicked() {
let failed = open_recent(app, p).err();
ui.ctx().data_mut(|d| d.insert_temp(open_error_id(), failed.unwrap_or_default()));
@@ -538,7 +536,7 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) {
}
fn open_error_id() -> egui::Id {
egui::Id::new("backstage_open_error")
egui::Id::unique("backstage_open_error")
}
/// Drop the Recent list's open error once File is closed, so it doesn't come back next time.
@@ -553,7 +551,7 @@ pub(crate) fn forget_open_error(ctx: &egui::Context) {
/// render loop skips the status bar, so a failed write (no permission, disk full, a bad path)
/// needs its own place to say why, or the page just sits there with a Browse… button and no clue.
pub(crate) fn save_as_error_id() -> egui::Id {
egui::Id::new("backstage_save_as_error")
egui::Id::unique("backstage_save_as_error")
}
/// Drop the Save As page's error once File is closed or a later save succeeds, so it doesn't
@@ -1106,14 +1104,14 @@ mod tests {
assert!(h.query_by_label("Report").is_some(), "the built-in templates stay");
// The search covers both.
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "memo".to_string()));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), "memo".to_string()));
h.run_steps(3);
assert!(h.query_by_label("Team memo").is_some(), "search finds a personal template");
assert!(h.query_by_label("Contract").is_none() && h.query_by_label("Report").is_none());
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "zzz".to_string()));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), "zzz".to_string()));
h.run_steps(3);
assert!(h.query_by_label("No templates match your search.").is_some());
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), String::new()));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), String::new()));
h.run_steps(3);
h.get_by_label("Team memo").click();
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -322,7 +322,7 @@ fn parse_ms(value: Option<&str>) -> f64 {
}
fn state_id() -> egui::Id {
egui::Id::new("caret_glide")
egui::Id::unique("caret_glide")
}
/// What puts the caret where it is: the document's revision and the caret's position in it.
+19 -15
View File
@@ -26,7 +26,7 @@ pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::Pat
/// The Quick Access Toolbar's button for command `id`.
pub(crate) fn qat_button_id(id: &str) -> egui::Id {
egui::Id::new(("qat_button", id))
egui::Id::unique(("qat_button", id))
}
fn qat_button(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, enabled: bool) -> egui::Response {
@@ -101,12 +101,12 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) {
/// The Undo button's drop-down arrow (#701), which opens [`undo_list`].
pub(crate) fn undo_list_button_id() -> egui::Id {
egui::Id::new("qat_undo_list")
egui::Id::unique("qat_undo_list")
}
/// The status bar's message, the one that says what went wrong (#2402).
pub(crate) fn status_message_id() -> egui::Id {
egui::Id::new("status_message")
egui::Id::unique("status_message")
}
/// Row `i` of the Undo list (0 = the newest step).
@@ -203,7 +203,7 @@ fn undo_list(ui: &mut Ui, app: &mut WordApp) {
/// The Quick Access Toolbar's menu button (…), which opens [`qat_menu`].
pub(crate) fn qat_menu_button_id() -> egui::Id {
egui::Id::new("qat_menu_button")
egui::Id::unique("qat_menu_button")
}
/// The Quick Access Toolbar's menu: common commands to show or hide, and More Commands…
@@ -289,7 +289,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
.show(ui, |ui| {
let full = ui.max_rect();
// Drag the window by the title bar (integrated title bar on macOS).
let drag = ui.interact(full, ui.id().with("title_drag"), Sense::click_and_drag());
let drag = ui.interact(full, ui.scope_id().with("title_drag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
@@ -347,7 +347,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
// Account / community.
let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click());
ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Account")));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Account")));
let initials: String = app.session.user_initials().chars().take(2).collect();
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent);
if resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)).clicked() {
@@ -405,8 +405,8 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
let w = text_width(ui.painter(), &shown, &font, track, t.text);
if available > 24.0 {
let rect = Rect::from_center_size(pos2(cx, full.center().y), vec2(w + 8.0, control_h));
let response = ui.interact(rect, ui.id().with("document_name"), Sense::click()).on_hover_text(tl!("Rename Document"));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &title));
let response = ui.interact(rect, ui.scope_id().with("document_name"), Sense::click()).on_hover_text(tl!("Rename Document"));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &title));
if response.clicked() {
let _ = app.run("file.rename", json!({}));
}
@@ -430,7 +430,7 @@ fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(260.0, control_h), Sense::click());
let ph = tl!("Search commands and help");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, ph));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, ph));
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(16.0, 16.0)), "search", t.text_dim, t.accent);
let pfont = TypeRung::Control.regular();
@@ -506,7 +506,7 @@ pub fn screen_bar(app: &mut WordApp, ui: &mut Ui, screen: Screen) {
let shade = if resp.hovered() || app.focus_ribbon { egui::Color32::from_gray(70) } else { egui::Color32::from_gray(40) };
ui.painter().rect_filled(r, 4.0, shade);
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), "more", egui::Color32::from_gray(220), t.accent);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Show the ribbon")));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Show the ribbon")));
if resp.on_hover_text(tl!("Show the ribbon")).clicked() {
let _ = app.run("ui.focusRibbon", json!({}));
}
@@ -522,7 +522,7 @@ pub fn screen_bar(app: &mut WordApp, ui: &mut Ui, screen: Screen) {
/// click on the page puts it away; Focus stays on.
pub fn focus_ribbon(app: &mut WordApp, ctx: &egui::Context) {
let width = ctx.content_rect().width();
egui::Area::new(egui::Id::new("focus_ribbon")).fixed_pos(pos2(0.0, SCREEN_BAR_HEIGHT)).order(egui::Order::Middle).show(ctx, |ui| {
egui::Area::new(egui::Id::unique("focus_ribbon")).fixed_pos(pos2(0.0, SCREEN_BAR_HEIGHT)).order(egui::Order::Middle).show(ctx, |ui| {
ui.set_width(width);
ui.set_max_width(width);
crate::ribbon::show(app, ui);
@@ -568,7 +568,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
let w = text_width(ui.painter(), s, &font, track, t.text_dim);
let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click());
paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, s));
resp
};
if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())]))
@@ -652,7 +652,11 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
let mut v = if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT };
let slider = egui::Slider::new(&mut v, if reading { 0.75..=1.5 } else { 0.1..=5.0 }).logarithmic(!reading).show_value(false);
ui.spacing_mut().slider_width = 110.0;
if ui.add(slider).changed() {
let response = ui.add(slider);
response.widget_info(|| {
egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), if reading { tl!("Text Size") } else { tl!("Zoom") })
});
if response.changed() {
let command = if reading { "view.readTextSize" } else { "view.zoom" };
let _ = app.run(command, json!({"value": (v * 100.0).round()}));
}
@@ -678,7 +682,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
ui.painter().rect_filled(r, 3.0, t.hover);
}
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, name));
if resp.on_hover_text(name).clicked() {
let _ = app.run(id, json!({}));
}
@@ -705,7 +709,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
ftrack,
t.text_dim,
);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, focus));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, focus));
if resp.clicked() {
let _ = app.run("view.focus", json!({}));
}
+6 -6
View File
@@ -248,15 +248,15 @@ pub enum Pick {
}
fn recent_id() -> Id {
Id::new("wordcraft.recentColors")
Id::unique("wordcraft.recentColors")
}
fn request_id() -> Id {
Id::new("wordcraft.moreColorsRequest")
Id::unique("wordcraft.moreColorsRequest")
}
fn open_flag_id() -> Id {
Id::new("wordcraft.colorsDialogOpen")
Id::unique("wordcraft.colorsDialogOpen")
}
/// Removes and returns a temporary value.
@@ -449,7 +449,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
ctx.data_mut(|m| m.insert_temp(open_flag_id(), false));
return;
};
let resp = egui::Modal::new(Id::new("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d));
let resp = egui::Modal::new(Id::unique("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d));
let mut done = resp.inner;
if done.is_none() && resp.should_close() {
done = Some(false);
@@ -799,7 +799,7 @@ mod tests {
assert_eq!(Target::cmd("format.color").command("1A2B3C"), Some(("format.color".into(), json!({"color": "1A2B3C"}))));
let glow = Target::at("shape.effects", json!({"glow": {"color": null, "size": 8.0}}), "/glow/color");
assert_eq!(glow.command("00FF00"), Some(("shape.effects".into(), json!({"glow": {"color": "00FF00", "size": 8.0}}))));
assert_eq!(Target::Field(Id::new("x")).command("00FF00"), None);
assert_eq!(Target::Field(Id::unique("x")).command("00FF00"), None);
}
#[test]
@@ -837,7 +837,7 @@ mod tests {
assert_eq!(app.canvas.last_font_color, "7A3EC8");
assert_eq!(app.ui.recent_colors.first().map(String::as_str), Some("7A3EC8"));
// A dialog field gets the colour back instead.
let id = Id::new("field");
let id = Id::unique("field");
d.target = Target::Field(id);
finish(&mut app, &ctx, &d);
let mut value = String::new();
+1 -1
View File
@@ -163,7 +163,7 @@ pub fn popup(app: &mut WordApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let at_json = serde_json::to_value(&at).unwrap_or_default();
let mut close = false;
let area = egui::Area::new(egui::Id::new("content_control_popup"))
let area = egui::Area::new(egui::Id::unique("content_control_popup"))
.order(egui::Order::Foreground)
.fixed_pos(pos2(anchor.max.x - 180.0, anchor.max.y + 2.0));
let shown = area.show(ctx, |ui| {
+703 -9
View File
@@ -15,6 +15,8 @@
use std::sync::mpsc::Sender;
use craft_ui::capture::CaptureSlot;
use serde_json::{Value, json};
use crate::WordApp;
@@ -244,7 +246,7 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
};
let delta = egui::vec2(f("dx").unwrap_or(0.0), f("dy").unwrap_or(0.0));
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y)));
app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase, modifiers: mods(p) });
app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase, modifiers: mods(p), source: egui::MouseWheelSource::Unknown });
ok(json!({"queued": true}))
}
"ui.text" => {
@@ -312,23 +314,715 @@ fn render_png(app: &WordApp, page: u64, scale: f32) -> Result<Value, String> {
Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}))
}
/// The bounded admission policy for `ui.screenshot` captures. This is the screenshot queue;
/// the engine's 64-entry UI-intent queue is unrelated. The per-image and aggregate limits are
/// working-set limits, not just raw RGBA: encoding a PNG and base64-encoding it for a reply can
/// briefly need several times the raw pixel buffer.
pub(crate) const SCREENSHOT_MAX_REQUESTS: usize = 4;
pub(crate) const SCREENSHOT_MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024;
pub(crate) const SCREENSHOT_MAX_AGGREGATE_BYTES: usize = 256 * 1024 * 1024;
pub(crate) const SCREENSHOT_MAX_RETRIES: u32 = 2;
/// An incompressible PNG of `raw` RGBA bytes. Deflate's stored-block worst case adds five bytes
/// per 64 KiB block; zlib/PNG framing and the encoder's small internal buffers get 64 KiB more.
/// Checked, because every byte count here comes from a hostile window size.
fn png_upper_bound(raw: usize) -> Option<usize> {
let blocks = raw.div_ceil(65_535);
raw.checked_add(blocks.checked_mul(5)?)?.checked_add(64 * 1024)
}
/// The length of the base64 text for `bytes`, rounded up, as `base64_encode` produces it.
fn base64_upper_bound(bytes: usize) -> Option<usize> {
bytes.div_ceil(3).checked_mul(4)
}
/// Peak memory of one screenshot, in bytes: the completed `ColorImage`, the one straight-alpha
/// RGBA copy handed to the PNG encoder, the PNG buffer, and (for a reply without a path) the
/// base64 string. All of these can be live at once, so admission reserves their sum rather than
/// only the raw pixels or a PNG-only estimate. Checked throughout: a hostile surface size must
/// result in an error, never a wrapped budget.
fn working_set(raw: usize, inline_reply: bool) -> Option<usize> {
let png = png_upper_bound(raw)?;
let mut peak = raw.checked_add(raw)?.checked_add(png)?;
if inline_reply {
peak = peak.checked_add(base64_upper_bound(png)?)?;
}
Some(peak)
}
/// The surface reported by one `RawInput`, before it is tied to a document. Captured in the host
/// input hook because `InputState::viewport_rect` and the last painted screen rect are stale on a
/// hidden logic tick; RawInput is the host's answer for this frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct SurfaceGeometry {
viewport: egui::Vec2,
/// Native pixels per point times egui's zoom, so a zoom change is its own epoch.
pixels_per_point: f32,
max_texture_side: usize,
minimized: bool,
occluded: bool,
}
impl SurfaceGeometry {
pub(crate) fn from_raw(raw: &egui::RawInput, ctx: &egui::Context) -> Option<Self> {
let viewport = raw.viewports.get(&raw.viewport_id)?;
let minimized = viewport.minimized == Some(true);
let occluded = viewport.occluded == Some(true);
// A minimized/occluded surface may report no rectangle at all. Its stale size is harmless
// because such a surface is never capturable; a visible surface without a rectangle is not
// fresh enough to use.
let rect = match viewport.inner_rect.or(raw.screen_rect) {
Some(rect) => rect,
None if minimized || occluded => ctx.content_rect(),
None => return None,
};
let zoom = ctx.zoom_factor();
let pixels_per_point = viewport.native_pixels_per_point.map_or_else(|| ctx.pixels_per_point(), |native| native * zoom);
let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side));
if !rect.width().is_finite() || !rect.height().is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 {
return None;
}
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 || !zoom.is_finite() || zoom <= 0.0 {
return None;
}
Some(Self { viewport: rect.size(), pixels_per_point, max_texture_side, minimized, occluded })
}
pub(crate) fn with_document(self, document: u64) -> CaptureGeometry {
CaptureGeometry { surface: self, document }
}
}
/// The viewport geometry and document identity a capture is admitted, issued and completed
/// against. `WordApp` keeps a monotonic epoch: any change, including a change back, makes an
/// older image stale.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct CaptureGeometry {
surface: SurfaceGeometry,
document: u64,
}
impl CaptureGeometry {
pub(crate) fn document(self) -> u64 {
self.document
}
pub(crate) fn unavailable_reason(self) -> Option<&'static str> {
if self.surface.minimized {
Some("the window is minimized")
} else if self.surface.occluded {
Some("the window is fully occluded")
} else {
None
}
}
/// The screenshot's physical size, rounded to the nearest pixel.
///
/// The renderer's maximum texture side is deliberately not applied here. It is a texture
/// limit, not the size of the surface the callback reports; capping one axis would distort
/// the expected aspect and reject the real image. [`Self::oversize_reason`] rejects a window
/// that cannot have a surface texture at all, before admission.
pub(crate) fn expected_size(self) -> Option<[usize; 2]> {
if self.unavailable_reason().is_some() {
return None;
}
let axis = |points: f32| -> Option<usize> {
let px = (f64::from(points) * f64::from(self.surface.pixels_per_point)).round();
if !px.is_finite() || px <= 0.0 || px > usize::MAX as f64 {
return None;
}
Some(px as usize)
};
Some([axis(self.surface.viewport.x)?, axis(self.surface.viewport.y)?])
}
/// Why this surface is too large to capture at all: a window larger than the renderer's
/// maximum texture side has no surface texture to copy. Reject it explicitly rather than
/// silently capping the expected size and failing exact comparisons later.
pub(crate) fn oversize_reason(self) -> Option<String> {
let [width, height] = self.expected_size()?;
let max = self.surface.max_texture_side;
(max > 0 && (width > max || height > max))
.then(|| format!("screenshot failed: the window is {width}×{height} pixels, over the renderer's {max}-pixel texture limit"))
}
/// Checked `width * height * 4` bytes for the expected image.
pub(crate) fn expected_bytes(self) -> Option<usize> {
let [width, height] = self.expected_size()?;
width.checked_mul(height)?.checked_mul(4)
}
/// The admission reservation for the complete capture working set (image plus encoding).
/// `inline_reply` includes the base64 string a reply without a path produces.
pub(crate) fn reserved_bytes(self, inline_reply: bool) -> Option<usize> {
working_set(self.expected_bytes()?, inline_reply)
}
}
/// One admitted capture. The request owns its token, path, deadline, immutable document identity,
/// geometry and reservation; the shared slot owns the image and its weak callback.
pub(crate) struct Shot {
pub(crate) token: u64,
pub(crate) path: Option<String>,
pub(crate) reply: Sender<Value>,
/// One monotonic deadline for the whole request, including retries.
pub(crate) deadline: f64,
pub(crate) ready_at: f64,
pub(crate) frames: u32,
pub(crate) issued: bool,
pub(crate) retries: u32,
/// The document the request was admitted for. It never changes; a document switch cancels.
pub(crate) document: u64,
pub(crate) geometry: CaptureGeometry,
pub(crate) epoch: u64,
pub(crate) expected: [usize; 2],
/// Admission reservation, including the encoding working set.
pub(crate) bytes: usize,
pub(crate) slot: Option<CaptureSlot>,
pub(crate) callback: Option<egui::ScreenshotCallback>,
}
/// All outstanding `ui.screenshot` requests.
#[derive(Default)]
pub(crate) struct Shots {
pub(crate) token: u64,
pub(crate) requests: Vec<Shot>,
}
impl Shots {
pub(crate) fn in_flight(&self) -> bool {
!self.requests.is_empty()
}
fn reserved(&self) -> usize {
self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.bytes))
}
}
fn error_reply(token: u64, message: impl std::fmt::Display) -> Value {
json!({"ok": false, "error": message.to_string(), "request": token})
}
fn reject_shot(reply: Sender<Value>, token: u64, message: impl std::fmt::Display) {
let _ = reply.send(error_reply(token, message));
}
fn next_token(app: &mut WordApp) -> u64 {
let mut token = app.shots.token.wrapping_add(1);
if token == 0 {
token = 1;
}
app.shots.token = token;
token
}
/// Admit one screenshot request against the count and byte budgets **before** a capture is
/// issued. A rejected request gets one error reply, with its request token, and never evicts an
/// earlier request.
pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Option<String>, reply: Sender<Value>) {
let token = next_token(app);
let Some(geometry) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else {
reject_shot(reply, token, "screenshot failed: the window geometry is not known yet");
return;
};
if let Some(reason) = geometry.unavailable_reason() {
reject_shot(reply, token, format!("screenshot failed: {reason}"));
return;
}
let Some(expected) = geometry.expected_size() else {
reject_shot(reply, token, "screenshot failed: the window has no capturable size");
return;
};
if let Some(reason) = geometry.oversize_reason() {
reject_shot(reply, token, reason);
return;
}
let inline_reply = path.is_none();
let Some(bytes) = geometry.reserved_bytes(inline_reply) else {
reject_shot(reply, token, "screenshot failed: the window size overflows the screenshot budget");
return;
};
if bytes > SCREENSHOT_MAX_IMAGE_BYTES {
reject_shot(
reply,
token,
format!(
"screenshot budget exceeded: {bytes} bytes reserved for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit",
expected[0], expected[1]
),
);
return;
}
if app.shots.requests.len() >= SCREENSHOT_MAX_REQUESTS {
reject_shot(
reply,
token,
format!("screenshot budget exceeded: {} requests are already outstanding (limit {SCREENSHOT_MAX_REQUESTS})", app.shots.requests.len()),
);
return;
}
let reserved = app.shots.reserved().saturating_add(bytes);
if reserved > SCREENSHOT_MAX_AGGREGATE_BYTES {
reject_shot(
reply,
token,
format!("screenshot budget exceeded: {reserved} bytes reserved, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit"),
);
return;
}
let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), SCREENSHOT_MAX_IMAGE_BYTES);
let now = crate::now_ms();
app.shots.requests.push(Shot {
token,
path,
reply,
deadline: now + 8000.0,
ready_at: now + 120.0,
frames: 0,
issued: false,
retries: 0,
document: geometry.document(),
geometry,
epoch: app.capture_epoch,
expected,
bytes,
slot: Some(slot),
callback: Some(callback),
});
// The capture command is issued from `ui`; while hidden, logic keeps asking for a painted
// frame until it gets there.
ctx.request_repaint();
}
/// Issued only from `ui`, after the layout has settled. Counts one frame per host frame on the
/// first pass, revalidates geometry and the byte reservation, and sends one callback command.
/// `current_pass_index() == 0` is meaningful here because this function is already in a UI pass.
pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) {
let now = crate::now_ms();
let first_pass = ctx.current_pass_index() == 0;
let mut failures: Vec<(usize, String)> = Vec::new();
for i in 0..app.shots.requests.len() {
let (issued, ready_at, frames) = {
let shot = &app.shots.requests[i];
(shot.issued, shot.ready_at, shot.frames)
};
if issued {
continue;
}
let frames = if first_pass { frames.saturating_add(1) } else { frames };
app.shots.requests[i].frames = frames;
if now < ready_at || frames < 3 {
continue;
}
let Some(current) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else {
failures.push((i, "screenshot failed: the window geometry is not known yet".into()));
continue;
};
if let Some(reason) = current.unavailable_reason() {
failures.push((i, format!("screenshot failed: {reason} while the capture was queued")));
continue;
}
if current.document() != app.shots.requests[i].document {
failures.push((i, "screenshot failed: the document changed while the capture was queued".into()));
continue;
}
let Some(expected) = current.expected_size() else {
failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into()));
continue;
};
if let Some(reason) = current.oversize_reason() {
failures.push((i, reason));
continue;
}
let inline_reply = app.shots.requests[i].path.is_none();
let Some(bytes) = current.reserved_bytes(inline_reply) else {
failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into()));
continue;
};
if bytes > SCREENSHOT_MAX_IMAGE_BYTES {
failures.push((
i,
format!(
"screenshot budget exceeded: {bytes} bytes reserved for one image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit"
),
));
continue;
}
let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.bytes));
if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES {
failures.push((i, format!("screenshot budget exceeded: outstanding requests would reserve over {SCREENSHOT_MAX_AGGREGATE_BYTES} bytes")));
continue;
}
let epoch = app.capture_epoch;
let shot = &mut app.shots.requests[i];
shot.geometry = current;
shot.expected = expected;
shot.bytes = bytes;
shot.epoch = epoch;
if let Some(callback) = shot.callback.take() {
ctx.send_viewport_cmd_to(shot.viewport(), egui::ViewportCommand::Screenshot(callback));
shot.issued = true;
}
}
for (i, message) in failures.into_iter().rev() {
let shot = app.shots.requests.remove(i);
if let Some(slot) = shot.slot {
slot.cancel();
}
reject_shot(shot.reply, shot.token, message);
}
if app.shots.in_flight() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
impl Shot {
fn viewport(&self) -> egui::ViewportId {
egui::ViewportId::ROOT
}
}
/// A completed image is either stale because the surface changed (safe to retry once within the
/// deadline) or invalid for this request (report it at once; retrying cannot help).
enum ImageCheck {
Stale(String),
Invalid(String),
}
/// The callback's physical size is the host surface texture's extent, rounded independently of
/// the expected `round(logical points × pixels_per_point)`. One physical pixel on each axis is
/// therefore the same surface seen through a different rounding boundary, not a resize.
fn dimensions_are_a_rounded_variant(expected: [usize; 2], actual: [usize; 2]) -> bool {
// Both axes are derived from the same logical viewport and the same pixels-per-point; each
// may round at a boundary one pixel away, but a larger change on either axis is a resize.
expected[0].abs_diff(actual[0]) <= 1 && expected[1].abs_diff(actual[1]) <= 1
}
fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), ImageCheck> {
let [width, height] = image.size;
if width == 0 || height == 0 {
return Err(ImageCheck::Invalid("the screenshot has no pixels".to_string()));
}
// A screenshot is issued for one exact logical viewport. Exact equality would reject a valid
// fractional-DPI capture whose callback rounds by one pixel; a larger difference, or a
// different aspect, is a different surface.
if !dimensions_are_a_rounded_variant(expected, [width, height]) {
return Err(ImageCheck::Stale(format!(
"screenshot is {width}×{height} pixels, expected {}×{}: the window changed size while it was captured",
expected[0], expected[1]
)));
}
let pixels = width.checked_mul(height).ok_or_else(|| ImageCheck::Invalid("screenshot dimensions overflow".to_string()))?;
if image.pixels.len() != pixels {
return Err(ImageCheck::Invalid(format!("screenshot has {} pixels for its {width}×{height} dimensions", image.pixels.len())));
}
// The largest image allowed by the one-pixel rounding allowance. The reservation was made for
// the expected size; a sibling one pixel wider or taller is still the same surface.
let largest = expected[0]
.checked_add(1)
.and_then(|w| expected[1].checked_add(1).and_then(|h| w.checked_mul(h)))
.ok_or_else(|| ImageCheck::Invalid("screenshot dimensions overflow".to_string()))?;
if pixels > largest {
return Err(ImageCheck::Invalid(format!("screenshot has {pixels} pixels, over the {largest} allowed by its reservation")));
}
Ok(())
}
/// Called from `logic`: take each completed slot once, check the deadline **before** writing,
/// validate request/document/geometry identity, and only then encode and reply. A late, stale or
/// oversized image is dropped without a file; a document switch cancels instead of rebinding.
pub(crate) fn drain_screenshots(app: &mut WordApp, ctx: &egui::Context) {
let now = crate::now_ms();
let current = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g });
let epoch = app.capture_epoch;
let mut i = 0;
while i < app.shots.requests.len() {
enum Action {
Keep,
Remove(Value),
Restart,
}
let action = {
let shot = &mut app.shots.requests[i];
if now >= shot.deadline {
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
Action::Remove(error_reply(shot.token, "no frame was presented before the screenshot timeout"))
} else if !shot.issued {
Action::Keep
} else if let Some(reason) = current.and_then(CaptureGeometry::unavailable_reason) {
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
Action::Remove(error_reply(shot.token, format!("screenshot failed: {reason} while the capture was pending")))
} else if current.is_some_and(|geometry| geometry.document() != shot.document) {
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
Action::Remove(error_reply(shot.token, "screenshot failed: the document changed while the capture was queued"))
} else {
match shot.slot.as_ref().and_then(|slot| slot.take()) {
None => Action::Keep,
Some(Err(error)) => Action::Remove(error_reply(shot.token, error)),
Some(Ok(image)) => {
let stale = current != Some(shot.geometry) || epoch != shot.epoch;
if stale {
if shot.retries < SCREENSHOT_MAX_RETRIES {
Action::Restart
} else {
Action::Remove(error_reply(shot.token, "screenshot geometry changed while it was captured; no image was written"))
}
} else {
match validate_image(&image, shot.expected) {
Err(ImageCheck::Stale(_)) if shot.retries < SCREENSHOT_MAX_RETRIES => Action::Restart,
Err(ImageCheck::Stale(message) | ImageCheck::Invalid(message)) => Action::Remove(error_reply(shot.token, message)),
Ok(()) => Action::Remove(save_screenshot(image.as_ref(), shot.path.as_deref(), shot.token)),
}
}
}
}
}
};
match action {
Action::Keep => i += 1,
Action::Remove(value) => {
let shot = app.shots.requests.remove(i);
if let Some(slot) = shot.slot {
slot.cancel();
}
let _ = shot.reply.send(value);
}
Action::Restart => {
let Some(current) = current else {
let shot = app.shots.requests.remove(i);
if let Some(slot) = shot.slot {
slot.cancel();
}
reject_shot(shot.reply, shot.token, "screenshot failed: the window geometry is not known");
continue;
};
let Some(expected) = current.expected_size() else {
let shot = app.shots.requests.remove(i);
if let Some(slot) = shot.slot {
slot.cancel();
}
reject_shot(shot.reply, shot.token, "screenshot failed: the window size overflows the screenshot budget");
continue;
};
if let Some(reason) = current.oversize_reason() {
let shot = app.shots.requests.remove(i);
if let Some(slot) = shot.slot {
slot.cancel();
}
reject_shot(shot.reply, shot.token, reason);
continue;
}
let inline_reply = app.shots.requests[i].path.is_none();
let Some(bytes) = current.reserved_bytes(inline_reply) else {
let shot = app.shots.requests.remove(i);
if let Some(slot) = shot.slot {
slot.cancel();
}
reject_shot(shot.reply, shot.token, "screenshot failed: the window size overflows the screenshot budget");
continue;
};
let others =
app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.bytes));
if bytes > SCREENSHOT_MAX_IMAGE_BYTES || others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES {
let shot = app.shots.requests.remove(i);
if let Some(slot) = shot.slot {
slot.cancel();
}
reject_shot(shot.reply, shot.token, "screenshot budget exceeded while retrying after a resize");
continue;
}
let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), SCREENSHOT_MAX_IMAGE_BYTES);
let shot = &mut app.shots.requests[i];
shot.slot = Some(slot);
shot.callback = Some(callback);
shot.issued = false;
shot.ready_at = now + 120.0;
shot.frames = 0;
shot.retries = shot.retries.saturating_add(1);
shot.geometry = current;
shot.expected = expected;
shot.bytes = bytes;
shot.epoch = epoch;
i += 1;
}
}
}
if app.shots.in_flight() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// The screenshot as a straight-alpha PNG, using one RGBA copy rather than the extra full-image
/// copy [`wordcraft_render::Rendered::to_png`] makes for its premultiplied input. The callback's
/// `ColorImage` is premultiplied; PNG stores straight alpha.
fn screenshot_png(image: &egui::ColorImage) -> Option<Vec<u8>> {
let [width, height] = image.size;
if width == 0 || height == 0 {
return None;
}
let pixels = width.checked_mul(height)?;
if image.pixels.len() != pixels {
return None;
}
let mut rgba = Vec::with_capacity(pixels.checked_mul(4)?);
for color in &image.pixels {
let [r, g, b, a] = color.to_array();
if a == 0 || a == 255 {
rgba.extend_from_slice(&[r, g, b, a]);
} else {
let straight = |c: u8| ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8;
rgba.extend_from_slice(&[straight(r), straight(g), straight(b), a]);
}
}
let img = image::RgbaImage::from_raw(u32::try_from(width).ok()?, u32::try_from(height).ok()?, rgba)?;
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(png)
}
/// The window screenshot: written to `path` when one was given, else base64 PNG in the reply
/// (never a fixed name in the shared temporary folder).
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
/// (never a fixed name in the shared temporary folder). Every reply carries the request token so
/// a client can correlate it with the admission, timeout and error replies.
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64) -> Value {
let [w, h] = image.size;
let bytes: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
let rendered = wordcraft_render::Rendered { width: w as u32, height: h as u32, pixels: bytes };
let png = rendered.to_png();
let Some(raw) = w.checked_mul(h).and_then(|pixels| pixels.checked_mul(4)) else {
return error_reply(token, "screenshot dimensions overflow the byte count");
};
let inline_reply = path.is_none();
let Some(peak) = working_set(raw, inline_reply) else {
return error_reply(token, "screenshot working set overflows the byte count");
};
if peak > SCREENSHOT_MAX_IMAGE_BYTES {
return error_reply(
token,
format!("screenshot needs {peak} bytes of working set, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit"),
);
}
let Some(png) = screenshot_png(image) else {
return error_reply(token, "screenshot PNG encoding failed");
};
if png.len() > SCREENSHOT_MAX_IMAGE_BYTES {
return error_reply(token, format!("screenshot PNG uses {} bytes, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", png.len()));
}
let Some(path) = path else {
return json!({"ok": true, "result": {"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": w, "height": h}});
let encoded = wordcraft_engine::cmd::insert::base64_encode(&png);
if encoded.len() > SCREENSHOT_MAX_IMAGE_BYTES {
return error_reply(
token,
format!("screenshot base64 reply uses {} bytes, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", encoded.len()),
);
}
return json!({"ok": true, "request": token, "result": {"png": encoded, "width": w, "height": h}});
};
#[cfg(target_arch = "wasm32")]
let _ = (&png, path);
#[cfg(not(target_arch = "wasm32"))]
{
if let Err(e) = std::fs::write(path, &png) {
return json!({"ok": false, "error": format!("{path}: {e}")});
return error_reply(token, format!("{path}: {e}"));
}
}
json!({"ok": true, "result": {"path": path, "width": w, "height": h}})
json!({"ok": true, "request": token, "result": {"path": path, "width": w, "height": h}})
}
#[cfg(test)]
mod tests {
use super::*;
fn surface(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> SurfaceGeometry {
let ctx = egui::Context::default();
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, viewport);
let mut raw = egui::RawInput { screen_rect: Some(rect), max_texture_side: Some(max_texture_side), ..Default::default() };
raw.viewports.insert(
egui::ViewportId::ROOT,
egui::ViewportInfo {
inner_rect: Some(rect),
native_pixels_per_point: Some(pixels_per_point),
minimized: Some(false),
occluded: Some(false),
..Default::default()
},
);
SurfaceGeometry::from_raw(&raw, &ctx).expect("a visible surface")
}
fn geometry(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> CaptureGeometry {
CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), document: 7 }
}
fn image(width: usize, height: usize) -> egui::ColorImage {
egui::ColorImage::filled([width, height], egui::Color32::BLACK)
}
/// The reservation covers every live copy: the image, the RGBA copy the encoder consumes, the
/// PNG buffer and the base64 reply string. A path reply needs no base64.
#[test]
fn screenshot_working_set_counts_image_copy_png_and_base64() {
let raw = 1_000_000usize;
let png = png_upper_bound(raw).expect("the PNG bound");
let base64 = base64_upper_bound(png).expect("the base64 bound");
assert_eq!(working_set(raw, false), Some(raw + raw + png));
assert_eq!(working_set(raw, true), Some(raw + raw + png + base64));
assert!(working_set(raw, true).expect("bounded") > raw * 4, "raw pixels alone are not the peak");
assert_eq!(base64_upper_bound(0), Some(0));
assert_eq!(base64_upper_bound(1), Some(4));
assert_eq!(base64_upper_bound(4), Some(8), "rounded up, as the encoder pads");
assert_eq!(base64_upper_bound(usize::MAX), None, "overflow is refused, not wrapped");
assert_eq!(working_set(usize::MAX, true), None);
}
/// The 128 MiB per-image budget is a working-set budget, not a raw-pixel budget: a
/// 3000×3000 inline reply no longer fits even though its raw pixels are only 36 MB, while a
/// path reply for the same image (no base64) does.
#[test]
fn the_per_image_budget_reflects_the_encoding_peak() {
let fits = geometry(egui::vec2(2000.0, 2000.0), 1.0, 16_384);
assert!(fits.reserved_bytes(true).expect("bounded") <= SCREENSHOT_MAX_IMAGE_BYTES);
let too_big_inline = geometry(egui::vec2(3000.0, 3000.0), 1.0, 16_384);
assert!(too_big_inline.reserved_bytes(true).expect("bounded") > SCREENSHOT_MAX_IMAGE_BYTES);
assert!(too_big_inline.reserved_bytes(false).expect("bounded") <= SCREENSHOT_MAX_IMAGE_BYTES);
}
/// A texture limit must not cap the expected physical size: capping one axis changes the
/// expected aspect and makes the real callback look stale. Out-of-limit surfaces are rejected
/// explicitly instead.
#[test]
fn expected_size_is_not_capped_by_the_texture_limit() {
let wide = geometry(egui::vec2(10_000.0, 2_000.0), 1.0, 8_192);
assert_eq!(wide.expected_size(), Some([10_000, 2_000]));
let reason = wide.oversize_reason().expect("over the texture limit");
assert!(reason.contains("texture limit"), "{reason}");
let fits = geometry(egui::vec2(8_000.0, 2_000.0), 1.0, 8_192);
assert_eq!(fits.expected_size(), Some([8_000, 2_000]));
assert_eq!(fits.oversize_reason(), None);
}
/// Fractional DPI rounds the host surface and the expected size at different boundaries: a
/// one-pixel difference on each axis is the same capture, a two-pixel difference is stale.
#[test]
fn a_callback_one_pixel_off_the_rounded_size_is_accepted() {
let g = geometry(egui::vec2(100.4, 100.4), 1.5, 16_384);
assert_eq!(g.expected_size(), Some([151, 151]));
assert!(validate_image(&image(152, 150), [151, 151]).is_ok());
assert!(matches!(validate_image(&image(153, 149), [151, 151]), Err(ImageCheck::Stale(_))));
assert!(matches!(validate_image(&image(800, 600), [151, 151]), Err(ImageCheck::Stale(_))));
}
/// A real resize is stale even when one axis happens to be close: both axes must match within
/// the one-pixel rounding allowance.
#[test]
fn a_two_pixel_change_is_a_resize_not_rounding() {
assert!(dimensions_are_a_rounded_variant([1000, 1000], [1001, 999]));
assert!(!dimensions_are_a_rounded_variant([1000, 1000], [1200, 800]));
assert!(!dimensions_are_a_rounded_variant([1000, 1000], [1002, 999]));
assert!(!dimensions_are_a_rounded_variant([1000, 1000], [1000, 998]));
}
}
+1 -1
View File
@@ -212,7 +212,7 @@ impl Default for View {
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let id = egui::Id::unique("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label(tl!("Show"));
+1 -1
View File
@@ -285,7 +285,7 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at:
/// it can't be decoded (then the cropped-away area is just dimmed).
fn source(app: &WordApp, ctx: &egui::Context, media: &str) -> Option<egui::TextureHandle> {
let bytes = app.session.doc.media.get(media)?;
let id = egui::Id::new(("crop_source", media, bytes.len()));
let id = egui::Id::unique(("crop_source", media, bytes.len()));
if let Some(t) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
return t;
}
+103 -81
View File
@@ -936,7 +936,7 @@ pub(crate) fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut
/// colour grid, Recent Colors and More Colors… (#606). `value` is hex or `none_value`.
pub(crate) fn color_field(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_label: &str, none_value: &str) {
ui.horizontal(|ui| {
let id = ui.id().with("colorField");
let id = ui.scope_id().with("colorField");
crate::colors::take_field(ui.ctx(), id, value);
let c = wordcraft_doc::Rgb::parse(value);
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
@@ -1356,7 +1356,7 @@ impl Dialog {
/// Grid picker for Insert › Table (hover to size, click to insert).
pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
let t = Tokens::get(ui.ctx());
let id = egui::Id::new("table_picker_hover");
let id = egui::Id::unique("table_picker_hover");
let hover: (usize, usize) = ui.data(|d| d.get_temp(id)).unwrap_or((0, 0));
ui.label(if hover.0 > 0 {
crate::i18n::fmt(tl!("{cols}×{rows} Table"), &[("cols", &hover.1.to_string()), ("rows", &hover.0.to_string())])
@@ -1396,7 +1396,7 @@ pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
/// The open dialog, as (window id, frames it has been centred for).
fn dialog_top_key() -> egui::Id {
egui::Id::new("dialog_top")
egui::Id::unique("dialog_top")
}
/// Frames a dialog stays centred: the first one measures it, the second centres that size.
@@ -1487,7 +1487,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::Organizer { .. } => "Organizer",
Dialog::Layout(d) => d.title(),
};
let id = egui::Id::new(("dialog", title));
let id = egui::Id::unique(("dialog", title));
let window = crate::window_close::window(ctx, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false));
let shown = place_dialog(ctx, id, window).show(ctx, |ui| {
ui.set_min_width(340.0);
@@ -1628,10 +1628,12 @@ fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &mut bool, c
egui::Grid::new("ps_paper").num_columns(4).show(ui, |ui| {
use wordcraft_engine::cmd::page::{MAX_PAGE_SIDE, MIN_PAGE_SIDE};
let range = MIN_PAGE_SIDE / ppu..=MAX_PAGE_SIDE / ppu;
ui.label(tl!(l.width));
let w = ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2));
ui.label(tl!(l.height));
let h = ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2));
let width_label = ui.label(tl!(l.width));
let w = ui
.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2))
.labelled_by(width_label.id);
let height_label = ui.label(tl!(l.height));
let h = ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)).labelled_by(height_label.id);
if (w.changed() || h.changed()) && paper.0 != paper.1 {
*landscape = paper.0 > paper.1;
}
@@ -1763,8 +1765,8 @@ fn apply_font(app: &mut WordApp, d: &Dialog) {
fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
match d {
Dialog::RenameDocument { name, message, focus } => {
ui.label(tl!("Document name"));
let r = ui.add(egui::TextEdit::singleline(name).desired_width(320.0).char_limit(200));
let label = ui.label(tl!("Document name"));
let r = ui.add(egui::TextEdit::singleline(name).desired_width(320.0).char_limit(200)).labelled_by(label.id);
if *focus {
r.request_focus();
*focus = false;
@@ -1858,15 +1860,15 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
ui.end_row();
ui.label(tl!("Size:"));
ui.text_edit_singleline(size);
let size_label = ui.label(tl!("Size:"));
ui.text_edit_singleline(size).labelled_by(size_label.id);
ui.end_row();
ui.label(tl!("Font color:"));
let theme = app.session.doc.settings.theme_colors.clone();
color_field(ui, &theme, color, "Automatic", "auto");
ui.end_row();
ui.label(tl!("Spacing (pt):"));
ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0));
let spacing_label = ui.label(tl!("Spacing (pt):"));
ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0)).labelled_by(spacing_label.id);
ui.end_row();
let label = tl!("Kerning for fonts:");
match kern {
@@ -1880,8 +1882,9 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
ui.horizontal(|ui| {
ui.add_enabled(*kern == Some(true), egui::DragValue::new(kern_at).speed(0.5).range(1.0..=1638.0).max_decimals(1));
ui.label(tl!("Points and above"));
let points = ui.add_enabled(*kern == Some(true), egui::DragValue::new(kern_at).speed(0.5).range(1.0..=1638.0).max_decimals(1));
let points_label = ui.label(tl!("Points and above"));
points.labelled_by(points_label.id);
});
ui.end_row();
});
@@ -1959,43 +1962,44 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
const ALIGNS: [(&str, &str); 4] = [("left", "Left"), ("center", "Center"), ("right", "Right"), ("justify", "Justify")];
ui.horizontal(|ui| {
ui.label(tl!("Alignment:"));
let align_label = ui.label(tl!("Alignment:"));
let shown = if mixed.contains("align") {
""
} else {
ALIGNS.iter().find(|(v, _)| v == align).map_or(align.as_str(), |(_, l)| tl!(l))
};
egui::ComboBox::from_id_salt("para_align").selected_text(shown).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("para_align").selected_text(shown).show_ui(ui, |ui| {
for (v, l) in ALIGNS {
if ui.selectable_value(align, v.to_string(), tl!(l)).clicked() {
mixed.remove("align");
}
}
});
combo.response.labelled_by(align_label.id);
});
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
// Mirrored indents are the inside and outside ones (they swap on even pages).
let (l_label, r_label) = if opts[0] { ("Inside:", "Outside:") } else { ("Left:", "Right:") };
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
ui.label(tl!(l_label));
para_number(ui, left, ParaNumber::Length, "left", mixed);
ui.label(tl!(r_label));
para_number(ui, right, ParaNumber::Length, "right", mixed);
let left_label = ui.label(tl!(l_label));
para_number(ui, left, ParaNumber::Length, "left", mixed, &left_label);
let right_label = ui.label(tl!(r_label));
para_number(ui, right, ParaNumber::Length, "right", mixed, &right_label);
ui.end_row();
ui.label(tl!("First line:"));
para_number(ui, first, ParaNumber::Length, "indentFirst", mixed);
let first_label = ui.label(tl!("First line:"));
para_number(ui, first, ParaNumber::Length, "indentFirst", mixed, &first_label);
ui.end_row();
});
para_check(ui, &mut opts[0], tl!("Mirror indents"), "mirrorIndents", mixed);
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
ui.label(tl!("Before:"));
para_number(ui, before, ParaNumber::Points, "spaceBefore", mixed);
ui.label(tl!("Line spacing:"));
para_number(ui, line, ParaNumber::Lines, "lineSpacing", mixed);
let before_label = ui.label(tl!("Before:"));
para_number(ui, before, ParaNumber::Points, "spaceBefore", mixed, &before_label);
let line_label = ui.label(tl!("Line spacing:"));
para_number(ui, line, ParaNumber::Lines, "lineSpacing", mixed, &line_label);
ui.end_row();
ui.label(tl!("After:"));
para_number(ui, after, ParaNumber::Points, "spaceAfter", mixed);
let after_label = ui.label(tl!("After:"));
para_number(ui, after, ParaNumber::Points, "spaceAfter", mixed, &after_label);
ui.end_row();
});
para_check(ui, &mut opts[1], tl!("Don't add space between paragraphs of the same style"), "contextualSpacing", mixed);
@@ -2054,8 +2058,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
let replace_mode = *tab == FindTab::Replace;
ui.horizontal(|ui| {
ui.label(tl!("Find what:"));
let r = ui.add(egui::TextEdit::singleline(query).id(egui::Id::new("find_what")).desired_width(240.0));
let find_label = ui.label(tl!("Find what:"));
let r = ui.add(egui::TextEdit::singleline(query).id(egui::Id::unique("find_what")).desired_width(240.0)).labelled_by(find_label.id);
if std::mem::take(focus) {
r.request_focus();
}
@@ -2065,8 +2069,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
if replace_mode {
ui.horizontal(|ui| {
ui.label(tl!("Replace with:"));
if ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)).gained_focus() {
let replace_label = ui.label(tl!("Replace with:"));
if ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)).labelled_by(replace_label.id).gained_focus() {
extra.to_replace = true;
}
});
@@ -2242,17 +2246,21 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
} => {
ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5)));
egui::Grid::new("ps").num_columns(4).show(ui, |ui| {
ui.label(tl!("Top:"));
ui.add(egui::DragValue::new(top).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2));
ui.label(tl!("Bottom:"));
let top_label = ui.label(tl!("Top:"));
ui.add(egui::DragValue::new(top).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2))
.labelled_by(top_label.id);
let bottom_label = ui.label(tl!("Bottom:"));
ui.add(
egui::DragValue::new(bottom).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2),
);
)
.labelled_by(bottom_label.id);
ui.end_row();
ui.label(if *mirror { tl!("Inside:") } else { tl!("Left:") });
ui.add(egui::DragValue::new(left).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2));
ui.label(if *mirror { tl!("Outside:") } else { tl!("Right:") });
ui.add(egui::DragValue::new(right).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2));
let left_label = ui.label(if *mirror { tl!("Inside:") } else { tl!("Left:") });
ui.add(egui::DragValue::new(left).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2))
.labelled_by(left_label.id);
let right_label = ui.label(if *mirror { tl!("Outside:") } else { tl!("Right:") });
ui.add(egui::DragValue::new(right).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2))
.labelled_by(right_label.id);
ui.end_row();
});
ui.label(egui::RichText::new(tl!("Orientation")).font(semibold(12.5)));
@@ -2271,10 +2279,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.radio_value(&mut grid.0, 3, tl!("Text snaps to characters"));
});
egui::Grid::new("ps_grid").num_columns(4).show(ui, |ui| {
ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:")));
ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000));
ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:")));
ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000));
let chars_label = ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:")));
ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id);
let lines_label = ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:")));
ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id);
ui.end_row();
});
ui.horizontal(|ui| {
@@ -2348,7 +2356,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.end_row();
if !*place {
ui.label(tl!("Address:"));
let mut out = egui::TextEdit::singleline(url).id(egui::Id::new("link_address")).show(ui);
let mut out = egui::TextEdit::singleline(url).id(egui::Id::unique("link_address")).show(ui);
if std::mem::take(focus) {
out.response.request_focus();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(url.chars().count()));
@@ -2560,8 +2568,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
Dialog::ModifyStyle { back, resume, id, name, font, size, bold, italic, color, before, after } => {
egui::Grid::new("ms").num_columns(2).show(ui, |ui| {
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
let name_label = ui.label(tl!("Name:"));
ui.text_edit_singleline(name).labelled_by(name_label.id);
ui.end_row();
ui.label(tl!("Font:"));
let shown = app.previews.font_shown(font);
@@ -2575,16 +2583,16 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
ui.end_row();
ui.label(tl!("Size:"));
ui.add(egui::DragValue::new(size).range(1.0..=1638.0).speed(0.5));
let size_label = ui.label(tl!("Size:"));
ui.add(egui::DragValue::new(size).range(1.0..=1638.0).speed(0.5)).labelled_by(size_label.id);
ui.end_row();
ui.label(tl!("Color:"));
ui.text_edit_singleline(color);
let color_label = ui.label(tl!("Color:"));
ui.text_edit_singleline(color).labelled_by(color_label.id);
ui.end_row();
ui.label(tl!("Space before/after:"));
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(before).range(0.0..=1584.0));
ui.add(egui::DragValue::new(after).range(0.0..=1584.0));
ui.add(egui::DragValue::new(before).range(0.0..=1584.0)).accessible_name(tl!("Space before"));
ui.add(egui::DragValue::new(after).range(0.0..=1584.0)).accessible_name(tl!("Space after"));
});
ui.end_row();
});
@@ -3001,13 +3009,13 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.radio_value(choice, 0, tl!("All"));
ui.radio_value(choice, 1, tl!("Current record"));
ui.horizontal(|ui| {
ui.radio_value(choice, 2, tl!("From:"));
let from_label = ui.radio_value(choice, 2, tl!("From:"));
// Typing a number picks the range, as in Word.
if ui.add(egui::TextEdit::singleline(from).desired_width(60.0)).changed() {
if ui.add(egui::TextEdit::singleline(from).desired_width(60.0)).labelled_by(from_label.id).changed() {
*choice = 2;
}
ui.label(tl!("To:"));
if ui.add(egui::TextEdit::singleline(to).desired_width(60.0)).changed() {
let to_label = ui.label(tl!("To:"));
if ui.add(egui::TextEdit::singleline(to).desired_width(60.0)).labelled_by(to_label.id).changed() {
*choice = 2;
}
});
@@ -3182,7 +3190,8 @@ pub(crate) fn recipient_list(
ui.horizontal(|ui| {
ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover());
for (i, f) in fields.iter_mut().enumerate() {
ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(f).font(egui::TextStyle::Button).hint_text(tl!("Field name")));
ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(f).font(egui::TextStyle::Button).hint_text(tl!("Field name")))
.accessible_name(format!("{} {}", tl!("Field name"), i + 1));
let x = ui.add_enabled(can_remove, egui::Button::new("×").small().min_size(vec2(X_W, X_W))).on_hover_text(tl!("Remove field"));
x_w = x.rect.width();
if x.clicked() {
@@ -3210,7 +3219,10 @@ pub(crate) fn recipient_list(
for (j, c) in row.iter_mut().enumerate() {
// Keyed on the record, not its place in the grid: when the view hides
// or moves rows, the keyboard can't land in another record (#1576).
let cell = ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(c).id_salt(("recipient_cell", r, j)));
let field_name = fields.get(j).map_or("", String::as_str);
let cell = ui
.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(c).id_salt(("recipient_cell", r, j)))
.accessible_name(crate::i18n::fmt(tl!("{field}, record {n}"), &[("field", field_name), ("n", &(r + 1).to_string())]));
if cell.has_focus() {
focused = Some(cell.id);
}
@@ -3518,7 +3530,7 @@ enum ParaNumber {
/// A Paragraph dialog number box for the field `key`. While the selected paragraphs differ in
/// it, it shows nothing, not even the unit, until a value is typed or dragged in.
fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: &str, mixed: &mut BTreeSet<String>) {
fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: &str, mixed: &mut BTreeSet<String>, label: &egui::Response) {
let unit = crate::options::unit();
let blank = mixed.contains(key);
// A value from the document outside the range (line spacing 6) stays until edited.
@@ -3534,7 +3546,7 @@ fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: &str, mixed:
ParaNumber::Points => field.suffix(format!(" {}", tl!("pt"))),
ParaNumber::Lines => field,
};
if ui.add(field).changed() {
if ui.add(field).labelled_by(label.id).changed() {
mixed.remove(key);
}
}
@@ -3607,8 +3619,8 @@ fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_location: &mut
let (mut add, mut delete, mut go_to) = (false, false, false);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.label(tl!("Bookmark name:"));
ui.add(egui::TextEdit::singleline(name).desired_width(240.0));
let name_label = ui.label(tl!("Bookmark name:"));
ui.add(egui::TextEdit::singleline(name).desired_width(240.0)).labelled_by(name_label.id);
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| {
ui.set_width(232.0);
ui.set_height(150.0);
@@ -3684,7 +3696,7 @@ fn manage_styles(app: &mut WordApp, ui: &mut Ui, alphabetical: &mut bool, select
});
});
let t = Tokens::get(ui.ctx());
let shown = egui::Id::new("manage_styles_shown");
let shown = egui::Id::unique("manage_styles_shown");
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| {
egui::ScrollArea::vertical().id_salt("manage_styles_list").max_height(200.0).auto_shrink([false, false]).show(ui, |ui| {
ui.set_width(380.0);
@@ -3828,7 +3840,7 @@ const SEARCH_SUGGESTIONS: [&str; 3] = ["review.spelling", "review.wordCount", "f
/// Search's "Find … in the document": the Navigation pane opens on its results for `text`.
fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, text: &str) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("nav_query"), text.to_string()));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("nav_query"), text.to_string()));
app.ui.nav_tab = "results".into();
let _ = app.run("view.navigationPane", json!({"value": true}));
let _ = app.run("edit.find", json!({"text": text}));
@@ -3890,20 +3902,25 @@ fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, target: &mut S
ui.add_space(16.0);
ui.vertical(|ui| match what {
GoToWhat::Page => {
ui.label(tl!("Enter page number:"));
let r = ui.add(egui::TextEdit::singleline(target).desired_width(220.0));
let label = ui.label(tl!("Enter page number:"));
let r = ui.add(egui::TextEdit::singleline(target).desired_width(220.0)).labelled_by(label.id);
if !r.has_focus() {
r.request_focus();
}
ui.label(egui::RichText::new(tl!("Type +2 or -1 to move from the page you are on.")).weak());
}
GoToWhat::Bookmark => {
ui.label(tl!("Enter bookmark name:"));
egui::ComboBox::from_id_salt("goto_bookmark").width(220.0).selected_text(target.as_str()).show_ui(ui, |ui| {
for b in &bookmarks {
ui.selectable_value(target, b.clone(), b.as_str());
}
});
let label = ui.label(tl!("Enter bookmark name:"));
egui::ComboBox::from_id_salt("goto_bookmark")
.width(220.0)
.selected_text(target.as_str())
.show_ui(ui, |ui| {
for b in &bookmarks {
ui.selectable_value(target, b.clone(), b.as_str());
}
})
.response
.labelled_by(label.id);
if bookmarks.is_empty() {
ui.label(egui::RichText::new(tl!("This document has no bookmarks.")).weak());
}
@@ -4079,13 +4096,14 @@ fn page_valign_combo(ui: &mut egui::Ui, valign: &mut wordcraft_doc::section::Pag
const CHOICES: [(PageVAlign, &str); 4] =
[(PageVAlign::Top, "Top"), (PageVAlign::Center, "Center"), (PageVAlign::Justify, "Justified"), (PageVAlign::Bottom, "Bottom")];
ui.horizontal(|ui| {
ui.label(tl!("Vertical alignment:"));
let label = ui.label(tl!("Vertical alignment:"));
let shown = CHOICES.iter().find(|(v, _)| v == valign).map_or("", |(_, l)| tl!(l));
egui::ComboBox::from_id_salt("ps_valign").selected_text(shown).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("ps_valign").selected_text(shown).show_ui(ui, |ui| {
for (v, l) in CHOICES {
ui.selectable_value(valign, v, tl!(l));
}
});
combo.response.labelled_by(label.id);
});
}
@@ -4128,7 +4146,7 @@ mod tests {
fn the_bookmark_dialog_goes_to_and_deletes_bookmarks() {
fn press(h: &mut egui_kittest::Harness<'_, WordApp>, label: &str) {
use egui_kittest::kittest::Queryable;
let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "Bookmark")))).expect("the dialog is open");
let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Bookmark")))).expect("the dialog is open");
h.query_all_by_label(label).find(|n| dialog.contains(n.rect().center())).expect(label).click();
for _ in 0..3 {
h.step();
@@ -4474,6 +4492,8 @@ mod tests {
for _ in 0..4 {
h.step();
}
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "From:");
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "To:");
h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click();
for _ in 0..2 {
h.step();
@@ -4843,7 +4863,7 @@ mod tests {
for _ in 0..4 {
h.step();
}
let id = egui::Id::new(("dialog", "Paragraph"));
let id = egui::Id::unique(("dialog", "Paragraph"));
let before = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is open");
let screen = h.ctx.content_rect();
assert!((before.center().x - screen.center().x).abs() <= 1.0, "opens centred: {before:?} in {screen:?}");
@@ -4894,7 +4914,7 @@ mod tests {
for _ in 0..4 {
h.step();
}
let id = egui::Id::new(("dialog", "Word Count"));
let id = egui::Id::unique(("dialog", "Word Count"));
let before = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is open");
let bx = crate::window_close::close_rect(before, 1.0, crate::window_close::geometry(&h.ctx));
let title = h.ctx.read_response(id.with("__title_click")).expect("egui's title bar").rect;
@@ -5199,7 +5219,9 @@ mod tests {
for _ in 0..4 {
h.step();
}
assert!(h.query_by_label("Vertical alignment:").is_some(), "the dialog has the control");
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Vertical alignment:");
h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Characters per line:");
h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Lines per page:");
h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click();
for _ in 0..4 {
h.step();
+31 -1
View File
@@ -287,7 +287,7 @@ pub fn data_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartDataForm) -> bool {
}
if let Some(row) = f.cells.get_mut(i) {
for cell in row.iter_mut() {
ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(cell).horizontal_align(egui::Align::Max));
ui.add_sized([CELL_W, ROW_H - 4.0], value_cell(cell));
ui.allocate_exact_size(vec2(X_W, ROW_H - 4.0), Sense::hover());
}
}
@@ -336,6 +336,13 @@ pub fn data_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartDataForm) -> bool {
}
}
/// A numeric chart-data cell. `RIGHT_TOP` preserves the old deprecated
/// `horizontal_align(Align::Max)` behaviour: numbers are right-aligned but stay on the cell's
/// first line, rather than becoming vertically centred.
fn value_cell(text: &mut String) -> egui::TextEdit<'_> {
egui::TextEdit::singleline(text).align(egui::Align2::RIGHT_TOP)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -356,4 +363,27 @@ mod tests {
f.cells[1][0] = "abc".into();
assert!(f.params().is_err());
}
/// The chart-data grid kept Word's top line when the deprecated `horizontal_align(Align::Max)`
/// became `align(Align2)`: `RIGHT_CENTER` silently changed vertical alignment too.
#[test]
fn chart_value_cells_are_right_and_top_aligned() {
fn text_pos(shape: &egui::Shape) -> Option<egui::Pos2> {
match shape {
egui::Shape::Text(text) => Some(text.pos),
egui::Shape::Vec(shapes) => shapes.iter().find_map(text_pos),
_ => None,
}
}
let ctx = egui::Context::default();
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(120.0, 120.0))), ..Default::default() };
let mut text = "7".to_owned();
let output = ctx.run_ui(raw, |ui| {
ui.add_sized([100.0, 100.0], value_cell(&mut text));
});
let pos = output.shapes.iter().find_map(|clipped| text_pos(&clipped.shape)).expect("the cell's text is painted");
output.drop_without_applying_deltas();
assert!(pos.y < 20.0, "RIGHT_TOP leaves the number on the first line, not centred (pos={pos:?})");
assert!(pos.x > 40.0, "the number is right-aligned (pos={pos:?})");
}
}
+4 -1
View File
@@ -111,7 +111,9 @@ pub(crate) fn list(ui: &mut Ui, id: &str, size: egui::Vec2, rows: &[(String, Str
ui.centered_and_justified(|ui| ui.label(egui::RichText::new(tl!("(none)")).weak()));
return;
}
let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
// Virtual rows must include the button's padding and minimum interaction height;
// otherwise the last row can extend beyond the scroll area's declared content height.
let row_h = (ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y).max(ui.spacing().interact_size.y);
// Bring the selected row into view when the selection (or the list) changes, so the row
// an action targets is never off-screen; scrolling by hand doesn't change the selection.
let seen = ui.make_persistent_id(("scrolled_to", id));
@@ -432,6 +434,7 @@ mod tests {
delta: egui::vec2(0.0, dy),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
});
h.step();
}
+1 -1
View File
@@ -434,7 +434,7 @@ fn choice(ui: &mut Ui, id: &str, value: &mut String, options: &[(&str, &str)]) {
/// hex.
fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_label: &str, auto: Option<wordcraft_doc::Rgb>) {
ui.horizontal(|ui| {
let id = ui.id().with("colorField");
let id = ui.scope_id().with("colorField");
crate::colors::take_field(ui.ctx(), id, value);
let c = wordcraft_doc::Rgb::parse(value);
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
+47 -12
View File
@@ -231,10 +231,12 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
// Position, edited in place: the interface unit by default, any
// unit spelled out, either decimal separator (`.measure`).
let mut pv = t.pos / k;
let r = ui.add_sized(
vec2(58.0, 18.0),
egui::DragValue::new(&mut pv).speed(0.05).range(-1584.0 / k..=1584.0 / k).measure(u).max_decimals(2),
);
let r = ui
.add_sized(
vec2(58.0, 18.0),
egui::DragValue::new(&mut pv).speed(0.05).range(-1584.0 / k..=1584.0 / k).measure(u).max_decimals(2),
)
.accessible_name(format!("{} {}", tl!("Tab stop position:"), i + 1));
if r.changed() {
f.set_pos(i, pv * k);
}
@@ -253,7 +255,9 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
for v in crate::ruler_tabs::KINDS {
ui.selectable_value(&mut a, v, tl!(crate::ruler_tabs::kind_label(v)));
}
});
})
.response
.accessible_name(format!("{} {}", tl!("Alignment:"), i + 1));
if a != t.align {
f.set_align(i, a);
pick = Some(i);
@@ -265,11 +269,16 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
.find(|(v, _, _)| *v == l)
.map(|(_, _, fill)| if fill.is_empty() { tl!("None").to_string() } else { (*fill).to_string() })
.unwrap_or_default();
egui::ComboBox::from_id_salt("tab_leader").width(64.0).selected_text(shown).show_ui(ui, |ui| {
for (v, label, _) in LEADERS {
ui.selectable_value(&mut l, v, tl!(label));
}
});
egui::ComboBox::from_id_salt("tab_leader")
.width(64.0)
.selected_text(shown)
.show_ui(ui, |ui| {
for (v, label, _) in LEADERS {
ui.selectable_value(&mut l, v, tl!(label));
}
})
.response
.accessible_name(format!("{} {}", tl!("Leader"), i + 1));
if l != t.leader {
f.set_leader(i, l);
pick = Some(i);
@@ -313,8 +322,9 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
ui.add_space(12.0);
ui.vertical(|ui| {
ui.set_min_width(130.0);
ui.label(tl!("Default tab stops:"));
ui.add(egui::DragValue::new(&mut f.default_tab).speed(0.05).range(1.0 / k..=1584.0 / k).measure(u).max_decimals(2));
let default_label = ui.label(tl!("Default tab stops:"));
ui.add(egui::DragValue::new(&mut f.default_tab).speed(0.05).range(1.0 / k..=1584.0 / k).measure(u).max_decimals(2))
.labelled_by(default_label.id);
ui.add_space(8.0);
ui.label(tl!("Tab stops to be cleared:"));
let list = if f.clear_all {
@@ -914,6 +924,31 @@ mod tests {
assert_eq!(f.stops[0].pos, 36.0);
}
/// The inline tab-stop rows are named by their column, and the default interval by its label.
#[test]
fn tab_stop_fields_have_semantic_names() {
use egui_kittest::kittest::Queryable;
let mut app = app_with("a\tb");
app.run("para.tabs", json!({"set": [{"pos": 72.0}]})).unwrap();
app.dialog = crate::dialogs::Dialog::open("tabs", &mut app);
assert!(matches!(app.dialog, Some(crate::dialogs::Dialog::Tabs { .. })));
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..4 {
h.step();
}
h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Tab stop position: 1");
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Alignment: 1");
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Leader 1");
h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Default tab stops:");
}
/// #3031: a paragraph with no stops — the default — says so in the list box instead of
/// showing a blank frame.
#[test]
+57 -50
View File
@@ -149,14 +149,15 @@ fn heading(ui: &mut Ui, s: &str) {
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
}
/// A choice from `options` ((value, label)) in a drop-down.
fn choice<T: PartialEq + Clone>(ui: &mut Ui, id: &str, value: &mut T, options: &[(T, &str)]) {
let label = options.iter().find(|(v, _)| v == value).map(|(_, l)| tl!(l).to_string()).unwrap_or_default();
egui::ComboBox::from_id_salt(id).selected_text(label).width(150.0).show_ui(ui, |ui| {
/// A choice from `options` ((value, label)) in a drop-down, named by its visible label.
fn choice<T: PartialEq + Clone>(ui: &mut Ui, id: &str, value: &mut T, options: &[(T, &str)], label: &egui::Response) {
let shown = options.iter().find(|(v, _)| v == value).map(|(_, l)| tl!(l).to_string()).unwrap_or_default();
let combo = egui::ComboBox::from_id_salt(id).selected_text(shown).width(150.0).show_ui(ui, |ui| {
for (v, l) in options {
ui.selectable_value(value, v.clone(), tl!(l));
}
});
combo.response.labelled_by(label.id);
}
const LEADERS: [(&str, &str); 4] = [("none", "(none)"), ("dot", "........"), ("hyphen", "--------"), ("underscore", "________")];
@@ -312,24 +313,25 @@ impl CaptionForm {
}
fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> Next {
ui.label(tl!("Caption:"));
let caption_label = ui.label(tl!("Caption:"));
ui.horizontal(|ui| {
ui.label(f.preview());
ui.add(egui::TextEdit::singleline(&mut f.text).desired_width(220.0).hint_text(tl!("Caption text")));
ui.add(egui::TextEdit::singleline(&mut f.text).desired_width(220.0).hint_text(tl!("Caption text"))).labelled_by(caption_label.id);
});
ui.add_space(6.0);
let mut then = None;
egui::Grid::new("cap_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Label:"));
let label_label = ui.label(tl!("Label:"));
let labels: Vec<(String, &str)> = f.labels.iter().map(|l| (l.clone(), l.as_str())).collect();
egui::ComboBox::from_id_salt("cap_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("cap_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| {
for (v, l) in &labels {
ui.selectable_value(&mut f.label, v.clone(), tl!(l));
}
});
combo.response.labelled_by(label_label.id);
ui.end_row();
ui.label(tl!("Position:"));
choice(ui, "cap_pos", &mut f.position, &[("below".to_string(), "Below the item"), ("above".to_string(), "Above the item")]);
let position_label = ui.label(tl!("Position:"));
choice(ui, "cap_pos", &mut f.position, &[("below".to_string(), "Below the item"), ("above".to_string(), "Above the item")], &position_label);
ui.end_row();
});
ui.checkbox(&mut f.exclude_label, tl!("Leave the label out of the caption"));
@@ -421,14 +423,14 @@ impl CrossRefForm {
fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next {
let was = f.to.clone();
egui::Grid::new("xref_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Reference type:"));
let type_label = ui.label(tl!("Reference type:"));
let types = ref_types(app);
let types: Vec<(String, &str)> = types.iter().map(|(v, l)| (v.clone(), l.as_str())).collect();
choice(ui, "xref_type", &mut f.to, &types);
choice(ui, "xref_type", &mut f.to, &types, &type_label);
ui.end_row();
ui.label(tl!("Insert reference to:"));
let show_label = ui.label(tl!("Insert reference to:"));
let shows: Vec<(String, &str)> = f.shows().iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "xref_show", &mut f.show, &shows);
choice(ui, "xref_show", &mut f.show, &shows, &show_label);
ui.end_row();
});
if f.to != was {
@@ -461,26 +463,27 @@ fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next {
fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next {
egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Format:"));
let format_label = ui.label(tl!("Format:"));
let opts: Vec<(String, &str)> = NUMBER_FORMATS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "cap_fmt", &mut f.format, &opts);
choice(ui, "cap_fmt", &mut f.format, &opts, &format_label);
ui.end_row();
});
ui.checkbox(&mut f.chapter, tl!("Add the chapter number"));
ui.add_enabled_ui(f.chapter, |ui| {
egui::Grid::new("cap_chap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Chapters begin at:"));
let chapter_label = ui.label(tl!("Chapters begin at:"));
let opts: Vec<(u8, String)> = (1..=9u8).map(|l| (l, crate::i18n::fmt(tl!("Heading {n}"), &[("n", &l.to_string())]))).collect();
let shown = opts.iter().find(|(l, _)| *l == f.chapter_level).map(|(_, s)| s.clone()).unwrap_or_default();
egui::ComboBox::from_id_salt("cap_level").selected_text(shown).width(150.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("cap_level").selected_text(shown).width(150.0).show_ui(ui, |ui| {
for (l, s) in &opts {
ui.selectable_value(&mut f.chapter_level, *l, s);
}
});
combo.response.labelled_by(chapter_label.id);
ui.end_row();
ui.label(tl!("Separator:"));
let separator_label = ui.label(tl!("Separator:"));
let opts: Vec<(String, &str)> = SEPARATORS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "cap_sep", &mut f.separator, &opts);
choice(ui, "cap_sep", &mut f.separator, &opts, &separator_label);
ui.end_row();
});
});
@@ -497,8 +500,8 @@ fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next {
}
fn new_label_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> Next {
ui.label(tl!("Label:"));
let r = ui.add(egui::TextEdit::singleline(&mut f.new_label).desired_width(260.0));
let label = ui.label(tl!("Label:"));
let r = ui.add(egui::TextEdit::singleline(&mut f.new_label).desired_width(260.0)).labelled_by(label.id);
if !r.has_focus() {
r.request_focus();
}
@@ -587,16 +590,16 @@ fn index_ui(app: &mut WordApp, ui: &mut Ui, f: &mut IndexForm) -> Next {
ui.radio_value(&mut f.kind, "runIn".to_string(), tl!("Run-in"));
});
ui.end_row();
ui.label(tl!("Columns:"));
ui.add(egui::DragValue::new(&mut f.columns).range(1..=4));
let columns_label = ui.label(tl!("Columns:"));
ui.add(egui::DragValue::new(&mut f.columns).range(1..=4)).labelled_by(columns_label.id);
ui.end_row();
ui.label("");
ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin"));
ui.end_row();
ui.label(tl!("Tab leader:"));
let leader_label = ui.label(tl!("Tab leader:"));
ui.add_enabled_ui(f.right_align, |ui| {
let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "idx_leader", &mut f.leader, &opts);
choice(ui, "idx_leader", &mut f.leader, &opts, &leader_label);
});
ui.end_row();
ui.label("");
@@ -664,30 +667,30 @@ impl MarkForm {
fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next {
heading(ui, "Index");
egui::Grid::new("xe").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Main entry:"));
ui.add(egui::TextEdit::singleline(&mut f.entry).desired_width(220.0));
let entry_label = ui.label(tl!("Main entry:"));
ui.add(egui::TextEdit::singleline(&mut f.entry).desired_width(220.0)).labelled_by(entry_label.id);
ui.end_row();
ui.label(tl!("Subentry:"));
ui.add(egui::TextEdit::singleline(&mut f.subentry).desired_width(220.0));
let subentry_label = ui.label(tl!("Subentry:"));
ui.add(egui::TextEdit::singleline(&mut f.subentry).desired_width(220.0)).labelled_by(subentry_label.id);
ui.end_row();
});
heading(ui, "Options");
ui.horizontal(|ui| {
ui.radio_value(&mut f.option, "see".to_string(), tl!("Cross-reference:"));
ui.add_enabled(f.option == "see", egui::TextEdit::singleline(&mut f.see).desired_width(160.0));
let see_label = ui.radio_value(&mut f.option, "see".to_string(), tl!("Cross-reference:"));
ui.add_enabled(f.option == "see", egui::TextEdit::singleline(&mut f.see).desired_width(160.0)).labelled_by(see_label.id);
});
ui.radio_value(&mut f.option, "page".to_string(), tl!("This page"));
ui.horizontal(|ui| {
ui.add_enabled_ui(!f.bookmarks.is_empty(), |ui| {
ui.radio_value(&mut f.option, "range".to_string(), tl!("Pages of bookmark:"));
});
let bookmark_label =
ui.add_enabled_ui(!f.bookmarks.is_empty(), |ui| ui.radio_value(&mut f.option, "range".to_string(), tl!("Pages of bookmark:"))).inner;
ui.add_enabled_ui(f.option == "range", |ui| {
let opts: Vec<(String, &str)> = f.bookmarks.iter().map(|b| (b.clone(), b.as_str())).collect();
egui::ComboBox::from_id_salt("xe_bm").selected_text(f.bookmark.clone()).width(140.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("xe_bm").selected_text(f.bookmark.clone()).width(140.0).show_ui(ui, |ui| {
for (v, l) in &opts {
ui.selectable_value(&mut f.bookmark, v.clone(), *l);
}
});
combo.response.labelled_by(bookmark_label.id);
});
});
heading(ui, "Page number style");
@@ -824,17 +827,17 @@ fn toc_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm) -> Next {
ui.add_enabled_ui(f.page_numbers, |ui| {
ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin"));
ui.horizontal(|ui| {
ui.label(tl!("Tab leader:"));
let leader_label = ui.label(tl!("Tab leader:"));
ui.add_enabled_ui(f.right_align, |ui| {
let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "toc_leader", &mut f.leader, &opts);
choice(ui, "toc_leader", &mut f.leader, &opts, &leader_label);
});
});
});
ui.checkbox(&mut f.hyperlinks, tl!("Link entries to their headings"));
ui.horizontal(|ui| {
ui.label(tl!("Levels:"));
ui.add(egui::DragValue::new(&mut f.levels).range(1..=9));
let levels_label = ui.label(tl!("Levels:"));
ui.add(egui::DragValue::new(&mut f.levels).range(1..=9)).labelled_by(levels_label.id);
});
ui.add_space(4.0);
let mut then = None;
@@ -867,8 +870,9 @@ fn toc_options_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm, was: &TocForm
for (name, level) in f.style_levels.iter_mut() {
// Built-in styles read in the interface language; the document keeps the English name.
let shown = app.session.doc.styles.styles.iter().find(|st| st.name == *name).map_or_else(|| name.clone(), crate::i18n::style_name);
ui.label(shown);
ui.add(egui::DragValue::new(level).range(0..=9).custom_formatter(|v, _| if v < 0.5 { String::new() } else { format!("{v:.0}") }));
let style_label = ui.label(shown);
ui.add(egui::DragValue::new(level).range(0..=9).custom_formatter(|v, _| if v < 0.5 { String::new() } else { format!("{v:.0}") }))
.labelled_by(style_label.id);
ui.end_row();
}
});
@@ -969,23 +973,24 @@ fn figures_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FiguresForm) -> Next {
sample(ui, &lines, f.right_align, leader_char(&f.leader));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(tl!("Label:"));
let label_label = ui.label(tl!("Label:"));
let labels: Vec<(String, &str)> = f.labels.iter().map(|l| (l.clone(), l.as_str())).collect();
egui::ComboBox::from_id_salt("tof_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("tof_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| {
for (v, l) in &labels {
ui.selectable_value(&mut f.label, v.clone(), tl!(l));
}
});
combo.response.labelled_by(label_label.id);
});
ui.checkbox(&mut f.include_label, tl!("Include label and number"));
ui.checkbox(&mut f.page_numbers, tl!("Include page numbers"));
ui.add_enabled_ui(f.page_numbers, |ui| {
ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin"));
ui.horizontal(|ui| {
ui.label(tl!("Tab leader:"));
let leader_label = ui.label(tl!("Tab leader:"));
ui.add_enabled_ui(f.right_align, |ui| {
let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "tof_leader", &mut f.leader, &opts);
choice(ui, "tof_leader", &mut f.leader, &opts, &leader_label);
});
});
});
@@ -1142,24 +1147,26 @@ impl SourceForm {
fn source_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SourceForm) -> Next {
egui::Grid::new("src").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| {
ui.label(tl!("Source type:"));
let kind_label = ui.label(tl!("Source type:"));
let shown = SOURCE_TYPES.iter().find(|(k, ..)| *k == f.source.kind).map(|(_, l, _)| tl!(l).to_string()).unwrap_or_default();
egui::ComboBox::from_id_salt("src_kind").selected_text(shown).width(200.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("src_kind").selected_text(shown).width(200.0).show_ui(ui, |ui| {
for (k, l, _) in SOURCE_TYPES {
ui.selectable_value(&mut f.source.kind, k.to_string(), tl!(l));
}
});
combo.response.labelled_by(kind_label.id);
ui.end_row();
let edit = f.edit;
for (key, label) in f.fields() {
ui.label(format!("{}:", tl!(label)));
let field_label = ui.label(format!("{}:", tl!(label)));
if let Some(v) = field_mut(&mut f.source, key) {
let hint = match key {
"author" => tl!("Last, First; Last, First"),
"tag" => tl!("Made from the author and year when empty"),
_ => "",
};
ui.add_enabled(!(edit && key == "tag"), egui::TextEdit::singleline(v).desired_width(260.0).hint_text(hint));
ui.add_enabled(!(edit && key == "tag"), egui::TextEdit::singleline(v).desired_width(260.0).hint_text(hint))
.labelled_by(field_label.id);
}
ui.end_row();
}
+6 -6
View File
@@ -435,11 +435,11 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
ui.checkbox(&mut o.caps_lock, tl!("Correct accidental usage of cAPS LOCK key"));
ui.checkbox(&mut o.replace_text, tl!("Replace text as you type"));
egui::Grid::new("ac_boxes").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| {
ui.label(tl!("Replace:"));
ui.label(tl!("With:"));
let replace_label = ui.label(tl!("Replace:"));
let with_label = ui.label(tl!("With:"));
ui.end_row();
ui.add(egui::TextEdit::singleline(&mut f.from).desired_width(110.0));
ui.add(egui::TextEdit::singleline(&mut f.to).desired_width(260.0));
ui.add(egui::TextEdit::singleline(&mut f.from).desired_width(110.0)).labelled_by(replace_label.id);
ui.add(egui::TextEdit::singleline(&mut f.to).desired_width(260.0)).labelled_by(with_label.id);
ui.end_row();
});
// The list, filtered to what's typed in Replace (as Word scrolls to it).
@@ -526,8 +526,8 @@ fn exceptions(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
fn math_tab(ui: &mut Ui, f: &mut AutoCorrectForm) {
ui.label(egui::RichText::new(tl!("In equations, typing a name after a backslash replaces it with its symbol.")).small().weak());
ui.horizontal(|ui| {
ui.label(tl!("Replace:"));
ui.add(egui::TextEdit::singleline(&mut f.math_filter).desired_width(140.0));
let label = ui.label(tl!("Replace:"));
ui.add(egui::TextEdit::singleline(&mut f.math_filter).desired_width(140.0)).labelled_by(label.id);
});
let filter = f.math_filter.trim().trim_start_matches('\\').to_string();
egui::ScrollArea::vertical().id_salt("ac_math").max_height(240.0).auto_shrink([false, true]).show(ui, |ui| {
+37 -11
View File
@@ -295,7 +295,7 @@ pub fn frame_button(app: &mut WordApp, ctx: &egui::Context, layout: &wordcraft_l
return;
}
let t = Tokens::get(ctx);
egui::Area::new(egui::Id::new("smart_art_frame_button")).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| {
egui::Area::new(egui::Id::unique("smart_art_frame_button")).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| {
egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border)).corner_radius(4).inner_margin(2).show(ui, |ui| {
if crate::widgets::icon_button(ui, "textPane", tl!("Text Pane")).clicked() {
let _ = app.run("smartArt.textPane", json!({"value": true}));
@@ -306,20 +306,43 @@ pub fn frame_button(app: &mut WordApp, ctx: &egui::Context, layout: &wordcraft_l
/// Text typed with a SmartArt graphic selected goes at the end of its current item (the first,
/// before any is picked), and the Text Pane opens, as Word does; it never replaces the graphic.
/// False when no graphic is selected.
pub fn type_into(app: &mut WordApp, typed: &str) -> bool {
let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else { return false };
let Some(i) = app.smart_art_item.filter(|i| *i < g.items.len()).or((!g.items.is_empty()).then_some(0)) else { return true };
///
/// What typing into a selected SmartArt graphic did.
///
/// A selected graphic always consumes the input, even when it has no item to edit: the typing
/// must not fall through into the body. Only the absence of a SmartArt target lets the body type.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SmartArtTypeResult {
/// No SmartArt graphic is selected: the caller may type into the body.
NotHandled,
/// A selected graphic consumed the input without editing the document.
ConsumedNoChange,
/// A SmartArt item really changed.
Changed,
}
pub(crate) fn type_into(app: &mut WordApp, typed: &str) -> SmartArtTypeResult {
let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else {
return SmartArtTypeResult::NotHandled;
};
let Some(i) = app.smart_art_item.filter(|i| *i < g.items.len()).or((!g.items.is_empty()).then_some(0)) else {
// Selected but empty: consume, never insert into the body, never claim an edit.
return SmartArtTypeResult::ConsumedNoChange;
};
let text = g.items.get(i).map_or(String::new(), |it| it.text.clone()) + typed;
if app.smart_art_typing == Some(i) {
app.session.join_next_undo();
}
if app.run("smartArt.items", items_with(&g, i, &text)).is_ok() {
app.smart_art_item = Some(i);
app.smart_art_typing = Some(i);
if app.run("smartArt.items", items_with(&g, i, &text)).is_err() {
// A selected graphic that refuses the edit still consumes the event; an IME composition
// must start afresh rather than be applied to the body.
app.canvas.ime_interrupt_now = true;
return SmartArtTypeResult::ConsumedNoChange;
}
app.smart_art_item = Some(i);
app.smart_art_typing = Some(i);
let _ = app.run("smartArt.textPane", json!({"value": true}));
true
SmartArtTypeResult::Changed
}
/// The Text Pane: a row per item, indented by level. Typing changes the graphic as you go (one
@@ -330,7 +353,7 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!("One line per item. Enter adds an item.")).color(t.text_dim));
ui.add_space(4.0);
let focus_id = egui::Id::new("smart_art_focus");
let focus_id = egui::Id::unique("smart_art_focus");
let want_focus: Option<usize> = ui.data_mut(|d| d.remove_temp(focus_id));
let mut typed = false;
app.smart_art_row_focused = false;
@@ -340,7 +363,10 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
ui.add_space(f32::from(it.level) * 16.0);
ui.label(egui::RichText::new("•").color(t.text_dim));
let mut text = it.text.clone();
let r = ui.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(f32::INFINITY));
let item_name = format!("{} {}", tl!("Text Pane"), i + 1);
let r = ui
.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(f32::INFINITY))
.accessible_name(item_name);
if want_focus == Some(i) {
r.request_focus();
}
+20 -15
View File
@@ -470,11 +470,11 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone());
let mut rebased = false;
egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
let name_label = ui.label(tl!("Name:"));
ui.text_edit_singleline(name).labelled_by(name_label.id);
ui.end_row();
ui.label(tl!("Style based on:"));
egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| {
let base_label = ui.label(tl!("Style based on:"));
let base = egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| {
for (sid, sname) in &styles {
if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on {
*based_on = sid.clone();
@@ -482,16 +482,18 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
}
}
});
base.response.labelled_by(base_label.id);
ui.end_row();
ui.label(tl!("Apply formatting to:"));
egui::ComboBox::from_id_salt("tstyle_region").width(200.0).selected_text(tl!(PARTS.get(*region).map_or("Whole Table", |(_, l)| *l))).show_ui(
ui,
|ui| {
let region_label = ui.label(tl!("Apply formatting to:"));
let part = egui::ComboBox::from_id_salt("tstyle_region")
.width(200.0)
.selected_text(tl!(PARTS.get(*region).map_or("Whole Table", |(_, l)| *l)))
.show_ui(ui, |ui| {
for (i, (_, n)) in PARTS.iter().enumerate() {
ui.selectable_value(region, i, tl!(*n));
}
},
);
});
part.response.labelled_by(region_label.id);
ui.end_row();
});
// A new style shows its new base's formatting; a modified one keeps its own.
@@ -512,7 +514,8 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
{
r.font = f;
}
ui.add(egui::TextEdit::singleline(&mut r.size).desired_width(36.0)).on_hover_text(tl!("Size:"));
let size = ui.add(egui::TextEdit::singleline(&mut r.size).desired_width(36.0));
size.on_hover_text(tl!("Size:")).accessible_name(tl!("Size:"));
ui.toggle_value(&mut r.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold"));
ui.toggle_value(&mut r.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic"));
ui.toggle_value(&mut r.underline, egui::RichText::new("U").underline()).on_hover_text(tl!("Underline"));
@@ -524,14 +527,15 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
// Borders: the pen (style, width, colour) and which edges; then the cell shading.
ui.horizontal(|ui| {
let current = LINE_STYLES.iter().find(|(s, _)| *s == pen.style).map_or("Single", |(_, n)| *n);
ui.label(tl!("Style:"));
egui::ComboBox::from_id_salt("tstyle_pen_style").selected_text(tl!(current)).width(90.0).show_ui(ui, |ui| {
let style_label = ui.label(tl!("Style:"));
let style = egui::ComboBox::from_id_salt("tstyle_pen_style").selected_text(tl!(current)).width(90.0).show_ui(ui, |ui| {
for (s, n) in LINE_STYLES {
ui.selectable_value(&mut pen.style, s, tl!(n));
}
});
ui.label(tl!("Width:"));
egui::ComboBox::from_id_salt("tstyle_pen_width")
style.response.labelled_by(style_label.id);
let width_label = ui.label(tl!("Width:"));
let width = egui::ComboBox::from_id_salt("tstyle_pen_width")
.selected_text(crate::i18n::fmt(tl!("{n} pt"), &[("n", &pen.width.to_string())]))
.width(70.0)
.show_ui(ui, |ui| {
@@ -539,6 +543,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
ui.selectable_value(&mut pen.width, w, crate::i18n::fmt(tl!("{n} pt"), &[("n", &w.to_string())]));
}
});
width.response.labelled_by(width_label.id);
ui.push_id("tstyle_pen_color", |ui| {
ui.label(tl!("Color:"));
color_menu(ui, &theme, &mut pen.color);
+42 -6
View File
@@ -40,11 +40,31 @@ fn paint_math(app: &mut WordApp, ui: &Ui, r: Rect, key: &str, linear: &str, node
}
None => {
let t = Tokens::get(ui.ctx());
ui.painter().text(r.center(), Align2::CENTER_CENTER, linear, regular(11.0), t.text_dim);
// Math previews are asynchronous. Interface fonts need not cover the math font's
// repertoire, so use readable linear commands until the actual equation is ready.
let placeholder = math_placeholder(ui, linear);
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, placeholder, regular(11.0), t.text_dim);
}
}
}
fn math_placeholder(ui: &Ui, linear: &str) -> String {
let font = regular(11.0);
let mut result = String::new();
for c in linear.chars() {
if c.is_whitespace() || ui.fonts_mut(|fonts| fonts.has_glyph(&font, c)) {
result.push(c);
} else if let Some(name) = wordcraft_doc::math_symbols::name_of(c) {
result.push('\\');
result.push_str(name);
} else {
use std::fmt::Write as _;
let _ = write!(result, "U+{:04X}", c as u32);
}
}
result
}
/// A big ribbon button whose picture is an equation; opens `menu`.
fn math_menu_button(ui: &mut Ui, app: &mut WordApp, picture: &str, label: &str, menu: impl FnOnce(&mut Ui, &mut WordApp)) {
let label = tl!(label);
@@ -154,8 +174,8 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) {
let (r, _) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::hover());
let top = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + BIG_LABEL_Y - 2.0));
let bottom = Rect::from_min_max(pos2(r.min.x, r.min.y + BIG_LABEL_Y - 2.0), r.max);
let rt = ui.interact(top, ui.id().with("eq_insert_top"), Sense::click());
let rb = ui.interact(bottom, ui.id().with("eq_insert_bottom"), Sense::click());
let rt = ui.interact(top, ui.scope_id().with("eq_insert_top"), Sense::click());
let rb = ui.interact(bottom, ui.scope_id().with("eq_insert_bottom"), Sense::click());
for (rr, resp) in [(top, &rt), (bottom, &rb)] {
if resp.hovered() {
ui.painter().rect_filled(rr, 4.0, t.hover);
@@ -178,10 +198,10 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) {
/// The Symbols group: a strip of the current set's symbols, and a menu with every set.
fn symbols(ui: &mut Ui, app: &mut WordApp) {
let id = egui::Id::new("eq_symbol_set");
let id = egui::Id::unique("eq_symbol_set");
let set = ui.data(|d| d.get_temp::<usize>(id)).unwrap_or(0).min(SYMBOL_SETS.len().saturating_sub(1));
let Some((_, chars)) = SYMBOL_SETS.get(set) else { return };
let start_id = egui::Id::new("eq_symbol_start");
let start_id = egui::Id::unique("eq_symbol_start");
let per_row = 10usize;
let rows = 3usize;
let all: Vec<char> = chars.chars().collect();
@@ -256,7 +276,9 @@ fn symbol_button(ui: &mut Ui, app: &mut WordApp, c: char, t: &Tokens) -> bool {
let s = c.to_string();
let nodes = vec![wordcraft_doc::math::MNode::Run(wordcraft_doc::math::MRun { text: s.clone(), lit: true, ..Default::default() })];
paint_math(app, ui, r, &format!("sym:{s}"), &s, Some(nodes), false, 11.0);
let name = wordcraft_doc::math_symbols::name_of(c).map(|n| format!("{s} \\{n}")).unwrap_or_else(|| format!("{s} U+{:04X}", c as u32));
let display = math_placeholder(ui, &s);
let name =
wordcraft_doc::math_symbols::name_of(c).map(|n| format!("{display} \\{n}")).unwrap_or_else(|| format!("{display} U+{:04X}", c as u32));
resp.on_hover_text(name).clicked()
}
@@ -378,3 +400,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
});
});
}
#[cfg(test)]
mod placeholder_tests {
#[test]
fn a_missing_math_glyph_has_a_readable_linear_placeholder() {
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
assert!(!ui.fonts_mut(|fonts| fonts.has_glyph(&super::regular(11.0), '∁')));
assert_eq!(super::math_placeholder(ui, "x+\u{2201}"), "x+\\complement");
assert_eq!(super::math_placeholder(ui, "x+y"), "x+y");
});
output.textures_delta.clear();
}
}
+12 -7
View File
@@ -309,7 +309,7 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let mut done: Option<Option<PathBuf>> = None;
let window = egui::Window::new(tl!(b.title()))
.id(egui::Id::new("builtin_file_browser"))
.id(egui::Id::unique("builtin_file_browser"))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0));
@@ -347,7 +347,7 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>
{
b.navigate(h);
}
let r = ui.add(egui::TextEdit::singleline(&mut b.location).desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(&mut b.location).desired_width(f32::INFINITY)).accessible_name(tl!("Folder path"));
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
location_done = true;
let typed = b.location.clone();
@@ -439,11 +439,16 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>
}
let before = b.filter;
let current = b.filters.get(b.filter).map(|(l, _)| tl!(l.as_str()).to_string()).unwrap_or_default();
egui::ComboBox::from_id_salt("builtin_file_browser_type").selected_text(current).width(150.0).show_ui(ui, |ui| {
for (i, (l, _)) in b.filters.iter().enumerate() {
ui.selectable_value(&mut b.filter, i, tl!(l.as_str()));
}
});
egui::ComboBox::from_id_salt("builtin_file_browser_type")
.selected_text(current)
.width(150.0)
.show_ui(ui, |ui| {
for (i, (l, _)) in b.filters.iter().enumerate() {
ui.selectable_value(&mut b.filter, i, tl!(l.as_str()));
}
})
.response
.accessible_name(tl!("File type"));
refilter = b.filter != before;
});
if refilter {
+9 -9
View File
@@ -17,11 +17,11 @@ use crate::WordApp;
use crate::theme::Tokens;
fn mark_id() -> Id {
Id::new("format_shape.drag_mark")
Id::unique("format_shape.drag_mark")
}
fn stop_id() -> Id {
Id::new("format_shape.stop")
Id::unique("format_shape.stop")
}
/// Forgets a drag's undo mark: called while the pane is closed, so a drag cut short by closing
@@ -36,7 +36,7 @@ pub fn forget_drag(ctx: &egui::Context) {
struct Variation(wordcraft_doc::Pos, Fill, Rgb);
fn variation_id() -> Id {
Id::new("format_shape.variation")
Id::unique("format_shape.variation")
}
/// The colour Shape Fill › Gradient builds from for the object at `pos` filled with `fill`:
@@ -128,7 +128,7 @@ pub fn pane(app: &mut WordApp, ui: &mut Ui) {
fn solid(app: &mut WordApp, ui: &mut Ui, theme: &[Rgb], color: Rgb, transparency: f32) {
let mut c = color;
if color_field(ui, theme, Id::new("format_shape.solid_color"), &mut c) {
if color_field(ui, theme, Id::unique("format_shape.solid_color"), &mut c) {
apply(app, ui, Some(&Fill::Solid { color: c, transparency }), None);
}
let mut t = transparency;
@@ -140,8 +140,8 @@ fn solid(app: &mut WordApp, ui: &mut Ui, theme: &[Rgb], color: Rgb, transparency
/// A labelled 0–100% slider with its value box; the response when it changed.
fn percent(ui: &mut Ui, label: &str, v: &mut f32) -> Option<Response> {
ui.horizontal(|ui| {
ui.label(label);
let s = ui.add(egui::Slider::new(v, 0.0..=100.0).suffix("%").fixed_decimals(0));
let label = ui.label(label);
let s = ui.add(egui::Slider::new(v, 0.0..=100.0).suffix("%").fixed_decimals(0)).labelled_by(label.id);
(s.changed()).then_some(s)
})
.inner
@@ -289,7 +289,7 @@ fn gradient(app: &mut WordApp, ui: &mut Ui, theme: &[Rgb], g: &Gradient) {
let Some(stop) = g.stops.get(sel).copied() else { return };
let set = |s: GradStop| with(g, |g| g.stops[sel] = s);
let mut c = stop.color;
if color_field(ui, theme, Id::new("format_shape.stop_color"), &mut c) {
if color_field(ui, theme, Id::unique("format_shape.stop_color"), &mut c) {
apply(app, ui, Some(&set(GradStop { color: c, ..stop })), None);
}
ui.horizontal(|ui| {
@@ -360,7 +360,7 @@ fn stops_bar(app: &mut WordApp, ui: &mut Ui, g: &Gradient, sel: usize) {
};
let Some(p) = resp.interact_pointer_pos() else { return };
// Whether the drag in progress began on a handle: only then does it move a stop.
let grab_id = Id::new("format_shape.grabbed");
let grab_id = Id::unique("format_shape.grabbed");
if resp.drag_started() || resp.clicked() {
resp.request_focus();
let hit = near(p);
@@ -436,7 +436,7 @@ pub fn swatch(ui: &mut Ui, fill: &Fill, tip: &str) -> bool {
let ring = if resp.hovered() { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) };
ui.painter().rect_stroke(r.shrink(2.0), 0.0, ring, egui::StrokeKind::Outside);
let name = tl!(tip);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name));
resp.on_hover_text(name).clicked()
}
+4
View File
@@ -167,8 +167,12 @@ impl Exempt {
let theme_fonts = plain.strip_prefix(": ").is_some_and(|f| f.split(" / ").count() == 2);
let icon_lettering = matches!(plain, "Aa" | "ab" | "abc" | "ac" | "cd" | "ab-" | "fx" | "ABC");
let initials = plain.chars().count() == 2 && plain.chars().all(|c| c.is_ascii_uppercase());
// Math tiles fall back to UnicodeMath syntax while their preview loads. These are
// language-independent commands, and only names in the real symbol table qualify.
let math_symbol = plain.strip_prefix('\\').is_some_and(|name| wordcraft_doc::math_symbols::symbol(name).is_some());
icon_lettering // drawn inside icons
|| initials // the account button
|| math_symbol
|| status
|| theme_fonts
|| is_shortcut()
+1 -1
View File
@@ -18,7 +18,7 @@ pub(crate) fn replace(ctx: &egui::Context, p: Pos2, bg: Rgb) -> bool {
}
ctx.set_cursor_icon(CursorIcon::None);
// Above the page and whatever floats over it, as the system pointer would be.
let painter = ctx.layer_painter(LayerId::new(Order::Tooltip, Id::new("wordcraft_ibeam")));
let painter = ctx.layer_painter(LayerId::new(Order::Tooltip, Id::unique("wordcraft_ibeam")));
paint(&painter, p, crate::canvas::caret_contrast(bg));
true
}
+1 -1
View File
@@ -388,7 +388,7 @@ pub fn replay_bar(app: &mut WordApp, ui: &Ui, area: Rect) {
let Some(r) = app.canvas.ink_ui.replay.as_ref() else { return };
let (playing, n, total) = (r.playing, r.started(), r.strokes.len());
let mut action = None;
egui::Area::new(egui::Id::new("ink_replay_bar"))
egui::Area::new(egui::Id::unique("ink_replay_bar"))
.order(egui::Order::Foreground)
.pivot(Align2::CENTER_BOTTOM)
.fixed_pos(pos2(area.center().x, area.max.y - 18.0))
+1481 -50
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -9,16 +9,16 @@ use crate::theme::{Tokens, semibold};
/// egui ids the ribbon publishes its keytip state under (accessors, since `Id::new` isn't const).
pub fn phase_id() -> egui::Id {
egui::Id::new("wc_keytips_phase")
egui::Id::unique("wc_keytips_phase")
}
pub fn tab_id() -> egui::Id {
egui::Id::new("wc_keytips_tab")
egui::Id::unique("wc_keytips_tab")
}
pub fn letters_id() -> egui::Id {
egui::Id::new("wc_keytips_letters")
egui::Id::unique("wc_keytips_letters")
}
pub fn rects_id() -> egui::Id {
egui::Id::new("wc_keytips_rects")
egui::Id::unique("wc_keytips_rects")
}
/// A bare Alt press (either Alt key) arms or cancels keytips.
@@ -863,7 +863,7 @@ fn legacy_hint(app: &WordApp, ui: &Ui, tabs: Option<Rect>) {
let font = egui::FontId::proportional(12.0);
let galley = ui.ctx().fonts_mut(|f| f.layout_no_wrap(text, font, t.text));
let r = Rect::from_min_size(tabs.left_bottom() + Vec2::new(4.0, 2.0), galley.size() + Vec2::new(16.0, 10.0));
let p = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("wc_legacy_keys")));
let p = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("wc_legacy_keys")));
p.rect_filled(r, CornerRadius::same(4), t.menu);
p.rect_stroke(r, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
p.galley(r.min + Vec2::new(8.0, 5.0), galley, t.text);
+734 -136
View File
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -99,7 +99,7 @@ pub fn anchor_rect(selection_top: f32, selection_left: f32, viewport: Rect) -> O
/// Where the bar and the controls in it were drawn last frame (`[bar, controls]`), kept in the
/// context's memory under this id so tests can check that no control spills out of the bar.
pub fn shown_id() -> egui::Id {
egui::Id::new("wc_mini_toolbar").with("shown")
egui::Id::unique("wc_mini_toolbar").with("shown")
}
/// Whether the bar may be shown for the current app state.
@@ -181,7 +181,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: R
// the topmost layer, so a click on the bar never reaches the document underneath. Clipped to
// the document area (`constrain_to` without `constrain`, which would push it inside), the part
// past its edge takes no clicks either, so the ribbon and the rulers under it keep them.
let area = egui::Area::new(egui::Id::new("wc_mini_toolbar")).order(Order::Middle).interactable(true);
let area = egui::Area::new(egui::Id::unique("wc_mini_toolbar")).order(Order::Middle).interactable(true);
let area = area.constrain_to(viewport).constrain(false).fixed_pos(bar.min);
area.show(ctx, |ui| {
ui.set_clip_rect(shown);
@@ -260,7 +260,7 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default();
let sizes: Vec<String> =
wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect();
if let Some(v) = widgets::combo(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None)
if let Some(v) = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size"))
&& let Ok(x) = v.trim().parse::<f64>()
{
let _ = app.run("format.size", json!({"size": x}));
@@ -524,7 +524,7 @@ mod tests {
/// The bar's layer, when it was drawn last frame.
fn bar_layer(h: &egui_kittest::Harness<'static, WordApp>) -> Option<egui::LayerId> {
h.ctx.memory(|m| m.areas().visible_layer_ids().into_iter().find(|l| l.id == egui::Id::new("wc_mini_toolbar")))
h.ctx.memory(|m| m.areas().visible_layer_ids().into_iter().find(|l| l.id == egui::Id::unique("wc_mini_toolbar")))
}
fn click_at(h: &mut egui_kittest::Harness<'static, WordApp>, at: egui::Pos2) {
@@ -776,7 +776,13 @@ mod tests {
h.event(egui::Event::PointerMoved(page.center()));
h.step();
let delta = egui::vec2(0.0, target - line.min.y);
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase: egui::TouchPhase::Start, modifiers: Default::default() });
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta,
phase: egui::TouchPhase::Start,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
});
h.step();
h.state_mut().canvas.wheel = Default::default();
steps(&mut h);
@@ -864,7 +870,7 @@ mod tests {
a.run("select.all", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(400.0, 400.0)).build_ui_state(
|ui, app: &mut WordApp| {
egui::Area::new(egui::Id::new("font_colour_probe")).fixed_pos(pos2(50.0, 50.0)).show(ui.ctx(), |ui| {
egui::Area::new(egui::Id::unique("font_colour_probe")).fixed_pos(pos2(50.0, 50.0)).show(ui.ctx(), |ui| {
font_color_menu(ui, app);
});
},
@@ -883,10 +889,10 @@ mod tests {
fn picking_a_highlight_swatch_returns_focus_to_the_document() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), Default::default());
a.run("select.all", json!({})).unwrap();
let origin = egui::Id::new("hl_swatch_origin");
let origin = egui::Id::unique("hl_swatch_origin");
let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(400.0, 300.0)).build_ui_state(
move |ui, app: &mut WordApp| {
egui::Area::new(egui::Id::new("hl_probe")).fixed_pos(pos2(100.0, 100.0)).show(ui.ctx(), |ui| {
egui::Area::new(egui::Id::unique("hl_probe")).fixed_pos(pos2(100.0, 100.0)).show(ui.ctx(), |ui| {
ui.ctx().data_mut(|d| d.insert_temp(origin, ui.next_widget_position()));
highlight_menu(ui, app);
});
+45 -32
View File
@@ -147,7 +147,7 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) {
let label = tl!(pane_label(id));
let active = current == id;
let (r, resp) = ui.allocate_exact_size(vec2(170.0, 30.0), egui::Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label));
if active {
ui.painter().rect_filled(r, 4.0, t.checked);
} else if resp.hovered() {
@@ -236,20 +236,21 @@ fn general(app: &mut WordApp, ui: &mut Ui) {
ui.add_space(8.0);
section(ui, "Personalize");
egui::Grid::new("options_user").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
ui.label(tl!("User name:"));
let user_label = ui.label(tl!("User name:"));
let mut n = app.session.author.clone();
let protected = wordcraft_engine::cmd::protect::enforced(&app.session.doc);
if ui.add_enabled(!protected, egui::TextEdit::singleline(&mut n).desired_width(260.0)).changed() && !protected {
if ui.add_enabled(!protected, egui::TextEdit::singleline(&mut n).desired_width(260.0)).labelled_by(user_label.id).changed() && !protected {
app.session.author = n;
}
ui.end_row();
ui.label(tl!("Initials:"));
let initials_label = ui.label(tl!("Initials:"));
let mut i = app.session.initials.clone();
let hint = app.session.user_initials();
if ui
.add(
egui::TextEdit::singleline(&mut i).desired_width(80.0).char_limit(wordcraft_engine::cmd::options::MAX_INITIALS_CHARS).hint_text(hint),
)
.labelled_by(initials_label.id)
.changed()
{
set(app, json!({"initials": i}));
@@ -317,11 +318,13 @@ fn proofing(app: &mut WordApp, ui: &mut Ui) {
/// dictionary kept in a folder of the user's own.
fn dictionaries(app: &mut WordApp, ui: &mut Ui) {
let saved = app.session.prefs.dictionaries_folder.clone().unwrap_or_default();
let id = egui::Id::new("options_dictionaries_folder");
let id = egui::Id::unique("options_dictionaries_folder");
let mut folder = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| saved.clone());
ui.label(tl!("Dictionaries folder:"));
let folder_label = ui.label(tl!("Dictionaries folder:")).id;
ui.horizontal(|ui| {
let r = ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(tl!("Not set: the system's usual places")));
let r = ui
.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(tl!("Not set: the system's usual places")))
.labelled_by(folder_label);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let changed = folder.trim() != saved;
if ui.add_enabled(changed, egui::Button::new(tl!("Apply"))).clicked() || (enter && changed) {
@@ -341,7 +344,7 @@ fn dictionaries(app: &mut WordApp, ui: &mut Ui) {
ui.data_mut(|d| d.insert_temp(id, folder));
note(ui, "Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's.");
// The dictionary each language found, looked up again after the folder changes.
let found_id = egui::Id::new("options_dictionaries_found");
let found_id = egui::Id::unique("options_dictionaries_found");
let generation = wordcraft_engine::proof::dictionary_generation();
let cached = ui.data(|d| d.get_temp::<(u64, Vec<(String, Option<String>, Option<String>)>)>(found_id)).filter(|(g, _)| *g == generation);
let found = match cached {
@@ -407,10 +410,12 @@ fn custom_dictionary(app: &mut WordApp, ui: &mut Ui) {
});
}
});
let id = egui::Id::new("options_dictionary_new");
let id = egui::Id::unique("options_dictionary_new");
let mut word = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_default();
ui.horizontal(|ui| {
let r = ui.add(egui::TextEdit::singleline(&mut word).desired_width(220.0).char_limit(MAX_WORD_CHARS).hint_text(tl!("New word")));
let r = ui
.add(egui::TextEdit::singleline(&mut word).desired_width(220.0).char_limit(MAX_WORD_CHARS).hint_text(tl!("New word")))
.accessible_name(tl!("New word"));
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let w = word.trim().to_string();
let ok = !w.is_empty() && !w.chars().any(char::is_whitespace);
@@ -481,8 +486,9 @@ fn accessibility(app: &mut WordApp, ui: &mut Ui) {
section(ui, "Read Aloud");
let mut rate = app.session.read_aloud.rate();
ui.horizontal(|ui| {
ui.label(tl!("Speed"));
let r = ui.add(egui::Slider::new(&mut rate, MIN_RATE..=MAX_RATE).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×")));
let label = ui.label(tl!("Speed"));
let r =
ui.add(egui::Slider::new(&mut rate, MIN_RATE..=MAX_RATE).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×"))).labelled_by(label.id);
if r.changed() {
let _ = app.run("readAloud.speed", json!({"value": rate}));
}
@@ -510,19 +516,19 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
ui.add_space(8.0);
section(ui, "Display");
egui::Grid::new("options_display").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Recent documents to show:"));
let recent_label = ui.label(tl!("Recent documents to show:"));
let mut n = app.ui.recent_shown();
if ui.add(egui::DragValue::new(&mut n).range(0..=crate::MAX_RECENT).speed(0.2)).changed() {
if ui.add(egui::DragValue::new(&mut n).range(0..=crate::MAX_RECENT).speed(0.2)).labelled_by(recent_label.id).changed() {
app.ui.recent_count = n;
}
ui.end_row();
ui.label(tl!("Measurement units:"));
let units_label = ui.label(tl!("Measurement units:"));
// The user's choice, else the region's (#838): Automatic follows the region.
let units = app.session.units();
let auto = app.session.prefs.units.is_none();
let name = UNITS.iter().find(|(u, _)| *u == units).map_or("Inches", |(_, n)| n);
let shown = if auto { format!("{} ({})", tl!("Automatic"), tl!(name)) } else { tl!(name).to_string() };
egui::ComboBox::from_id_salt("options_units").selected_text(shown).width(200.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("options_units").selected_text(shown).width(200.0).show_ui(ui, |ui| {
if ui.selectable_label(auto, tl!("Automatic")).clicked() {
set(app, json!({"units": "auto"}));
}
@@ -532,16 +538,17 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
}
}
});
combo.response.labelled_by(units_label.id);
ui.end_row();
// The region new documents, units and the decimal separator follow; Automatic is the
// system's (#838).
ui.label(tl!("Region:"));
let region_label = ui.label(tl!("Region:"));
let chosen = app.session.prefs.region.clone();
let system = &app.session.system_region;
let system_tag = if system.territory.is_empty() { system.language.clone() } else { format!("{}-{}", system.language, system.territory) };
let automatic = crate::i18n::fmt(tl!("Automatic ({region})"), &[("region", system_tag.as_str())]);
let shown = chosen.clone().unwrap_or_else(|| automatic.clone());
egui::ComboBox::from_id_salt("options_region").selected_text(shown).width(200.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("options_region").selected_text(shown).width(200.0).show_ui(ui, |ui| {
if ui.selectable_label(chosen.is_none(), automatic.as_str()).clicked() {
set(app, json!({"region": "auto"}));
}
@@ -551,6 +558,7 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
}
}
});
combo.response.labelled_by(region_label.id);
ui.end_row();
});
let mut keytips = app.ui.keytips_enabled;
@@ -595,13 +603,13 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
/// Template starts. Empty is the app's own folder, shown as the hint.
fn templates_folder(app: &mut WordApp, ui: &mut Ui) {
let saved = app.session.prefs.user_templates_folder.clone().unwrap_or_default();
let id = egui::Id::new("options_user_templates_folder");
let id = egui::Id::unique("options_user_templates_folder");
let mut folder = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| saved.clone());
let default = app.services.templates_dir.as_ref().map(|d| d.to_string_lossy().into_owned());
ui.label(tl!("User templates:"));
let templates_label = ui.label(tl!("User templates:")).id;
ui.horizontal(|ui| {
let hint = default.clone().unwrap_or_else(|| tl!("Not set").to_string());
let r = ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(hint));
let r = ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(hint)).labelled_by(templates_label);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let changed = folder.trim() != saved;
if ui.add_enabled(changed, egui::Button::new(tl!("Apply"))).clicked() || (enter && changed) {
@@ -630,14 +638,15 @@ fn agents(ui: &mut Ui) {
fn theme_picker(app: &mut WordApp, ui: &mut Ui) {
use crate::theme::Appearance;
ui.horizontal(|ui| {
ui.label(tl!("Interface theme:"));
egui::ComboBox::from_id_salt("interface_theme").selected_text(tl!(app.ui.theme.label())).width(220.0).show_ui(ui, |ui| {
let label = ui.label(tl!("Interface theme:"));
let combo = egui::ComboBox::from_id_salt("interface_theme").selected_text(tl!(app.ui.theme.label())).width(220.0).show_ui(ui, |ui| {
for a in Appearance::ALL {
if ui.selectable_label(app.ui.theme == a, tl!(a.label())).clicked() {
let _ = app.run("ui.theme", json!({"value": a.code()}));
}
}
});
combo.response.labelled_by(label.id);
});
}
@@ -650,8 +659,8 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) {
let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &InterfaceSize::AUTO.drawn_percent(native).to_string())]);
let selected = size.percent().map_or_else(|| auto.clone(), |pct| format!("{pct}%"));
ui.horizontal(|ui| {
ui.label(tl!("Interface size:"));
egui::ComboBox::from_id_salt("interface_size").selected_text(selected).width(220.0).show_ui(ui, |ui| {
let label = ui.label(tl!("Interface size:"));
let combo = egui::ComboBox::from_id_salt("interface_size").selected_text(selected).width(220.0).show_ui(ui, |ui| {
if ui.selectable_label(size.is_auto(), auto).clicked() {
let _ = app.run("ui.interfaceSize", json!({"value": "auto"}));
}
@@ -661,6 +670,7 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) {
}
}
});
combo.response.labelled_by(label.id);
});
ui.label(
RichText::new(tl!(
@@ -676,14 +686,15 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) {
fn animations_picker(app: &mut WordApp, ui: &mut Ui) {
use crate::theme::Animations;
ui.horizontal(|ui| {
ui.label(tl!("Animation effects:"));
egui::ComboBox::from_id_salt("animation_effects").selected_text(tl!(app.ui.animations.label())).width(220.0).show_ui(ui, |ui| {
let label = ui.label(tl!("Animation effects:"));
let combo = egui::ComboBox::from_id_salt("animation_effects").selected_text(tl!(app.ui.animations.label())).width(220.0).show_ui(ui, |ui| {
for a in Animations::ALL {
if ui.selectable_label(app.ui.animations == a, tl!(a.label())).clicked() {
let _ = app.run("ui.animations", json!({"value": a.code()}));
}
}
});
combo.response.labelled_by(label.id);
});
note(ui, "Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once.");
if app.ui.animations == Animations::System {
@@ -705,8 +716,8 @@ fn language_picker(app: &mut WordApp, ui: &mut Ui) {
let auto_label = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", system.name())]);
let current = if app.ui.language == AUTO { auto_label.clone() } else { Lang::from_pref(&app.ui.language).name().to_string() };
ui.horizontal(|ui| {
ui.label(tl!("Interface language:"));
egui::ComboBox::from_id_salt("interface_language").selected_text(current).width(220.0).show_ui(ui, |ui| {
let label = ui.label(tl!("Interface language:"));
let combo = egui::ComboBox::from_id_salt("interface_language").selected_text(current).width(220.0).show_ui(ui, |ui| {
if ui.selectable_label(app.ui.language == AUTO, auto_label.as_str()).clicked() {
let _ = app.run("ui.language", json!({"value": AUTO}));
}
@@ -716,6 +727,7 @@ fn language_picker(app: &mut WordApp, ui: &mut Ui) {
}
}
});
combo.response.labelled_by(label.id);
});
ui.label(RichText::new(tl!("Automatic follows your system's language. Menus and commands change; your documents don't.")).small().weak());
}
@@ -814,13 +826,14 @@ fn default_app_button(app: &mut WordApp, ui: &mut Ui) {
fn numeral_picker(app: &mut WordApp, ui: &mut Ui) {
use wordcraft_layout::NumeralMode;
ui.horizontal(|ui| {
ui.label(tl!("Numerals:"));
let label = ui.label(tl!("Numerals:"));
let mut sel = app.session.view.numeral;
egui::ComboBox::from_id_salt("numerals").selected_text(tl!(sel.label())).width(220.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("numerals").selected_text(tl!(sel.label())).width(220.0).show_ui(ui, |ui| {
for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context, NumeralMode::System] {
ui.selectable_value(&mut sel, m, tl!(m.label()));
}
});
combo.response.labelled_by(label.id);
if sel != app.session.view.numeral {
let _ = app.run("view.numeral", json!({ "value": sel }));
}
@@ -1146,7 +1159,7 @@ mod tests {
assert!(h.query_by_label("Animation effects:").is_some());
assert!(h.query_by_label("Your system has animation effects turned off.").is_some());
assert!(!h.state().animations_on());
h.get_by_role(egui::accesskit::Role::ComboBox).click();
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Animation effects:").click();
h.run_steps(4);
h.get_by_label("On").click();
h.run_steps(4);
+5 -5
View File
@@ -122,7 +122,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
let _ = app.run("view.navigationPane", json!({"value": false}));
return;
}
let qid = egui::Id::new("nav_query");
let qid = egui::Id::unique("nav_query");
let mut q = ui.data(|d| d.get_temp::<String>(qid)).unwrap_or_default();
// Ctrl+F with text selected searches for it, as Word does (a selection within one line).
let focus = std::mem::take(&mut app.nav_search_focus);
@@ -460,7 +460,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
});
// Editable comment text (one undo step per edit, like the balloons).
comment_editor(app, ui, id as u32, None, false);
let open_key = egui::Id::new(("comment_reply_open", id));
let open_key = egui::Id::unique(("comment_reply_open", id));
let mut replying = ui.data(|d| d.get_temp::<bool>(open_key)).unwrap_or(false);
ui.horizontal(|ui| {
if ui.small_button(tl!("Go to")).clicked()
@@ -471,7 +471,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
if ui.small_button(tl!("Reply")).clicked() {
replying = true;
ui.data_mut(|d| d.insert_temp(open_key, true));
ui.data_mut(|d| d.insert_temp(egui::Id::new(("comment_reply_focus", id)), true));
ui.data_mut(|d| d.insert_temp(egui::Id::unique(("comment_reply_focus", id)), true));
}
let resolved = c.get("resolved").and_then(Value::as_bool).unwrap_or(false);
if ui.small_button(if resolved { tl!("Reopen") } else { tl!("Resolve") }).clicked() {
@@ -482,7 +482,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
}
});
if replying {
let focus = ui.data_mut(|d| d.remove_temp::<bool>(egui::Id::new(("comment_reply_focus", id)))).unwrap_or(false);
let focus = ui.data_mut(|d| d.remove_temp::<bool>(egui::Id::unique(("comment_reply_focus", id)))).unwrap_or(false);
if reply_editor(app, ui, id as u32, None, focus) {
ui.data_mut(|d| d.remove::<bool>(open_key));
}
@@ -574,7 +574,7 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti
/// A comment field's widget id, per document: egui's own state for the field (its cursor and
/// undo history) never carries over to another document's comment with the same id.
fn field_id(app: &WordApp, ui: &Ui, kind: &str, id: u32) -> egui::Id {
ui.id().with((kind, app.session.document_id(), id))
ui.scope_id().with((kind, app.session.document_id(), id))
}
/// Write a comment's draft text back, when it has one that differs from the comment. The draft
+13 -13
View File
@@ -217,7 +217,7 @@ impl Previews {
self.east_asian = None;
ctx.data_mut(|d| {
for f in families {
d.remove::<TextureHandle>(egui::Id::new(format!("font:{f}")));
d.remove::<TextureHandle>(egui::Id::unique(format!("font:{f}")));
}
});
}
@@ -226,9 +226,9 @@ impl Previews {
pub fn font_preview_fn(&mut self) -> Box<dyn Fn(&mut Ui, &str) -> Response> {
Box::new(|ui: &mut Ui, name: &str| {
let key = format!("font:{name}");
let tex: Option<TextureHandle> = ui.ctx().data(|d| d.get_temp::<TextureHandle>(egui::Id::new(&key)));
let tex: Option<TextureHandle> = ui.ctx().data(|d| d.get_temp::<TextureHandle>(egui::Id::unique(&key)));
let (r, resp) = ui.allocate_exact_size(vec2(260.0, crate::widgets::COMBO_PREVIEW_ROW_H), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), name));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name));
let t = Tokens::get(ui.ctx());
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
@@ -237,7 +237,7 @@ impl Previews {
// Context accessors share a lock: read input before taking the data write lock.
let now = ui.input(|i| i.time);
let n = ui.ctx().data_mut(|d| {
let c = d.get_temp_mut_or_default::<(f64, u32)>(egui::Id::new("font_preview_budget"));
let c = d.get_temp_mut_or_default::<(f64, u32)>(egui::Id::unique("font_preview_budget"));
if c.0 != now {
*c = (now, 0);
}
@@ -252,7 +252,7 @@ impl Previews {
let mut img = font_entry_image(name, ppp);
ink_mask(&mut img);
let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone()));
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(&key), h.clone()));
Some(h)
});
match tex {
@@ -517,7 +517,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
let rev = app.session.rev();
let skey = app.previews.styles_key(&app.session.doc, rev);
let ppp = ui.ctx().pixels_per_point();
let start = ui.data(|d| d.get_temp::<usize>(egui::Id::new("gallery_start"))).unwrap_or(0).min(styles.len().saturating_sub(1));
let start = ui.data(|d| d.get_temp::<usize>(egui::Id::unique("gallery_start"))).unwrap_or(0).min(styles.len().saturating_sub(1));
// A compact ribbon group (#662, #1285) trims the gallery to fewer tiles before it folds.
let visible = if crate::ribbon_fit::compact(ui.ctx()) { STYLE_TILES_COMPACT } else { STYLE_TILES };
crate::ribbon_fit::add_saving(ui.ctx(), (STYLE_TILES - STYLE_TILES_COMPACT) as f32 * 76.0);
@@ -525,9 +525,9 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
ui.painter().rect(area.shrink(1.0), 4.0, t.input, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
for (k, (id, name)) in styles.iter().skip(start).take(visible).enumerate() {
let r = Rect::from_min_size(pos2(area.min.x + 3.0 + k as f32 * 76.0, area.min.y + 3.0), vec2(74.0, area.height() - 6.0));
let resp = ui.interact(r, ui.id().with(("style_tile", id)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("style_tile", id)), Sense::click());
let active = *id == current;
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, name));
if active {
ui.painter().rect(r, 3.0, t.checked, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
} else if resp.hovered() {
@@ -580,7 +580,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
let thirds = ar.height() / 3.0;
for (i, (icon, delta)) in [("chevronUp", -1i32), ("chevronDown", 1), ("more", 0)].iter().enumerate() {
let br = Rect::from_min_size(pos2(ar.min.x, ar.min.y + thirds * i as f32), vec2(ar.width(), thirds));
let resp = ui.interact(br, ui.id().with(("gal_arrow", i)), Sense::click());
let resp = ui.interact(br, ui.scope_id().with(("gal_arrow", i)), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(br, 2.0, t.hover);
}
@@ -590,7 +590,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
let _ = app.run("view.stylesPane", json!({"value": true}));
} else {
let n = if *delta < 0 { start.saturating_sub(visible) } else { (start + visible).min(styles.len().saturating_sub(1)) };
ui.data_mut(|d| d.insert_temp(egui::Id::new("gallery_start"), n));
ui.data_mut(|d| d.insert_temp(egui::Id::unique("gallery_start"), n));
}
}
}
@@ -791,9 +791,9 @@ mod tests {
let worker = std::thread::spawn(move || {
let ctx = egui::Context::default();
let preview = Previews::default().font_preview_fn();
let budget_id = egui::Id::new("font_preview_budget");
let sans_id = egui::Id::new("font:Source Sans 3");
let inter_id = egui::Id::new("font:Inter");
let budget_id = egui::Id::unique("font_preview_budget");
let sans_id = egui::Id::unique("font:Source Sans 3");
let inter_id = egui::Id::unique("font:Inter");
ctx.data_mut(|d| d.insert_temp(budget_id, (1.0_f64, 11_u32)));
for time in [1.0, 2.0, 3.0] {
+40 -20
View File
@@ -82,23 +82,40 @@ const NO_SCALING: &str = "none";
const ORIENTATIONS: [(&str, &str); 3] = [("auto", "Auto orientation"), ("portrait", "Portrait"), ("landscape", "Landscape")];
/// A choice list over `(id, label)` pairs; true when the choice changed.
fn choice(ui: &mut Ui, salt: &str, current: &mut String, items: &[(&str, &str)]) -> bool {
fn choice(ui: &mut Ui, salt: &str, current: &mut String, items: &[(&str, &str)], label: &egui::Response) -> bool {
let shown = items.iter().find(|(id, _)| *id == current.as_str()).map_or("", |(_, l)| *l);
let mut changed = false;
egui::ComboBox::from_id_salt(salt).width(260.0).selected_text(tl!(shown)).show_ui(ui, |ui| {
for (id, label) in items {
if ui.selectable_label(current == id, tl!(label)).clicked() && current != id {
let combo = egui::ComboBox::from_id_salt(salt).width(260.0).selected_text(tl!(shown)).show_ui(ui, |ui| {
for (id, item) in items {
if ui.selectable_label(current == id, tl!(item)).clicked() && current != id {
*current = (*id).to_string();
changed = true;
}
}
});
combo.response.labelled_by(label.id);
changed
}
/// A choice list named without a visible label beside it (its heading names a group of choices).
fn choice_named(ui: &mut Ui, salt: &str, current: &mut String, items: &[(&str, &str)], name: &str) -> bool {
let shown = items.iter().find(|(id, _)| *id == current.as_str()).map_or("", |(_, l)| *l);
let mut changed = false;
let combo = egui::ComboBox::from_id_salt(salt).width(260.0).selected_text(tl!(shown)).show_ui(ui, |ui| {
for (id, item) in items {
if ui.selectable_label(current == id, tl!(item)).clicked() && current != id {
*current = (*id).to_string();
changed = true;
}
}
});
combo.response.accessible_name(name);
changed
}
/// The output layout, kept while the document is unchanged (laying out every frame would be slow).
fn layout_of(app: &WordApp, ctx: &egui::Context) -> Arc<DocLayout> {
let id = egui::Id::new("print_page_layout");
let id = egui::Id::unique("print_page_layout");
let rev = app.session.rev();
if let Some((r, l)) = ctx.data(|d| d.get_temp::<(u64, Arc<DocLayout>)>(id))
&& r == rev
@@ -112,7 +129,7 @@ fn layout_of(app: &WordApp, ctx: &egui::Context) -> Arc<DocLayout> {
/// The texture of one page, re-rendered only when the document, page or size changes.
fn page_texture(app: &WordApp, ctx: &egui::Context, lay: &DocLayout, page: usize, width: f32) -> Option<TextureHandle> {
let id = egui::Id::new("print_page_texture");
let id = egui::Id::unique("print_page_texture");
let key = (app.session.rev(), page, width as u32, (ctx.pixels_per_point() * 100.0) as u32);
if let Some((k, t)) = ctx.data(|d| d.get_temp::<((u64, usize, u32, u32), TextureHandle)>(id))
&& k == key
@@ -184,10 +201,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
ui.add_enabled(!app.printing.busy(), egui::Checkbox::new(&mut app.ui.print.collate, tl!("Collate copies")));
}
} else {
ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0)));
let copies_label = ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0)));
ui.horizontal(|ui| {
let mut n = app.ui.print.copies;
ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2));
ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2)).labelled_by(copies_label.id);
app.ui.print.copies = n.clamp(1, wordcraft_engine::print::MAX_COPIES);
if app.ui.print.copies > 1 {
ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies"));
@@ -195,8 +212,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
});
}
ui.label(egui::RichText::new(tl!("Pages")).font(medium(13.0)));
if choice(ui, "print_range", &mut app.ui.print.range, &RANGES) {
let pages_label = ui.label(egui::RichText::new(tl!("Pages")).font(medium(13.0)));
if choice(ui, "print_range", &mut app.ui.print.range, &RANGES, &pages_label) {
// Show the first page that will print.
app.ui.print.preview = if app.ui.print.range == "all" { 0 } else { current.0.min(total.saturating_sub(1)) };
}
@@ -206,16 +223,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
.hint_text(tl!("Pages like 1-3, 5"))
.desired_width(260.0)
.char_limit(wordcraft_engine::print::MAX_SPEC_CHARS),
);
)
.labelled_by(pages_label.id);
}
choice(ui, "print_parity", &mut app.ui.print.parity, &PARITIES);
ui.label(egui::RichText::new(tl!("Orientation")).font(medium(13.0)));
choice(ui, "print_orientation", &mut app.ui.print.orientation, &ORIENTATIONS);
choice_named(ui, "print_parity", &mut app.ui.print.parity, &PARITIES, tl!("Pages in the range"));
let orientation_label = ui.label(egui::RichText::new(tl!("Orientation")).font(medium(13.0)));
choice(ui, "print_orientation", &mut app.ui.print.orientation, &ORIENTATIONS, &orientation_label);
// Word: Pages per sheet › Scale to paper size. Only the printout changes.
ui.label(egui::RichText::new(tl!("Scale to paper size")).font(medium(13.0)));
let paper_label = ui.label(egui::RichText::new(tl!("Scale to paper size")).font(medium(13.0)));
let papers: Vec<(&str, &str)> =
std::iter::once((NO_SCALING, "No Scaling")).chain(wordcraft_geom::PAPER_SIZES.iter().map(|(n, _, _)| (*n, *n))).collect();
choice(ui, "print_paper", &mut app.ui.print.paper, &papers);
choice(ui, "print_paper", &mut app.ui.print.paper, &papers, &paper_label);
match wordcraft_engine::print::plan(&app.ui.print.params(), total, current) {
Ok(plan) => {
@@ -274,8 +292,8 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
let room = vec2((size.x + 2.0 * PREVIEW_MARGIN).max(ui.available_width()), (size.y + 2.0 * PREVIEW_MARGIN).max(ui.available_height()));
let (area, _) = ui.allocate_exact_size(room, Sense::hover());
let r = Rect::from_center_size(area.center(), size);
let resp = ui.interact(r, ui.id().with("print_preview_page"), Sense::hover());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, &label));
let resp = ui.interact(r, ui.scope_id().with("print_preview_page"), Sense::hover());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, &label));
ui.painter().rect_filled(r.translate(vec2(2.0, 3.0)), 0.0, t.page_shadow);
ui.painter().rect(r, 0.0, egui::Color32::WHITE, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
let on_sheet = sheet.map_or(r, |f| Rect::from_min_size(r.min + vec2(f.dx, f.dy) * scale, vec2(page_w, page_h) * f.k * scale));
@@ -287,7 +305,7 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
ui.set_height(PREVIEW_BAR);
let nav = |ui: &mut Ui, icon: &str, tip: &str, enabled: bool| {
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip));
let col = if enabled { t.icon } else { t.text_disabled };
if enabled && resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
@@ -316,7 +334,9 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
if room >= 40.0 {
let mut v = z;
ui.spacing_mut().slider_width = room.min(110.0);
if ui.add(egui::Slider::new(&mut v, 0.1..=5.0).logarithmic(true).show_value(false)).on_hover_text(tl!("Zoom")).changed() {
let response = ui.add(egui::Slider::new(&mut v, 0.1..=5.0).logarithmic(true).show_value(false)).on_hover_text(tl!("Zoom"));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), tl!("Zoom")));
if response.changed() {
app.ui.print.zoom = Some(v.clamp(0.1, 5.0));
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate
short_edge: &translate("Two-sided: short edge"),
grayscale: &translate("Grayscale"),
};
state.show(ui, egui::Id::new("wordcraft_print_settings"), discover, &labels);
state.show(ui, egui::Id::unique("wordcraft_print_settings"), discover, &labels);
if let Some(message) = &state.message {
ui.label(feedback(message, translate));
}
+4 -3
View File
@@ -60,7 +60,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
let mut run: Option<(&str, serde_json::Value)> = None;
let right_top = app.canvas.canvas_rect.map_or(ctx.content_rect().right_top() + vec2(-24.0, 160.0), |r| r.right_top() + vec2(-24.0, 16.0));
let window = egui::Window::new(tl!("Read Aloud"))
.id(egui::Id::new("read-aloud-player"))
.id(egui::Id::unique("read-aloud-player"))
.collapsible(false)
.resizable(false)
.pivot(egui::Align2::RIGHT_TOP)
@@ -94,10 +94,10 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
ui.add_space(6.0);
// Speed: applied when the slider is let go, so dragging doesn't restart the sentence
// on every frame.
let id = egui::Id::new("read-aloud-rate");
let id = egui::Id::unique("read-aloud-rate");
let mut rate = ui.data(|d| d.get_temp::<f32>(id)).unwrap_or_else(|| app.session.read_aloud.rate());
ui.horizontal(|ui| {
ui.label(tl!("Speed"));
let label = ui.label(tl!("Speed"));
ui.spacing_mut().slider_width = 110.0;
let r = ui.add(
egui::Slider::new(&mut rate, MIN_RATE..=MAX_RATE)
@@ -106,6 +106,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
.custom_formatter(|v, _| format!("{v:.2}×"))
.custom_parser(|s| s.trim().trim_end_matches(['×', 'x']).trim().parse().ok()),
);
let r = r.labelled_by(label.id);
if r.dragged() {
ui.data_mut(|d| d.insert_temp(id, rate));
} else {
+35 -20
View File
@@ -352,7 +352,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
if share_text {
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent);
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, share));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, share));
if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() {
let _ = app.run("ui.backstage", json!({"value": true, "page": "export"}));
}
@@ -401,7 +401,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
crate::keytips::record_tab(r, tab, "", &mut app.keytip_rects);
}
let active = app.ui.tab == tab && !app.ui.backstage;
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, shown));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown));
if resp.hovered() && !active {
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
}
@@ -464,7 +464,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
);
// "»" means nothing read aloud: the button says what it holds.
let more = tl!("More tabs");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, more));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, more));
let resp = resp.on_hover_text(more);
egui::Popup::menu(&resp).show(|ui| {
for (tab, shown) in left_out {
@@ -494,11 +494,19 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
if !app.has_document() && app.ui.tab != "Help" {
ui.disable();
}
let bg = ui.interact(ui.max_rect(), ui.id().with("ribbon_background"), Sense::click());
let bg = ui.interact(ui.max_rect(), ui.scope_id().with("ribbon_background"), Sense::click());
bg.context_menu(|ui| crate::widgets::customize_menu_items(ui, app));
let entry = user_tabs.iter().find(|e| e.name == app.ui.tab);
crate::ribbon_fit::begin(ui.ctx(), &app.ui.tab, ui.available_width() - 4.0);
let scroll = egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| {
// 0.37 keeps the scroll offset private: an edge-chevron click leaves a one-frame
// request here and the next scroll area is built at that offset.
let pending_id = ui.scope_id().with("ribbon_scroll_pending");
let pending = ui.data(|d| d.get_temp::<f32>(pending_id));
let mut area = egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden);
if let Some(x) = pending {
area = area.horizontal_scroll_offset(x);
}
let scroll = area.show(ui, |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
match entry {
@@ -535,12 +543,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
});
});
if pending.is_some() {
let _ = ui.data_mut(|d| d.remove_temp::<f32>(pending_id));
}
crate::ribbon_fit::end(ui.ctx());
// The bar is hidden, so a row wider than the window would be clipped with no sign. Edge chevrons show
// where more is and scroll it on click. Every command also stays reachable through Search Commands.
let r = scroll.inner_rect;
let overflow = scroll.content_size.x - r.width();
let offset = scroll.state.offset.x;
let offset = scroll.state.clamped_offset().x;
let step = 160.0;
let mut next = None;
if offset > 1.0 {
@@ -556,9 +567,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
if let Some(x) = next {
let mut state = scroll.state;
state.offset.x = x.clamp(0.0, overflow.max(0.0));
state.store(ui.ctx(), scroll.id);
ui.data_mut(|d| d.insert_temp(pending_id, x.clamp(0.0, overflow.max(0.0))));
ui.ctx().request_repaint();
}
});
}
@@ -603,7 +613,7 @@ fn two_lines(label: &str) -> String {
/// A chevron over a ribbon edge that is also a button; `true` on the frame it is clicked.
fn edge_cue(ui: &mut Ui, rect: Rect, glyph: &str, hint: &str, t: &Tokens) -> bool {
let resp = ui.interact(rect, ui.id().with(glyph), Sense::click()).on_hover_text(hint);
let resp = ui.interact(rect, ui.scope_id().with(glyph), Sense::click()).on_hover_text(hint);
let ink = if resp.hovered() { t.text } else { t.text_dim };
ui.painter().rect_filled(rect, 0.0, t.ribbon);
ui.painter().text(rect.center(), Align2::CENTER_CENTER, glyph, egui::FontId::proportional(18.0), ink);
@@ -693,7 +703,7 @@ fn underline_item(ui: &mut Ui, app: &mut WordApp, style: &str, name: &str) {
let enabled = crate::widgets::enabled(app, "format.underline");
let resp = ui.add_enabled(enabled, egui::Button::new("").min_size(vec2(200.0, 22.0)));
let label = tl!(name);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label));
let t = Tokens::get(ui.ctx());
let color = if enabled { t.text } else { t.text_disabled };
paint_underline(ui.painter(), resp.rect.shrink2(vec2(10.0, 0.0)), style, color);
@@ -999,9 +1009,14 @@ fn language_checks(current: &str, listed: impl Fn(&str) -> bool) -> LanguageChec
/// A menu item with a check mark when `checked` (the current choice of several).
fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str, params: Value) {
// An invisible mark keeps the unchecked labels aligned with the checked one.
let mark = egui::RichText::new("✓");
let mark = if checked { mark } else { mark.color(egui::Color32::TRANSPARENT) };
// A vector mark needs no font glyph; the empty atom keeps labels aligned.
let mark = egui::Atom::paint(vec2(14.0, 14.0), move |ui, args| {
if checked {
let r = args.rect;
let points = vec![r.min + r.size() * vec2(0.15, 0.5), r.min + r.size() * vec2(0.4, 0.75), r.min + r.size() * vec2(0.85, 0.2)];
ui.painter().add(egui::Shape::line(points, egui::Stroke::new(1.5, args.fallback_text_color)));
}
});
let enabled = crate::widgets::enabled(app, id);
let resp = ui.add_enabled(enabled, egui::Button::new((mark, tl!(label))).selected(checked).min_size(vec2(200.0, 0.0)));
if resp.clicked() {
@@ -2093,9 +2108,9 @@ fn record_box(ui: &mut Ui, app: &mut WordApp) {
let on = !app.session.merge.rows.is_empty();
let shown = if on { (app.session.merge.record + 1).to_string() } else { String::new() };
// The draft lives in temp memory while typing (frame-local text would lose keystrokes).
let key = egui::Id::new("merge_record_box");
let key = egui::Id::unique("merge_record_box");
let mut text = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| shown.clone());
let r = ui.add_enabled(on, egui::TextEdit::singleline(&mut text).desired_width(36.0).horizontal_align(egui::Align::Center));
let r = ui.add_enabled(on, egui::TextEdit::singleline(&mut text).desired_width(36.0).align(egui::Align2::CENTER_TOP));
let r = r.on_hover_text(tl!("Go to Record"));
if r.changed() {
ui.data_mut(|d| d.insert_temp(key, text.clone()));
@@ -2316,7 +2331,7 @@ fn view(app: &mut WordApp, ui: &mut Ui) {
// browser has no full screen to ask for, and says so (#662).
let on = crate::widgets::enabled(app, "view.fullScreen");
let resp = big_toggle(ui, app, "fullScreen", "Full\nScreen", "view.fullScreen", v.full_screen);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, on, v.full_screen, "Full Screen"));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, v.full_screen, "Full Screen"));
});
group(ui, "Macros", None, app, |ui, app| {
big(ui, app, "macros", "Macros", "tools.macros", json!({}), false);
@@ -2752,7 +2767,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) {
Some((pos, wordcraft_doc::para::InlineObject::Image { media, .. })) => (media, pos.off),
_ => (String::new(), 0),
};
let key = egui::Id::new(("picture_transparency", media, off));
let key = egui::Id::unique(("picture_transparency", media, off));
let mut v = ui.data(|d| d.get_temp::<f64>(key)).unwrap_or(0.0);
let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(0.0..=100.0).suffix("%"));
if r.changed() {
@@ -2827,7 +2842,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) {
));
// While typing, the field reports each keystroke as a change; only a new
// value (Enter, Tab, a click elsewhere, a drag) resizes, and Esc cancels.
let cancelled = esc_cancelled(ui, &r, egui::Id::new(("picture_size_field", key)));
let cancelled = esc_cancelled(ui, &r, egui::Id::unique(("picture_size_field", key)));
if r.changed() && v != was && !cancelled {
if r.dragged() && !r.drag_started() {
app.session.join_next_undo();
@@ -2866,7 +2881,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) {
ui.label(egui::RichText::new(tl!("Alt:")).small());
// Frame-local buffers lose keystrokes; keep the draft in egui temp memory keyed
// by picture (same pattern as the comment editor in panes.rs).
let key = egui::Id::new(("picture_alt", media.clone(), off));
let key = egui::Id::unique(("picture_alt", media.clone(), off));
let mut alt = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| alt0.clone());
let r = ui.add(egui::TextEdit::singleline(&mut alt).desired_width(120.0).hint_text(tl!("Alt text")));
if r.changed() {
+5 -5
View File
@@ -93,19 +93,19 @@ struct Measured {
}
fn frame_id() -> Id {
Id::new("ribbon_fit_frame")
Id::unique("ribbon_fit_frame")
}
fn measured_id() -> Id {
Id::new("ribbon_fit_measured")
Id::unique("ribbon_fit_measured")
}
fn compact_id() -> Id {
Id::new("ribbon_fit_compact")
Id::unique("ribbon_fit_compact")
}
fn saving_id() -> Id {
Id::new("ribbon_fit_saving")
Id::unique("ribbon_fit_saving")
}
fn active_id() -> Id {
Id::new("ribbon_fit_active")
Id::unique("ribbon_fit_active")
}
/// What the measured widths depend on besides the groups themselves.
+12 -7
View File
@@ -118,7 +118,7 @@ pub(crate) fn selector(app: &mut WordApp, ui: &Ui, corner: Rect) {
return;
}
let t = Tokens::get(ui.ctx());
let r = ui.interact(corner, ui.id().with("ruler_tab_kind"), Sense::click());
let r = ui.interact(corner, ui.scope_id().with("ruler_tab_kind"), Sense::click());
if r.clicked() {
app.canvas.tab_kind = next_kind(app.canvas.tab_kind, false);
} else if r.secondary_clicked() {
@@ -186,7 +186,7 @@ pub(crate) fn stops(app: &mut WordApp, ui: &Ui, hp: &Painter, g: &Ruler<'_>, tab
// A click on an empty stretch over the text sets a stop of the selector's kind there; pressing
// and dragging places it where the button is released. The edges stay with the margin handles
// and indent markers, and only the primary button places stops.
let add = ui.interact(g.text.shrink2(vec2(4.0, 0.0)), ui.id().with("ruler_tab_add"), Sense::click_and_drag());
let add = ui.interact(g.text.shrink2(vec2(4.0, 0.0)), ui.scope_id().with("ruler_tab_add"), Sense::click_and_drag());
let kind = app.canvas.tab_kind;
let placing = |x: f32| TabDrag { from: None, para: para.clone(), align: kind, leader: TabLeader::None, at: snap(x), off: false };
if add.clicked()
@@ -204,8 +204,11 @@ pub(crate) fn stops(app: &mut WordApp, ui: &Ui, hp: &Painter, g: &Ruler<'_>, tab
let mut open_dialog = false;
for (i, tab) in tabs.iter().enumerate() {
let x = (g.x_of)(tab.pos);
let r =
ui.interact(Rect::from_center_size(pos2(x, g.bar.max.y - 4.0), vec2(9.0, 10.0)), ui.id().with(("ruler_tab", i)), Sense::click_and_drag());
let r = ui.interact(
Rect::from_center_size(pos2(x, g.bar.max.y - 4.0), vec2(9.0, 10.0)),
ui.scope_id().with(("ruler_tab", i)),
Sense::click_and_drag(),
);
let starts = r.drag_started_by(egui::PointerButton::Primary);
if starts {
app.canvas.tab_drag =
@@ -240,13 +243,15 @@ pub(crate) fn stops(app: &mut WordApp, ui: &Ui, hp: &Painter, g: &Ruler<'_>, tab
draw_tab_glyph(hp, x, base, d.align, g.rtl, stroke);
// A guide down the page shows where text will line up.
if let Some(area) = app.canvas.canvas_rect {
let guide =
ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, ui.id().with("ruler_tab_guide"))).with_clip_rect(area);
let guide = ui
.ctx()
.layer_painter(egui::LayerId::new(egui::Order::Foreground, ui.scope_id().with("ruler_tab_guide")))
.with_clip_rect(area);
guide.extend(Shape::dashed_line(&[pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, t.text_dim), 3.0, 3.0));
}
format!("{} {}", tl!(kind_label(d.align)), units.format_with(d.at, decimal))
};
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), ui.id().with("ruler_tab_tip"), egui::PopupAnchor::Pointer).show(|ui| {
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), ui.scope_id().with("ruler_tab_tip"), egui::PopupAnchor::Pointer).show(|ui| {
ui.label(label);
});
} else {
+8 -6
View File
@@ -14,6 +14,8 @@
//! stays: it carries on the fingers' own movement, as the momentum macOS and Windows send does
//! whatever their animation setting.
#[cfg(test)]
use egui::MouseWheelSource;
use egui::{Event, InputOptions, Modifiers, MouseWheelUnit, TouchPhase, Vec2};
/// Screen points one mouse-wheel notch scrolls at 100% zoom: about three lines of body text,
@@ -177,7 +179,7 @@ impl CanvasScroll {
let mut zooming = false;
for event in &input.events {
match event {
Event::MouseWheel { unit, delta, phase, modifiers } => {
Event::MouseWheel { unit, delta, phase, modifiers, .. } => {
// Ctrl/⌘+wheel zooms (egui turns it into a zoom factor).
if !hovered {
continue;
@@ -275,7 +277,7 @@ impl CanvasScroll {
for event in &input.events {
match event {
Event::Zoom(factor) => zoom *= factor,
Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } if modifiers.matches_any(opts.zoom_modifier) => {
Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers, .. } if modifiers.matches_any(opts.zoom_modifier) => {
let d = egui_points(*unit, *delta, input, opts);
turned += opts.scroll_zoom_speed * (d.x + d.y);
}
@@ -326,7 +328,7 @@ fn egui_points(unit: MouseWheelUnit, delta: Vec2, input: &egui::InputState, opts
pub fn unsmoothed_scroll(input: &egui::InputState, opts: &InputOptions) -> Vec2 {
let mut total = Vec2::ZERO;
for event in &input.events {
if let Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } = event
if let Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers, .. } = event
&& !modifiers.matches_any(opts.zoom_modifier)
{
total += axis_lock(egui_points(*unit, *delta, input, opts), *modifiers, opts);
@@ -415,7 +417,7 @@ mod tests {
}
fn wheel(unit: MouseWheelUnit, delta: Vec2, modifiers: Modifiers) -> Event {
Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers }
Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers, source: MouseWheelSource::Unknown }
}
/// One 60 fps frame over a tall egui scroll area with the pointer on it; `animate` false puts
@@ -432,12 +434,12 @@ mod tests {
if !animate {
ui.ctx().input_mut(|i| i.smooth_scroll_delta = unsmoothed_scroll(i, &opts));
}
let id = egui::Id::new("canvas scroll under test");
let id = egui::Id::unique("canvas scroll under test");
let mut scroll = ui.data(|d| d.get_temp::<CanvasScroll>(id)).unwrap_or_default();
let zoom = ui.input(|i| scroll.zoom(i, &opts, animate));
ui.data_mut(|d| d.insert_temp(id, scroll));
let out = egui::ScrollArea::vertical().show(ui, |ui| ui.allocate_space(egui::vec2(100.0, 5000.0)));
seen = (out.state.offset.y, zoom);
seen = (out.state.clamped_offset().y, zoom);
})
.drop_without_applying_deltas();
seen
+2 -2
View File
@@ -56,8 +56,8 @@ fn grip(app: &mut WordApp, ui: &Ui, layout: &DocLayout, zone: Rect, column: bool
} else {
("ruler_row", tl!("Table Row Height"), egui::CursorIcon::ResizeRow)
};
let r = ui.interact(zone, ui.id().with((name, i)), Sense::drag()).on_hover_cursor(cursor);
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, label));
let r = ui.interact(zone, ui.scope_id().with((name, i)), Sense::drag()).on_hover_cursor(cursor);
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Splitter, true, label));
let r = if r.dragged() { r } else { r.on_hover_text(label) };
if r.drag_started() {
crate::table_resize::grab(app, layout, at.0, at.1, at.2);
+1
View File
@@ -41,6 +41,7 @@ fn wheel(h: &mut egui_kittest::Harness<'static, WordApp>, dy: f32) {
delta: egui::vec2(0.0, dy),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
source: egui::MouseWheelSource::Unknown,
});
}
+14
View File
@@ -916,6 +916,20 @@ mod tests {
}
}
/// The test root declares egui's `default_fonts` and `monochrome_emoji_fonts` features
/// explicitly (kittest's own dev-dependencies do not propagate). WordCraft inserts Inter at
/// the front and SourceSans after egui's complete default fallback block, including the
/// monochrome emoji faces. Production keeps only `default_fonts` through eframe.
#[test]
fn interface_font_order_keeps_test_default_fonts_explicit() {
let defs = super::font_definitions(false, false);
let prop = &defs.families[&egui::FontFamily::Proportional];
assert_eq!(
prop.iter().take(6).map(String::as_str).collect::<Vec<_>>(),
["Inter", "Ubuntu-Light", "egui-icons", "NotoEmoji-Regular", "emoji-icon-font", "SourceSans"]
);
}
/// #565: the composition of an input method (Bangla through ibus) drew as boxes because only the
/// interface fonts were used. Faces found for the uncovered characters are added as fallbacks,
/// once, and not for characters the interface fonts draw.
+24 -24
View File
@@ -85,7 +85,7 @@ fn show_hint(resp: &Response, title: &str, notes: &[String]) {
let ctx = &resp.ctx;
let since_key = id_since_focus(resp);
// The pass on which the pointer was last over any hinted control (see the focus hint below).
let hover_key = egui::Id::new("hint_pointer_over_a_control");
let hover_key = egui::Id::unique("hint_pointer_over_a_control");
let pass = ctx.cumulative_pass_nr();
// A disabled response never reports hover, so test the pointer against its rectangle, as
// `Tooltip::for_disabled` does.
@@ -291,7 +291,7 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti
if resp.clicked() && on {
run_from_ribbon(app, id, params);
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tip));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tip));
resp
}
@@ -330,10 +330,10 @@ pub fn split(
}
let (arrow_title, _) = tooltip_lines(&crate::i18n::fmt(tl!("{name} options"), &[("name", tip)]), "", "", None);
show_hint(&aresp, &arrow_title, &[]);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tip));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tip));
// The arrow opens the choices beside the button: it says whose, so it is not just "button"
// (#3172).
aresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, &arrow_title));
aresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, &arrow_title));
if !on {
return;
}
@@ -399,7 +399,7 @@ pub fn menu_button(
let reason = if on { None } else { Some(app.session.why_disabled(id).unwrap_or("")) };
let (title, notes) = tooltip_lines(tip, &shortcut_text(app, id), "", reason);
show_hint(&resp, &title, &notes);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tip));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tip));
if !on {
return;
}
@@ -474,7 +474,7 @@ fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option
ui.painter().text(pos2(r.center().x, title_y), Align2::CENTER_CENTER, title, regular(11.0), t.group_label);
if let Some(cmd) = launcher {
let lr = Rect::from_center_size(pos2(r.max.x - 6.0, title_y), vec2(11.0, 11.0));
let resp = ui.interact(lr, ui.id().with(("launch", id_title)), Sense::click());
let resp = ui.interact(lr, ui.scope_id().with(("launch", id_title)), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(lr.expand(2.0), 2.0, t.hover);
}
@@ -487,7 +487,7 @@ fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option
/// Where a collapsed group remembers whether its drop-down is open.
fn collapsed_open_id(ui: &Ui, id_title: &str, position: usize) -> egui::Id {
ui.id().with(("collapsed_group", id_title, position))
ui.scope_id().with(("collapsed_group", id_title, position))
}
/// A group folded into one drop-down button (the window is too narrow, #662): the button, as tall
@@ -507,7 +507,7 @@ fn collapsed_group(
let t = Tokens::get(ui.ctx());
let text_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.5), t.text).size().x);
let (r, resp) = ui.allocate_exact_size(vec2((text_w + DROPDOWN + 14.0).max(48.0), CONTENT_H + LABEL_H), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title));
let open_id = collapsed_open_id(ui, id_title, position);
let was_open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
bg(ui, r, &resp, was_open, &t);
@@ -520,7 +520,7 @@ fn collapsed_group(
);
label_line(ui, r.center().x, r.min.y + BIG_LABEL_Y, title, regular(11.5), t.text, true);
let resp = resp.on_hover_text(title);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title));
let mut open = was_open;
if resp.clicked() {
open = !open;
@@ -746,7 +746,7 @@ pub fn font_combo(
items: &[String],
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
) -> Option<String> {
combo_impl(ui, id, width, current, items, &[], preview, true, "")
combo_impl(ui, id, width, current, items, &[], preview, true, tl!("Font"))
}
/// [`font_combo`] with the `recent` families (most recent first) in a Recently Used Fonts
@@ -763,7 +763,7 @@ pub fn font_combo_with_recent(
recent: &[String],
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
) -> Option<String> {
combo_impl(ui, id, width, current, items, recent, preview, true, "")
combo_impl(ui, id, width, current, items, recent, preview, true, tl!("Font"))
}
/// [`font_combo_with_recent`] with a hover hint `tip` (already translated).
@@ -839,9 +839,9 @@ fn combo_impl(
tip: &str,
) -> Option<String> {
let mut out = None;
let edit_id = ui.id().with(("combo_text", id));
let te_id = ui.id().with(("combo_edit", id));
let search_id = ui.id().with(("combo_search", id));
let edit_id = ui.scope_id().with(("combo_text", id));
let te_id = ui.scope_id().with(("combo_edit", id));
let search_id = ui.scope_id().with(("combo_search", id));
let mut text = ui.data_mut(|d| d.get_temp::<String>(edit_id)).unwrap_or_else(|| current.to_string());
let mut s: ComboSearch = if search { ui.data_mut(|d| d.get_temp(search_id)).unwrap_or_default() } else { ComboSearch::default() };
let mut focused = false;
@@ -875,7 +875,7 @@ fn combo_impl(
// The field says what it is: without a name a screen reader has only "edit" to say
// (#3172). The arrow says whose options it opens, as the split buttons' arrow does.
if !tip.is_empty() {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, ui.is_enabled(), tip));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), tip));
}
keytip_badge(ui, resp.rect, "combo", id);
focused = resp.has_focus();
@@ -931,7 +931,7 @@ fn combo_impl(
let (ar, aresp) = ui.allocate_exact_size(vec2(16.0, resp.rect.height()), Sense::click());
if !tip.is_empty() {
let arrow_name = crate::i18n::fmt(tl!("{name} options"), &[("name", tip)]);
aresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &arrow_name));
aresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &arrow_name));
}
// It reaches one border width under the field, so the field's right border is the only line
// between them: two borders side by side read as a double-width divider.
@@ -1155,7 +1155,7 @@ fn list_rows<'a>(
}
}
});
(out, shown.state.offset.y, shown.inner_rect.height())
(out, shown.state.clamped_offset().y, shown.inner_rect.height())
}
/// How a drawn button looks this frame. Hover fades in over egui's animation time and the
@@ -1253,7 +1253,7 @@ fn icon_text_button_with(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId
let text_w = if show_text { galley.size().x } else { 0.0 };
let size = vec2(icon_text_button_size(ui, text_w, marker, show_text), galley.size().y.max(icon_w) + 2.0 * pad.y);
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), text));
let st = drawn_state(ui, &resp, true);
let r = r.expand(st.expansion);
ui.painter().rect(r, ui.visuals().widgets.inactive.corner_radius, st.fill, st.stroke, egui::StrokeKind::Inside);
@@ -1286,7 +1286,7 @@ pub fn icon_menu_item(ui: &mut Ui, icon: &str, text: &str, shortcut: &str, min_w
let sc_w = sc.as_ref().map_or(0.0, |g| g.size().x + 3.0 * pad.x);
let w = (pad.x + icon_w + 6.0 + galley.size().x + sc_w + pad.x).max(min_w).max(ui.available_width());
let (r, resp) = ui.allocate_exact_size(vec2(w, galley.size().y.max(icon_w) + 2.0 * pad.y), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), text));
let st = drawn_state(ui, &resp, false);
ui.painter().rect_filled(r, ui.visuals().widgets.inactive.corner_radius, st.fill);
let (line, accent) = if ui.is_enabled() { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
@@ -1587,7 +1587,7 @@ mod tests {
for (n, events) in frames.into_iter().enumerate() {
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
if n == 0 {
ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f"))));
ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f"))));
}
let v = if search { font_combo_with_recent(ui, "f", 150.0, "", items, recent, None) } else { combo(ui, "f", 150.0, "", items, None) };
out = out.take().or(v);
@@ -1648,9 +1648,9 @@ mod tests {
for (n, events) in frames.into_iter().enumerate() {
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
if n == 0 {
ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f"))));
ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f"))));
}
area = Some(ui.id().with(("combo_search", "f")).with("list"));
area = Some(ui.scope_id().with(("combo_search", "f")).with("list"));
font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None);
})
.drop_without_applying_deltas();
@@ -1689,7 +1689,7 @@ mod tests {
(vec![egui::Event::Text("ar".into())], "Arial Black"),
];
ctx.run_ui(egui::RawInput::default(), |ui| {
ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f"))));
ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f"))));
font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None);
})
.drop_without_applying_deltas();
@@ -1768,7 +1768,7 @@ mod tests {
crate::theme::apply(&ctx, appearance);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
if focused {
ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f"))));
ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f"))));
}
combo(ui, "f", 150.0, "12", &sizes, None);
});
+4 -4
View File
@@ -57,7 +57,7 @@ pub(crate) fn show(ctx: &egui::Context, window: &Response, t: &Tokens) -> bool {
// Pointer-only, like a caption button: Tab never lands on it.
let r = ui.interact(rect, id.with("hit"), Sense::CLICK | Sense::DRAG);
let label = tl!("Close");
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
let r = r.on_hover_text(label);
let down = r.is_pointer_button_down_on() && r.contains_pointer();
let hot = down || r.hovered();
@@ -144,14 +144,14 @@ mod tests {
for _ in 0..4 {
h.step();
}
click_close_box(&mut h, egui::Id::new("read-aloud-player"));
click_close_box(&mut h, egui::Id::unique("read-aloud-player"));
assert!(!h.state().session.read_aloud.open, "the player stops and closes");
let _ = h.state_mut().run("ui.fileBrowser", json!({"purpose": "document"}));
for _ in 0..4 {
h.step();
}
assert!(h.state().file_browser.open.is_some(), "the browser opened");
click_close_box(&mut h, egui::Id::new("builtin_file_browser"));
click_close_box(&mut h, egui::Id::unique("builtin_file_browser"));
assert!(h.state().file_browser.open.is_none(), "the browser is cancelled");
}
@@ -165,7 +165,7 @@ mod tests {
for _ in 0..4 {
h.step();
}
let hit = egui::Id::new(("dialog", "Word Count")).with("close_box").with("hit");
let hit = egui::Id::unique(("dialog", "Word Count")).with("close_box").with("hit");
let r = h.ctx.read_response(hit).expect("the close box is a widget");
assert!(r.sense.senses_click() && !r.sense.is_focusable(), "pointer-only: {:?}", r.sense);
for _ in 0..12 {
+137 -42
View File
@@ -6,6 +6,48 @@
use serde::{Deserialize, Serialize};
/// The host's window state for one raw frame.
///
/// [`egui::InputState::viewport`] still describes the last painted frame on a hidden logic tick,
/// and its `viewport_rect()` is that frame's content size. The host's [`egui::RawInput`] is the
/// fresh answer for this frame, so the app copies the fields it needs here in `raw_input_hook`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct RawWindowState {
/// The content rectangle, when the platform reports it (not Wayland).
pub inner_rect: Option<egui::Rect>,
/// The window rectangle including decorations, when the platform reports it.
pub outer_rect: Option<egui::Rect>,
/// This frame's `RawInput::screen_rect`: the content size even where `inner_rect` is unknown.
pub screen_rect: Option<egui::Rect>,
pub monitor_size: Option<egui::Vec2>,
pub maximized: Option<bool>,
pub minimized: Option<bool>,
pub fullscreen: Option<bool>,
}
impl RawWindowState {
/// Copy the window state out of this frame's raw input.
pub fn from_raw(raw: &egui::RawInput) -> Self {
let viewport = raw.viewports.get(&raw.viewport_id);
Self {
inner_rect: viewport.and_then(|v| v.inner_rect).filter(|r| r.is_finite()),
outer_rect: viewport.and_then(|v| v.outer_rect).filter(|r| r.is_finite()),
screen_rect: raw.screen_rect.filter(|r| r.is_finite()),
monitor_size: viewport.and_then(|v| v.monitor_size),
maximized: viewport.and_then(|v| v.maximized),
minimized: viewport.and_then(|v| v.minimized),
fullscreen: viewport.and_then(|v| v.fullscreen),
}
}
/// The content size reported by this frame, if any. Never the last painted size: a hidden
/// Wayland tick with neither an `inner_rect` nor a screen rect has no usable geometry.
pub fn content_size(self) -> Option<egui::Vec2> {
let size = self.inner_rect.or(self.screen_rect)?.size();
(size.is_finite() && size.x > 0.0 && size.y > 0.0).then_some(size)
}
}
/// The smallest window restored: the desktop app's minimum inner size.
pub const MIN_SIZE: egui::Vec2 = egui::vec2(760.0, 480.0);
/// Larger saved sizes are corrupt. eframe also shrinks the start size to the largest monitor.
@@ -35,21 +77,26 @@ impl WindowGeometry {
/// The geometry to save, given the last saved one and the window as it is now. While the
/// window is minimized, full screen or maximized, the last un-maximized size and position are
/// kept, so un-maximizing after the next launch returns to them.
pub fn track(prev: Option<Self>, info: &egui::ViewportInfo, content_size: egui::Vec2) -> Option<Self> {
if info.minimized == Some(true) || info.fullscreen == Some(true) {
pub fn track(prev: Option<Self>, window: &RawWindowState) -> Option<Self> {
if window.minimized == Some(true) || window.fullscreen == Some(true) {
return prev;
}
// egui reads the maximized state on macOS only when the window opens (egui#3494), so a
// window that opened zoomed would stay "maximized" for good. There the zoomed frame is
// saved like any other size instead.
let maximized = info.maximized == Some(true) && !cfg!(target_os = "macos");
let maximized = window.maximized == Some(true) && !cfg!(target_os = "macos");
if maximized && let Some(prev) = prev {
return Some(Self { maximized, ..prev });
}
let rect = if cfg!(target_os = "macos") { info.inner_rect } else { info.outer_rect };
let size = info.inner_rect.map_or(content_size, |r| r.size());
let rect = if cfg!(target_os = "macos") { window.inner_rect } else { window.outer_rect };
// No rect on this raw frame: keep the last saved geometry. Falling back to
// `InputState::viewport_rect()` here would save the previous painted size, which is the
// stale value a hidden Wayland tick must not write.
let Some(size) = window.content_size() else {
return prev;
};
let position = rect.map(|r| [r.min.x, r.min.y]);
let monitor = info.monitor_size.map(|s| [s.x, s.y]);
let monitor = window.monitor_size.map(|s| [s.x, s.y]);
Self { position, width: size.x, height: size.y, maximized, monitor }.sanitized().or(prev)
}
@@ -94,12 +141,12 @@ impl WindowGeometry {
/// position no monitor covers any more doesn't open off screen. Centred on the primary
/// monitor, whose top-left corner is the origin. A maximized window fills whichever monitor
/// the system picks, so it stays put.
pub fn rescue_position(&self, info: &egui::ViewportInfo) -> Option<egui::Pos2> {
pub fn rescue_position(&self, window: &RawWindowState) -> Option<egui::Pos2> {
if self.maximized || self.position.is_none() {
return None;
}
let saved = egui::Vec2::from(self.monitor?);
let now = info.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?;
let now = window.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?;
if (now - saved).length() <= 1.0 {
return None;
}
@@ -115,13 +162,13 @@ impl WindowGeometry {
/// (no position here) is on the primary monitor, whose top-left corner is the origin, so its
/// position counts too; a restored one may be on another monitor, so only its size does. A
/// maximized window fills its monitor already.
pub fn fit_on_monitor(&self, info: &egui::ViewportInfo) -> Option<(egui::Pos2, egui::Vec2)> {
if self.maximized || info.maximized == Some(true) || info.fullscreen == Some(true) || info.minimized == Some(true) {
pub fn fit_on_monitor(&self, window: &RawWindowState) -> Option<(egui::Pos2, egui::Vec2)> {
if self.maximized || window.maximized == Some(true) || window.fullscreen == Some(true) || window.minimized == Some(true) {
return None;
}
let monitor = info.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?;
let inner = info.inner_rect.filter(|r| r.is_finite())?;
let outer = info.outer_rect.filter(|r| r.is_finite()).unwrap_or(inner);
let monitor = window.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?;
let inner = window.inner_rect.filter(|r| r.is_finite())?;
let outer = window.outer_rect.filter(|r| r.is_finite()).unwrap_or(inner);
let frame = (outer.size() - inner.size()).max(egui::Vec2::ZERO);
let room = monitor - 2.0 * SYSTEM_BAR;
let too_big = outer.width() > room.x || outer.height() > room.y;
@@ -137,11 +184,11 @@ impl WindowGeometry {
}
/// Checks the restored geometry in `pending` once, and only once the monitor size is known: while
/// `info` doesn't report a usable one, `pending` is left in place for a later frame. Then it is taken
/// `window` doesn't report a usable one, `pending` is left in place for a later frame. Then it is taken
/// and [`WindowGeometry::rescue_position`] decides where to move the window, if anywhere.
pub fn take_rescue(pending: &mut Option<WindowGeometry>, info: &egui::ViewportInfo) -> Option<egui::Pos2> {
info.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?;
pending.take()?.rescue_position(info)
pub fn take_rescue(pending: &mut Option<WindowGeometry>, window: &RawWindowState) -> Option<egui::Pos2> {
window.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?;
pending.take()?.rescue_position(window)
}
/// The window size to ask for when a caller (the control channel's `ui.resize`) wants `size`:
@@ -164,8 +211,8 @@ pub fn requested_size(size: egui::Vec2, max_side: f32) -> Option<egui::Vec2> {
mod tests {
use super::*;
fn info(rect: egui::Rect, monitor: egui::Vec2) -> egui::ViewportInfo {
egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), monitor_size: Some(monitor), ..Default::default() }
fn raw(rect: egui::Rect, monitor: egui::Vec2) -> RawWindowState {
RawWindowState { inner_rect: Some(rect), outer_rect: Some(rect), screen_rect: Some(rect), monitor_size: Some(monitor), ..Default::default() }
}
fn saved() -> WindowGeometry {
@@ -175,7 +222,7 @@ mod tests {
#[test]
fn records_the_window_and_restores_it() {
let rect = egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(1200.0, 800.0));
let g = WindowGeometry::track(None, &info(rect, egui::vec2(1920.0, 1080.0)), egui::Vec2::ZERO);
let g = WindowGeometry::track(None, &raw(rect, egui::vec2(1920.0, 1080.0)));
assert_eq!(g, Some(saved()));
let b = saved().apply(egui::ViewportBuilder::default());
assert_eq!(b.inner_size, Some(egui::vec2(1200.0, 800.0)));
@@ -203,9 +250,9 @@ mod tests {
#[test]
fn maximized_keeps_the_normal_size_and_position() {
let full = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1920.0, 1040.0));
let mut i = info(full, egui::vec2(1920.0, 1080.0));
let mut i = raw(full, egui::vec2(1920.0, 1080.0));
i.maximized = Some(true);
let g = WindowGeometry::track(Some(saved()), &i, egui::Vec2::ZERO);
let g = WindowGeometry::track(Some(saved()), &i);
if cfg!(target_os = "macos") {
// The zoomed frame is saved as the size; the window never reopens "maximized".
assert_eq!(g.map(|g| (g.position, g.width, g.height, g.maximized)), Some((Some([0.0, 0.0]), 1920.0, 1040.0, false)));
@@ -215,21 +262,69 @@ mod tests {
// Minimized or full screen: nothing changes.
i.maximized = Some(false);
i.minimized = Some(true);
assert_eq!(WindowGeometry::track(Some(saved()), &i, egui::Vec2::ZERO), Some(saved()));
assert_eq!(WindowGeometry::track(Some(saved()), &i), Some(saved()));
i.minimized = None;
i.fullscreen = Some(true);
assert_eq!(WindowGeometry::track(Some(saved()), &i, egui::Vec2::ZERO), Some(saved()));
assert_eq!(WindowGeometry::track(Some(saved()), &i), Some(saved()));
}
#[test]
fn without_a_position_the_content_size_is_kept() {
// Wayland reports neither rect.
let i = egui::ViewportInfo::default();
let g = WindowGeometry::track(None, &i, egui::vec2(1000.0, 700.0));
fn without_a_position_the_raw_screen_size_is_kept() {
// Wayland reports no inner/outer rect, but RawInput still carries the content screen rect.
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0));
let i = RawWindowState { screen_rect: Some(rect), ..Default::default() };
let g = WindowGeometry::track(None, &i);
assert_eq!(g, Some(WindowGeometry { position: None, width: 1000.0, height: 700.0, maximized: false, monitor: None }));
assert_eq!(g.map(|g| g.apply(egui::ViewportBuilder::default()).position), Some(None));
}
/// A hidden Wayland logic tick has no fresh rect. It must keep the saved geometry rather than
/// write the previous painted `InputState` size, and a fresh `screen_rect` on the same raw
/// frame is used when the host reports one.
#[test]
fn a_hidden_raw_tick_never_reuses_the_last_painted_size() {
assert_eq!(WindowGeometry::track(Some(saved()), &RawWindowState::default()), Some(saved()));
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0));
let fresh = RawWindowState {
inner_rect: None,
outer_rect: None,
screen_rect: Some(rect),
monitor_size: Some(egui::vec2(1920.0, 1080.0)),
..Default::default()
};
let g = WindowGeometry::track(Some(saved()), &fresh).expect("the fresh raw size");
assert_eq!((g.position, g.width, g.height, g.monitor), (None, 1000.0, 700.0, Some([1920.0, 1080.0])));
}
#[test]
fn raw_window_state_reads_only_this_frames_viewport() {
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0));
let mut input = egui::RawInput { screen_rect: Some(rect), ..Default::default() };
input.viewports.insert(
egui::ViewportId::ROOT,
egui::ViewportInfo {
inner_rect: Some(rect),
outer_rect: Some(rect.expand(10.0)),
monitor_size: Some(egui::vec2(1920.0, 1080.0)),
maximized: Some(false),
minimized: Some(false),
fullscreen: Some(false),
..Default::default()
},
);
let state = RawWindowState::from_raw(&input);
assert_eq!(state.inner_rect, Some(rect));
assert_eq!(state.outer_rect, Some(rect.expand(10.0)));
assert_eq!(state.content_size(), Some(egui::vec2(640.0, 480.0)));
assert_eq!(state.monitor_size, Some(egui::vec2(1920.0, 1080.0)));
// A frame without its viewport entry (or with non-finite rects) contributes nothing.
let empty = RawWindowState::from_raw(&egui::RawInput::default());
assert_eq!(empty, RawWindowState::default());
assert_eq!(empty.content_size(), None);
}
#[test]
fn insane_values_are_clamped_or_dropped() {
let zero = WindowGeometry { width: 0.0, height: 800.0, ..saved() };
@@ -248,16 +343,16 @@ mod tests {
fn a_window_whose_monitor_is_gone_is_moved_on_screen() {
let on = egui::Rect::from_min_size(egui::pos2(2000.0, 50.0), egui::vec2(1200.0, 800.0));
// Same monitor: stays where it was.
assert_eq!(saved().rescue_position(&info(on, egui::vec2(1920.0, 1080.0))), None);
assert_eq!(saved().rescue_position(&raw(on, egui::vec2(1920.0, 1080.0))), None);
// A different (smaller) monitor now: centred on it.
assert_eq!(saved().rescue_position(&info(on, egui::vec2(1600.0, 900.0))), Some(egui::pos2(200.0, 50.0)));
assert_eq!(saved().rescue_position(&raw(on, egui::vec2(1600.0, 900.0))), Some(egui::pos2(200.0, 50.0)));
// Larger than that monitor: its top-left corner.
assert_eq!(saved().rescue_position(&info(on, egui::vec2(1024.0, 768.0))), Some(egui::Pos2::ZERO));
assert_eq!(saved().rescue_position(&raw(on, egui::vec2(1024.0, 768.0))), Some(egui::Pos2::ZERO));
// Nothing to compare, or no saved position: no move.
assert_eq!(WindowGeometry { monitor: None, ..saved() }.rescue_position(&info(on, egui::vec2(1600.0, 900.0))), None);
assert_eq!(WindowGeometry { position: None, ..saved() }.rescue_position(&info(on, egui::vec2(1600.0, 900.0))), None);
assert_eq!(WindowGeometry { maximized: true, ..saved() }.rescue_position(&info(on, egui::vec2(1600.0, 900.0))), None);
assert_eq!(saved().rescue_position(&egui::ViewportInfo::default()), None);
assert_eq!(WindowGeometry { monitor: None, ..saved() }.rescue_position(&raw(on, egui::vec2(1600.0, 900.0))), None);
assert_eq!(WindowGeometry { position: None, ..saved() }.rescue_position(&raw(on, egui::vec2(1600.0, 900.0))), None);
assert_eq!(WindowGeometry { maximized: true, ..saved() }.rescue_position(&raw(on, egui::vec2(1600.0, 900.0))), None);
assert_eq!(saved().rescue_position(&RawWindowState::default()), None);
}
#[test]
@@ -265,15 +360,15 @@ mod tests {
let on = egui::Rect::from_min_size(egui::pos2(2000.0, 50.0), egui::vec2(1200.0, 800.0));
// The first frame doesn't know the monitor yet: nothing happens and the geometry is kept.
let mut pending = Some(saved());
assert_eq!(take_rescue(&mut pending, &egui::ViewportInfo::default()), None);
assert_eq!(take_rescue(&mut pending, &RawWindowState::default()), None);
assert_eq!(pending, Some(saved()));
// A later frame reports a different monitor: centred on it, and the check is done.
assert_eq!(take_rescue(&mut pending, &info(on, egui::vec2(1600.0, 900.0))), Some(egui::pos2(200.0, 50.0)));
assert_eq!(take_rescue(&mut pending, &raw(on, egui::vec2(1600.0, 900.0))), Some(egui::pos2(200.0, 50.0)));
assert_eq!(pending, None);
assert_eq!(take_rescue(&mut pending, &info(on, egui::vec2(1024.0, 768.0))), None);
assert_eq!(take_rescue(&mut pending, &raw(on, egui::vec2(1024.0, 768.0))), None);
// The same monitor: no move, and the check is done as well.
let mut pending = Some(saved());
assert_eq!(take_rescue(&mut pending, &info(on, egui::vec2(1920.0, 1080.0))), None);
assert_eq!(take_rescue(&mut pending, &raw(on, egui::vec2(1920.0, 1080.0))), None);
assert_eq!(pending, None);
}
@@ -290,8 +385,8 @@ mod tests {
}
/// A window opened with its frame's top-left corner at `at`: 8 pt borders, a 31 pt title bar.
fn opened(at: egui::Pos2, content: egui::Vec2, monitor: egui::Vec2) -> egui::ViewportInfo {
egui::ViewportInfo {
fn opened(at: egui::Pos2, content: egui::Vec2, monitor: egui::Vec2) -> RawWindowState {
RawWindowState {
inner_rect: Some(egui::Rect::from_min_size(at + egui::vec2(8.0, 31.0), content)),
outer_rect: Some(egui::Rect::from_min_size(at, content + egui::vec2(16.0, 39.0))),
monitor_size: Some(monitor),
@@ -363,6 +458,6 @@ mod tests {
i.monitor_size = None;
assert_eq!(system.fit_on_monitor(&i), None);
// Wayland reports no rects.
assert_eq!(system.fit_on_monitor(&egui::ViewportInfo { monitor_size: Some(laptop), ..Default::default() }), None);
assert_eq!(system.fit_on_monitor(&RawWindowState { monitor_size: Some(laptop), ..Default::default() }), None);
}
}
+1 -1
View File
@@ -230,7 +230,7 @@ pub fn show_alert(app: &mut WordApp, ctx: &egui::Context) {
};
let mut picked = None;
egui::Window::new("Zotero")
.id(egui::Id::new("zotero-alert"))
.id(egui::Id::unique("zotero-alert"))
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, -40.0))
+8 -8
View File
@@ -42,7 +42,7 @@ fn control_print_discovery_reports_metadata_without_host_side_effects() {
assert!(command["params"].as_str().unwrap().contains(field), "{id}: {field}");
}
}
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
let Outcome::Done(reply) = handle(&mut app, &ctx, &request) else { panic!("metadata replies immediately") };
for id in ["ui.printSubmit", "ui.print"] {
@@ -69,7 +69,7 @@ fn native_print_uses_selected_settings_and_reports_spool_errors() {
}));
assert!(app.run("ui.print", json!({})).is_err());
assert!(jobs.borrow().is_empty(), "opening settings/exporting does not spool");
app.printing.printers.push(Printer { name: "Chosen queue".into(), default: false });
app.printing.printers.push(Printer { name: "Chosen queue".into(), label: String::new(), default: false });
let configured = app.run("ui.printSettings", json!({"printer":"Chosen queue", "copies":3,"duplex":"shortEdge","grayscale":true})).unwrap();
assert_eq!(configured["ready"], true);
assert_eq!(configured["printers"][0]["name"], "Chosen queue");
@@ -98,7 +98,7 @@ fn print_settings_are_visible_in_a_narrow_window() {
app.run("text.insert", json!({"text": "A document to print"})).unwrap();
app.services.printers = Some(Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap();
sender.send(vec![Printer { name: "Test queue".into(), label: String::new(), default: true }]).unwrap();
receiver
}));
app.services.print_native = Some(Box::new(|_, _, _, _| {
@@ -127,7 +127,7 @@ fn print_settings_are_visible_in_a_narrow_window() {
#[test]
fn invalid_print_settings_are_rejected_without_partial_changes() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true });
let before = app.run("ui.printSettings", json!({})).unwrap();
for params in [
json!({"printer":"Missing"}),
@@ -150,7 +150,7 @@ fn invalid_print_settings_are_rejected_without_partial_changes() {
#[test]
fn repeated_fast_jobs_each_surface_an_identical_receipt() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
app.services.print_native = Some(Box::new(|_, _, _, _| {
let (sender, receiver) = std::sync::mpsc::channel();
@@ -184,7 +184,7 @@ fn native_ranges_and_parity_do_not_duplicate_spooler_copies() {
}
}
assert_eq!(app.session.export_layout().pages.len(), 3);
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue","copies":3})).unwrap();
let jobs = Rc::new(RefCell::new(Vec::new()));
let observed = jobs.clone();
@@ -214,7 +214,7 @@ fn native_ranges_and_parity_do_not_duplicate_spooler_copies() {
fn native_print_passes_orientation_options_to_spooler() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.run("text.insert", json!({"text": "Test orientation"})).unwrap();
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
let jobs = Rc::new(RefCell::new(Vec::new()));
let observed = jobs.clone();
@@ -251,7 +251,7 @@ fn native_print_passes_orientation_options_to_spooler() {
fn native_print_scales_to_the_chosen_paper() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.run("text.insert", json!({"text": "Scale me"})).unwrap();
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
let jobs = Rc::new(RefCell::new(Vec::new()));
let observed = jobs.clone();
File diff suppressed because it is too large Load Diff
+12 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1,6 +1,6 @@
# Localization parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the egui 0.37 migration preserves the equation “Delete Around in Equation” translation in all 26 catalogs through the shared catalog pin; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
Interface languages, script support and proofing per language. How translation works (adding a
language, the embedded catalogs): `crates/ui-egui/src/i18n/mod.rs`. The catalogs, their format and
@@ -219,6 +219,7 @@ WordCraft keeps 2,54 cm).
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Preserved “Delete Around in Equation” in all 26 shared WordCraft catalogs; the native/release pin and Flatpak source advance together. |
| 2026-10-23 | minor | Interface catalogs moved to storytold/craft-i18n (`locales/<locale>/wordcraft.tsv`), looked up through craft-libs' `craft-i18n` crate and embedded from the pinned commit (`craft-i18n.rev`); strings other Crafting Apps already translate in the shared catalog now show translated; the "Open" paragraph-spacing preset no longer shows the file verb |
| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed |
+9 -6
View File
@@ -2,7 +2,7 @@
WordCraft uses `craft-pdf` with default features disabled and `write` enabled. Both that dependency
and the required root Krilla patch use the immutable `storytold/craft-libs` revision
`c3487320b2d1de657b860b3c8abb44e1f2b32bd8`. Rust 1.95 or newer is required. The revision must be
`9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef`. Rust 1.95 or newer is required. The revision must be
available from the remote before a fresh checkout can resolve it. The shared writer checks a
backend marker at compile time so an unpatched Krilla cannot replace the required backend.
@@ -20,11 +20,14 @@ never chooses a clock or changes this policy.
## Backend sources and features
Krilla remains 0.8.2 with its default raster-image and simple-text support. WordCraft does not
embed existing PDF pages, so it does not enable Krilla's optional `pdf` feature or the shared
`write-pdf` feature. The shared writer has no parser dependency. Hayro syntax and interpretation
remain explicit test dependencies at their existing registry versions; no parser or codec fork
is introduced. The existing `craft-print` and `craft-ui` dependency pins are retained separately.
Krilla remains 0.8.2 with raster-image support only: the shared workspace turns Krilla's default
features off, so its naive `simple-text` shaping and the unmaintained `rustybuzz` and `ttf-parser`
crates behind it are not built. WordCraft shapes text itself and hands Krilla positioned glyphs, so
it does not enable the shared `write-simple-text` feature. WordCraft does not embed existing PDF
pages, so it does not enable Krilla's optional `pdf` feature or the shared `write-pdf` feature.
The shared writer has no parser dependency. Hayro syntax and interpretation remain explicit test
dependencies at their existing registry versions; no parser or codec fork is introduced. The
existing `craft-print` and `craft-ui` dependency pins are retained separately.
Krilla's existing bundled profiles retain their upstream licences, recorded in the pinned shared
repository's `licenses/pdf-writer/` and `ATTRIBUTION.md`. This migration
+73 -3
View File
@@ -1,6 +1,6 @@
# Typing parity with Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** trivial (queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; the transactional equation delete proves its commit with the engine revision; a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; a tracked own-insertion shifts the rest of a two-sided IME deletion; AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
What Word does when you type, observed black-box (same keystrokes typed into Word and
WordCraft, results read back paragraph by paragraph) and pinned by tests in
@@ -288,8 +288,8 @@ again.
- An input method can ask to erase characters before and after the caret (`DeleteSurrounding`)
and send replacement text. The web build's hidden text field does this whenever its contents
change other than by adding to the end: Backspace and corrections from phone keyboards, and
the Chrome caret jump below. The desktop builds never receive it (egui-winit 0.36 doesn't
send it). WordCraft erases those characters as Backspace erases a selection, stopping at the
the Chrome caret jump below. The 0.37 desktop bridge can also deliver character- or
byte-counted surrounding deletions from the input method. WordCraft erases those characters as Backspace erases a selection, stopping at the
paragraph's ends, then types the new text. The new text keeps the erased text's formatting
and never overtypes what follows. Erasing and retyping stay in the open typing undo step, and
typing after them joins it too (#1480,
@@ -312,6 +312,74 @@ again.
and the caret and the candidate window sit at the input method's cursor inside it: the end of
the range it reports, or the end of the marked text when it reports none. The range is clamped
to the text (`canvas::tests::the_caret_follows_the_input_method_cursor_in_the_composition`).
- A composition belongs to the text it was started on. When a command replaces the body under
the same document id — Replace Document Text, a version restore, Compare, Undo/Redo — or opens
a new document, the body's replacement epoch advances, the platform clears the composition, and
any marked text is dropped. Replacing the text with an identical one still starts a new
context (`canvas::tests::ime_generation_changes_on_body_replacement_with_the_same_text`,
`ime_generation_changes_on_undo_and_version_restore`,
`keys::tests::observe_ime_drops_a_preedit_without_focus`). Undo/Redo also gives the equation
being edited a fresh session token, so its context follows the restored tree. The marked text
is never painted without focus.
- If replacing the body or changing the edited equation invalidates nonempty marked text,
the next canvas input batch rejects that composition's queued surrounding deletions and one
replacement commit before any engine mutation. An associated `Text` fallback is rejected too.
A fresh nonempty preedit or ordinary typing ends that batch's suppression; without an old
marked composition, direct commits remain eligible. A later commit after the rejected one,
or in a later canvas batch, is also fresh input. New regression checks cover identical-body
replacement, both deletion units, equation exit before or within the batch, unchanged text
and Undo history, and fresh input (`keys::tests::ime_interrupted_body_preedit_drops_queued_replacement_before_mutation`,
`ime_interrupted_equation_preedit_drops_queued_body_replacement`,
`ime_interrupted_preedit_does_not_suppress_fresh_input`). Their managed validation is pending;
this does not claim native input-method acceptance or identify untagged events across frames.
- An accepted IME commit earns an `InputMethod` receipt only when the engine inserted exactly the
committed text. A single quote the engine leaves as typed (smart quotes off) is the input
method's own change; a smart quote or AutoCorrect replacement is `Other`
(`ime_apostrophe_with_smart_quotes_off_is_an_input_method_change`,
`ime_apostrophe_with_smart_quotes_on_stays_other`,
`ime_literal_commit_after_autoformat_is_input_method`). A `DeleteSurrounding` whose two sides
cannot both be applied is rolled back whole and publishes no receipt
(`a_refused_second_delete_surrounding_rolls_the_request_back`); every such refusal also
interrupts the composition and clears the painted pre-edit and both replacement joins because
the platform's hidden text already changed
(`a_refused_body_delete_clears_preedit_and_joins`). Under Track Changes, deleting text this
author inserted shortens the stored paragraph, so the following range and caret are shifted by
the bytes actually removed (`tracked_delete_surrounding_shifts_past_its_own_insertion`,
`tracked_own_insertion_then_a_refused_side_rolls_the_whole_request_back`). Emptying a content
control restores its placeholder, which may make the paragraph longer; a body deletion maps the
following range and caret by the signed stored-length change, and refuses the whole transaction
when growth cannot be proven to come from a placeholder restored at or before the erased range
(`delete_surrounding_maps_after_text_and_caret_past_a_growing_placeholder`,
`delete_surrounding_maps_a_shrinking_placeholder_and_after_text`,
`delete_surrounding_maps_a_placeholder_restored_by_the_after_side`). Control markers are
zero-length visible objects, so a range start maps after the start marker and after the end
marker: an ordinary IME deletion never breaks a control
(`ime_text::tests::control_markers_are_not_included_in_delete_ranges`). The replacement's undo
join is scoped to the next `text.insert` at the selection the deletion left; an intervening
command owns its own step (`body_delete_join_does_not_leak_to_an_intervening_command`).
- Inside an equation, `DeleteSurrounding` erases the counted characters with the equation's own
transactional command (at most 64 units a side), refusing the whole request if any step cannot
delete — including a step that would only move the caret into another argument
(`equation_delete_surrounding_refuses_a_mixed_caret_only_step`); the delete and the following
commit are one undo group. Byte counts have no reversible mapping into the
structured tree, so the whole request is refused, the composition is interrupted and the
replacement the platform sends for that rejected deletion — the immediately adjacent `Commit`,
or an adjacent `Text` + `Commit` pair — is dropped instead of being inserted beside the
untouched equation (the pre-edit never lives in the model). Any later `Text`/`Commit` in the
same raw-input batch is fresh input and applies, as is a later direct commit without a pre-edit;
no persistent tombstone can swallow it. An over-cap request clears the painted pre-edit and any
earlier deletion's replacement join before refusing. The equation's surroundings stay explicitly
unavailable (`ime_equation_delete_is_one_input_method_change`,
`ime_equation_delete_and_commit_are_one_undo_step`,
`ime_equation_byte_delete_drops_only_its_immediate_replacement`,
`over_cap_equation_delete_clears_the_painted_preedit`,
`over_cap_equation_delete_clears_the_previous_replacement_join`). The engine's checked revision
on the unavailable stamp proves a successful content change; a refusal, an automatic rewrite or
an exhausted revision publishes no receipt (`ime_transformed_equation_commits_are_other`,
`exhausted_ime_revisions_never_certify_equation_or_wide_body_edits`).
- Pre-edit and commit sizes are bounded (the visible window at 4000 bytes, a pre-edit at the same
cap, a commit at 64 KiB): larger hostile events are ignored rather than painted or inserted
(`oversized_ime_events_are_ignored`).
## Known gaps
@@ -342,6 +410,8 @@ again.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | trivial | Input methods: reject queued old-composition replacements before mutation after body/equation target replacement (new regression validation pending); equation `DeleteSurrounding` is one transactional command whose checked revision proves a receipt; a caret-only step in a mixed request refuses the whole deletion, and an over-cap request clears the painted pre-edit and the previous replacement join; a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, never a later direct commit; a tracked own-insertion shifts the following range; a body deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its replacement undo join to the matching `text.insert`; refusals clear the painted pre-edit and joins; AutoCorrect/AutoFormat classification compares the paragraph as typed; equation replacement interrupts the composition |
| 2026-10-23 | trivial | Input methods: a replaced body (Replace Document Text, version restore, Compare, Undo/Redo) starts a new context and interrupts the composition; smart quotes off keeps an IME quote an `InputMethod` change; equation `DeleteSurrounding` erases counted characters; bounded pre-edit/commit sizes; a partly refused two-sided deletion is rolled back |
| 2026-10-22 | trivial | Input methods: while composing, the caret and the candidate window follow the input method's cursor inside the marked text |
| 2026-10-21 | trivial | Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459) |
| 2026-10-21 | trivial | List AutoFormat (`- `, `1. `, …) triggers at the start of an already-written paragraph, not only a fresh empty one; checked in Word for Windows (#3352) |
@@ -70,6 +70,6 @@ modules:
# The interface translations at the commit in craft-i18n.rev (a test keeps the two equal).
- type: git
url: https://github.com/storytold/craft-i18n
commit: 04600cbb1508f1b15927ec574a5e32e5f376fd8b
commit: e5bfe19b95d30f50f250985bdc3aeec47c05af09
dest: craft-i18n
- cargo-sources.json
-1
View File
@@ -129,7 +129,6 @@ if has appimage; then
}
APPDIR="$WORK/WordCraft.AppDir"
cp -R "$STAGE" "$APPDIR"
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
ln -s usr/bin/wordcraft "$APPDIR/AppRun"
cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop"
cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
+14
View File
@@ -93,6 +93,19 @@ Copy-Item (Join-Path $Bin 'wordcraft.exe'), (Join-Path $Bin 'wordcraft-cli.exe')
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'wordcraft.exe') (Join-Path $Stage 'wordcraft-cli.exe')
# ---- font notices for the MSI ------------------------------------------------------------------
# The portable ZIP already carries these notices; stage the complete files before MSI creation too.
$Docs = Join-Path $Stage 'font-licences'
New-Item -ItemType Directory -Force -Path $Docs | Out-Null
$HasCraftFontLicences = 0
if ($env:CRAFT_FONTS_DIR) {
foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) {
Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt")
$HasCraftFontLicences = 1
}
}
# ---- end font notices for the MSI --------------------------------------------------------------
# ---- MSI ---------------------------------------------------------------------------------------
# The installer's welcome page shows the license: build its RTF from LICENSE-MIT and LICENSE-APACHE
# so it never drifts from them. Hard-wrapped lines are rejoined into paragraphs so the text wraps
@@ -130,6 +143,7 @@ Invoke-Native 'wix build' {
-ext WixToolset.UI.wixext -ext WixToolset.Util.wixext `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" `
-d "LicenseRtf=$LicenseRtf" `
-d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" `
-o $Msi
}
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
+11
View File
@@ -16,6 +16,9 @@
already installed") or silently uninstall the other. packaging-lint.yml checks this.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<?ifndef HasCraftFontLicences ?>
<?define HasCraftFontLicences=0 ?>
<?endif ?>
<Package
Name="WordCraft"
Manufacturer="Learning Machines LLC"
@@ -60,11 +63,19 @@
<StandardDirectory Id="ProgramMenuFolder" />
<Feature Id="Main" Title="WordCraft" Level="1">
<?if $(var.HasCraftFontLicences) = 1 ?>
<ComponentGroupRef Id="CraftFontLicences" />
<?endif ?>
<ComponentGroupRef Id="WordcraftFiles" />
</Feature>
</Package>
<Fragment>
<?if $(var.HasCraftFontLicences) = 1 ?>
<ComponentGroup Id="CraftFontLicences" Directory="INSTALLFOLDER">
<Files Include="$(var.DocsDir)\**" />
</ComponentGroup>
<?endif ?>
<ComponentGroup Id="WordcraftFiles" Directory="INSTALLFOLDER">
<Component Id="WordcraftApp" Guid="855DCEC6-9285-444F-9382-9654B83B34F6">
<File Id="WordcraftExe" Source="$(var.BinDir)\wordcraft.exe" KeyPath="yes">