mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-02 16:30:05 -06:00
E37: migrate WordCraft to egui 0.37 with guarded IME and capture lifecycle
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+23
-6
@@ -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" }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
)))
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
04600cbb1508f1b15927ec574a5e32e5f376fd8b
|
||||
e5bfe19b95d30f50f250985bdc3aeec47c05af09
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(¶)
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ¶.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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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 }
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
+933
-180
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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:?})");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ¬es);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user