mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-05 17:59:58 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75961e749d |
+1
-20
@@ -7,30 +7,11 @@ updates:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Minor and patch updates share one PR. A breaking bump gets its own PR so one API change
|
||||
# doesn't hold the safe updates back (#2973). For cargo, Dependabot counts a 0.y bump of a
|
||||
# 0.x crate as major, as Cargo does. A dependency goes to the first group it matches, so the
|
||||
# crate families that must move together come before the catch-all.
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
egui:
|
||||
patterns:
|
||||
- "egui"
|
||||
- "egui_*"
|
||||
- "eframe"
|
||||
- "winit"
|
||||
fonts:
|
||||
patterns:
|
||||
- "skrifa"
|
||||
- "harfrust"
|
||||
hayro:
|
||||
patterns:
|
||||
- "hayro-*"
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
|
||||
Generated
-1
@@ -6824,7 +6824,6 @@ dependencies = [
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
|
||||
@@ -299,21 +299,10 @@ adapter. The log file records the choice (lines starting with
|
||||
RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+
|
||||
(Ubuntu 24.04, Debian 13 and newer).
|
||||
|
||||
**Read Aloud** (Review › Read Aloud) speaks through a speech program installed on the system: it
|
||||
tries `espeak-ng`, then `espeak`, and when neither is installed it talks to Speech Dispatcher.
|
||||
The `.rpm` package recommends `espeak-ng`; the `.deb` recommends `espeak-ng` or
|
||||
`speech-dispatcher`, so the package manager installs one of the two by default. With the
|
||||
AppImage or the tarball, install one yourself if neither is there (`sudo apt install espeak-ng`,
|
||||
`sudo dnf install espeak-ng`, `sudo pacman -S espeak-ng`). The Flatpak doesn't speak yet: it has
|
||||
no audio access and doesn't include `espeak-ng`, `espeak` or Speech Dispatcher.
|
||||
|
||||
**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because
|
||||
winit (the windowing library) supports dragging files into the window only on X11. To use the
|
||||
native Wayland backend instead (for example for sharper text with fractional scaling, at the cost
|
||||
of file drag-and-drop), choose **File › Options › Advanced › Linux display server › Wayland**; it
|
||||
applies the next time WordCraft starts, and **X11 (XWayland)** or **Automatic** switches back. The
|
||||
`WORDCRAFT_WAYLAND` environment variable overrides the setting: `1` for native Wayland, `0` for
|
||||
XWayland:
|
||||
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
|
||||
|
||||
```sh
|
||||
WORDCRAFT_WAYLAND=1 wordcraft
|
||||
|
||||
+3
-22
File diff suppressed because one or more lines are too long
@@ -430,12 +430,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
"convert" => {
|
||||
validate_options("convert", &rest, CONVERT_OPTIONS)?;
|
||||
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
|
||||
let format = match find_option_arg(&rest, "--format") {
|
||||
OptionArg::Absent => None,
|
||||
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
|
||||
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
|
||||
OptionArg::Value(value) => Some(value),
|
||||
};
|
||||
let format = arg_value(&rest, "--format");
|
||||
let paths = positionals(&rest, CONVERT_OPTIONS);
|
||||
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
|
||||
let markup = rest.iter().any(|a| a == "--markup");
|
||||
|
||||
@@ -75,22 +75,6 @@ fn unknown_options_are_rejected_before_file_access() {
|
||||
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_malformed_format_options_before_file_access() {
|
||||
for (options, error) in [
|
||||
(vec!["--format"], "convert: --format requires a value"),
|
||||
(vec!["--format", "--markup"], "convert: --format requires a value"),
|
||||
(vec!["--format", "flatopc", "--format", "wordml2003"], "convert: duplicate --format option"),
|
||||
] {
|
||||
let mut argv = args(&["convert", "missing.docx", "out.xml"]);
|
||||
argv.extend(args(&options));
|
||||
assert_eq!(run(&argv), Err(error.into()), "{options:?}");
|
||||
}
|
||||
// A valid single value reaches file access, rather than a format-option error.
|
||||
let error = run(&args(&["convert", "missing.docx", "out.xml", "--format", "flatopc"])).unwrap_err().to_string();
|
||||
assert!(error.starts_with("missing.docx:"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
|
||||
for cmd in SUBCOMMANDS {
|
||||
|
||||
@@ -38,14 +38,13 @@ fmv-macos-events = "=0.1.0"
|
||||
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
|
||||
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
|
||||
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
|
||||
# traffic lights, the window, button and view ones; and `NSPasteboard` for the clipboard's RTF.
|
||||
# traffic lights, the window, button and view ones.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
|
||||
objc2 = "0.6"
|
||||
# A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
|
||||
# arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
|
||||
# the build through arboard, at this version; this adds only the NSString bindings, and NSData
|
||||
# for the clipboard's RTF (`public.rtf`).
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData"] }
|
||||
# the build through arboard, at this version; this adds only the NSString bindings.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
|
||||
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
|
||||
# inside the AppKit run loop (see `schedule_macos_terminate`).
|
||||
dispatch2 = "0.3"
|
||||
@@ -59,9 +58,6 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
|
||||
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
|
||||
# `wayland-data-control` feature, at this version.
|
||||
wl-clipboard-rs = "0.9"
|
||||
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
|
||||
# through arboard, at this version.
|
||||
x11rb = "0.13"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
|
||||
|
||||
+15
-222
@@ -18,11 +18,6 @@
|
||||
//! they paste math (learn.microsoft.com/office/math/mathml, "Clipboard support"). Pasting into
|
||||
//! Word and LibreOffice is not yet verified. Linux and the BSDs have no Word, and arboard can't
|
||||
//! add MIME types of other names, so there the HTML carries the MathML alone.
|
||||
//!
|
||||
//! RTF goes beside them where we can add it: Windows ("Rich Text Format") and macOS
|
||||
//! (`public.rtf`) after arboard's HTML, Wayland (`text/rtf`) in one offer with the text and HTML.
|
||||
//! On X11 arboard owns the selection and serves only its own targets, so a copy there carries text
|
||||
//! and HTML only; reading another program's RTF works on all four.
|
||||
|
||||
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
@@ -36,24 +31,17 @@ thread_local! {
|
||||
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Put a copy on the clipboard: `text`, with `html` and `rtf` beside it where the platform takes
|
||||
/// them (an empty one isn't offered), and `mathml` (a copy of one equation alone; empty for any
|
||||
/// other copy) in the platform's MathML formats. `false` when the text and HTML couldn't be set
|
||||
/// (the caller copies the text alone), including a Wayland session without the data-control
|
||||
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
|
||||
/// RTF or MathML that can't be added is logged: the HTML still carries the copy.
|
||||
pub fn set_rich(text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
|
||||
// Wayland: one offer with the text, HTML and RTF (no MathML formats there; see above).
|
||||
if platform::set_rich(text, html, rtf) {
|
||||
return true;
|
||||
}
|
||||
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
|
||||
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
|
||||
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
|
||||
/// session without the data-control protocol, where arboard would fall back to X11 and native
|
||||
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
|
||||
/// carries it.
|
||||
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
|
||||
if !platform::can_set() {
|
||||
return false;
|
||||
}
|
||||
let set = set_html_text(html, text);
|
||||
if set && !rtf.is_empty() {
|
||||
platform::add_rtf(rtf);
|
||||
}
|
||||
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
|
||||
platform::add_mathml(mathml);
|
||||
}
|
||||
@@ -66,8 +54,7 @@ fn set_html_text(html: &str, text: &str) -> bool {
|
||||
if slot.is_none() {
|
||||
*slot = open();
|
||||
}
|
||||
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
|
||||
match set {
|
||||
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
|
||||
Some(Ok(())) => true,
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
@@ -89,8 +76,8 @@ pub fn html() -> Option<String> {
|
||||
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
|
||||
}
|
||||
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format"), macOS (`public.rtf`), Wayland and X11
|
||||
/// (`text/rtf`); not offered by arboard, so read here.
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
|
||||
/// arboard, so read here.
|
||||
pub fn rtf() -> Option<String> {
|
||||
let bytes = platform::rtf()?;
|
||||
if bytes.len() > MAX_PASTE_BYTES {
|
||||
@@ -189,51 +176,15 @@ mod platform {
|
||||
)
|
||||
}
|
||||
|
||||
/// Put the copy on a Wayland clipboard in one offer: the text (with the usual plain-text
|
||||
/// aliases), HTML and RTF, served from a background thread until something else is copied.
|
||||
/// `false` outside Wayland (X11 goes through arboard) or without the data-control protocol.
|
||||
pub fn set_rich(text: &str, html: &str, rtf: &str) -> bool {
|
||||
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
|
||||
|
||||
if !wayland() {
|
||||
return false;
|
||||
}
|
||||
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
|
||||
// The text first: the plain-text aliases serve the first text type offered.
|
||||
let mut sources = vec![offer("text/plain;charset=utf-8", text)];
|
||||
for (mime, data) in [("text/html", html), ("text/rtf", rtf)] {
|
||||
if !data.is_empty() {
|
||||
sources.push(offer(mime, data));
|
||||
}
|
||||
}
|
||||
match Options::new().copy_multi(sources) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wayland copies carry their RTF already; X11 ones can't (see the module docs).
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
/// RTF types a clipboard may offer, best first.
|
||||
const RTF_MIMES: [&str; 3] = ["text/rtf", "application/rtf", "text/richtext"];
|
||||
|
||||
/// The RTF type to ask for among those `offered`.
|
||||
fn rtf_target(offered: &[String]) -> Option<&'static str> {
|
||||
RTF_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))
|
||||
}
|
||||
|
||||
/// arboard serves its own MIME types only; Word, which reads the MathML formats, isn't here.
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
if !wayland() {
|
||||
return std::env::var_os("DISPLAY").is_some().then(|| x11::rtf(super::MAX_PASTE_BYTES)).flatten();
|
||||
return None;
|
||||
}
|
||||
let mime = rtf_target(&formats())?;
|
||||
let offered = formats();
|
||||
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
read(mime, super::MAX_PASTE_BYTES as u64)
|
||||
}
|
||||
|
||||
@@ -245,125 +196,6 @@ mod platform {
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
|
||||
}
|
||||
|
||||
/// Whether a property read of `len` bytes, with `bytes_after` left unread, is the whole value
|
||||
/// and at most `cap` bytes.
|
||||
fn complete(len: usize, bytes_after: u32, cap: usize) -> bool {
|
||||
bytes_after == 0 && len <= cap
|
||||
}
|
||||
|
||||
/// Reading another program's RTF on X11, which arboard doesn't offer: ask the CLIPBOARD
|
||||
/// selection's owner for its targets, then for its RTF, into a property of a hidden window.
|
||||
mod x11 {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, GetPropertyReply, Window, WindowClass};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
/// How long a paste waits for the selection's owner, in all.
|
||||
const TIMEOUT: Duration = Duration::from_secs(1);
|
||||
/// Most targets looked at.
|
||||
const MAX_TARGETS: usize = 128;
|
||||
|
||||
struct Reader {
|
||||
conn: RustConnection,
|
||||
win: Window,
|
||||
clipboard: Atom,
|
||||
prop: Atom,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
fn atom(conn: &RustConnection, name: &str) -> Option<Atom> {
|
||||
conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom)
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
fn new() -> Option<Self> {
|
||||
let (conn, screen) = x11rb::connect(None).ok()?;
|
||||
let root = conn.setup().roots.get(screen)?.root;
|
||||
let win = conn.generate_id().ok()?;
|
||||
conn.create_window(0, win, root, 0, 0, 1, 1, 0, WindowClass::INPUT_ONLY, x11rb::COPY_FROM_PARENT, &CreateWindowAux::new()).ok()?;
|
||||
let clipboard = atom(&conn, "CLIPBOARD")?;
|
||||
let prop = atom(&conn, "WORDCRAFT_PASTE")?;
|
||||
Some(Self { conn, win, clipboard, prop, deadline: Instant::now() + TIMEOUT })
|
||||
}
|
||||
|
||||
/// The selection converted to `target`, at most `cap` bytes; `None` when the owner
|
||||
/// refuses or doesn't answer in time. A longer value is `None` when `whole`, else
|
||||
/// its first `cap` bytes.
|
||||
fn convert(&self, target: Atom, cap: usize, whole: bool) -> Option<GetPropertyReply> {
|
||||
self.conn.convert_selection(self.win, self.clipboard, target, self.prop, x11rb::CURRENT_TIME).ok()?;
|
||||
self.conn.flush().ok()?;
|
||||
loop {
|
||||
// Checked every time round, so a stream of other events can't keep us here.
|
||||
if Instant::now() >= self.deadline {
|
||||
return None;
|
||||
}
|
||||
match self.conn.poll_for_event().ok()? {
|
||||
Some(Event::SelectionNotify(e)) if e.requestor == self.win => {
|
||||
if e.property == x11rb::NONE {
|
||||
return None;
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => std::thread::sleep(Duration::from_millis(5)),
|
||||
}
|
||||
}
|
||||
// In 32-bit units; for a whole value one more than the cap, so a larger one shows
|
||||
// as `bytes_after`.
|
||||
let units = u32::try_from(cap / 4 + usize::from(whole)).unwrap_or(u32::MAX);
|
||||
let r = self.conn.get_property(true, self.win, self.prop, AtomEnum::ANY, 0, units).ok()?.reply().ok()?;
|
||||
(!whole || super::complete(r.value.len(), r.bytes_after, cap)).then_some(r)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Reader {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.conn.destroy_window(self.win);
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The CLIPBOARD selection's RTF, at most `cap` bytes. Copies too large to send at once
|
||||
/// arrive in pieces (INCR), which aren't read: `None` then.
|
||||
pub fn rtf(cap: usize) -> Option<Vec<u8>> {
|
||||
let r = Reader::new()?;
|
||||
// The first targets only: a longer list is cut short, not refused.
|
||||
let targets = r.convert(atom(&r.conn, "TARGETS")?, MAX_TARGETS.saturating_mul(4), false)?;
|
||||
let cookies: Vec<_> = targets.value32()?.take(MAX_TARGETS).filter_map(|a| r.conn.get_atom_name(a).ok()).collect();
|
||||
let names: Vec<String> =
|
||||
cookies.into_iter().filter_map(|c| c.reply().ok()).map(|n| String::from_utf8_lossy(&n.name).into_owned()).collect();
|
||||
let target = atom(&r.conn, super::rtf_target(&names)?)?;
|
||||
let incr = atom(&r.conn, "INCR")?;
|
||||
let reply = r.convert(target, cap, true)?;
|
||||
(reply.type_ != incr).then_some(reply.value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rtf_target_prefers_text_rtf() {
|
||||
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(rtf_target(&names(&["TARGETS", "text/richtext", "application/rtf", "text/rtf"])), Some("text/rtf"));
|
||||
assert_eq!(rtf_target(&names(&["text/richtext", "UTF8_STRING"])), Some("text/richtext"));
|
||||
assert_eq!(rtf_target(&names(&["text/html", "text/rtf;charset=x"])), None);
|
||||
assert_eq!(rtf_target(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_reads_must_be_whole_and_capped() {
|
||||
assert!(complete(10, 0, 10));
|
||||
assert!(!complete(11, 0, 10), "over the cap");
|
||||
assert!(!complete(8, 4, 10), "more left to read");
|
||||
assert!(complete(0, 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -419,23 +251,6 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
/// arboard sets the text and HTML.
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Add "Rich Text Format" to what arboard just set, NUL-terminated as is usual.
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
let Some(format) = raw::register_format("Rich Text Format") else { return };
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return };
|
||||
let mut bytes = Vec::with_capacity(rtf.len().saturating_add(1));
|
||||
bytes.extend_from_slice(rtf.as_bytes());
|
||||
bytes.push(0);
|
||||
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
|
||||
log::warn!("clipboard RTF: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the MathML to what arboard just set, as "MathML" and "MathML Presentation": UTF-16LE
|
||||
/// text (with its terminating NUL, as Windows text formats have).
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
@@ -461,18 +276,14 @@ const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2_app_kit::NSPasteboard;
|
||||
use objc2_foundation::{NSData, NSString};
|
||||
|
||||
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
|
||||
const RTF: &str = "public.rtf";
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
let data = NSPasteboard::generalPasteboard().dataForType(&NSString::from_str(RTF))?;
|
||||
(data.len() <= super::MAX_PASTE_BYTES).then(|| data.to_vec())
|
||||
None
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
@@ -483,18 +294,6 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
/// arboard sets the text and HTML.
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Add RTF to the pasteboard item arboard just wrote (it cleared the pasteboard first).
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
if !NSPasteboard::generalPasteboard().setData_forType(Some(&NSData::with_bytes(rtf.as_bytes())), &NSString::from_str(RTF)) {
|
||||
log::warn!("clipboard: the RTF couldn't be added");
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the MathML to the pasteboard item arboard just wrote, as the MathML types (W3C MathML 3
|
||||
/// §6.2.4): presentation markup, and the generic type for readers that only know that one.
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
@@ -530,11 +329,5 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
}
|
||||
|
||||
+13
-73
@@ -253,45 +253,13 @@ fn load_user_fonts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved preferences, read before the window opens for its geometry and, on Linux and the
|
||||
/// BSDs, the display server (`load_prefs` runs after).
|
||||
fn saved_ui() -> Option<UiState> {
|
||||
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
|
||||
fn saved_window() -> Option<WindowGeometry> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(prefs_path()?).ok()?;
|
||||
UiState::from_json(&bytes)
|
||||
}
|
||||
|
||||
/// Whether the window runs through XWayland (X11) rather than native Wayland, and the log line
|
||||
/// saying why. `WORDCRAFT_WAYLAND` wins (`0`, `false`, `no` or `off` asks for X11, any other value
|
||||
/// for Wayland), then File › Options › Advanced › Linux display server (#2184). Automatic is X11
|
||||
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
|
||||
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
|
||||
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
|
||||
/// available falls back to the other instead of failing to start.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (bool, String) {
|
||||
use wordcraft_ui_egui::DisplayServer;
|
||||
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
|
||||
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
|
||||
Some(v) if matches!(v.to_string_lossy().trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off") => {
|
||||
(DisplayServer::X11, "WORDCRAFT_WAYLAND")
|
||||
}
|
||||
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
|
||||
None => (setting, "the setting"),
|
||||
};
|
||||
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
|
||||
let runs = if x11 {
|
||||
"X11"
|
||||
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
|
||||
"native Wayland"
|
||||
} else if set("DISPLAY") {
|
||||
"X11 (no Wayland)"
|
||||
} else {
|
||||
"neither (no Wayland or X server found)"
|
||||
};
|
||||
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
|
||||
UiState::from_json(&bytes)?.window?.sanitized()
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
@@ -344,7 +312,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
animations_enabled: Some(animations::hook(ctx)),
|
||||
clipboard_text: Some(Box::new(clipboard::text)),
|
||||
clipboard_html: Some(Box::new(clipboard::html)),
|
||||
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
||||
set_clipboard_html: Some(Box::new(clipboard::set_html)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
..Default::default()
|
||||
@@ -525,21 +493,19 @@ fn main() -> eframe::Result {
|
||||
load_user_fonts();
|
||||
// Windows opened for further documents start like the first one, before its saved place.
|
||||
let window_builder = options.viewport.clone();
|
||||
let saved = saved_ui();
|
||||
let restored = saved.as_ref().and_then(|ui| ui.window?.sanitized());
|
||||
let restored = saved_window();
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
|
||||
log::info!("{why}");
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
@@ -653,32 +619,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// #2184: `WORDCRAFT_WAYLAND` beats the setting, Automatic keeps XWayland where there's an X
|
||||
/// server, and a choice that isn't available falls back to the other.
|
||||
#[test]
|
||||
fn the_display_server_follows_the_variable_then_the_setting() {
|
||||
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
|
||||
let x11 = |setting, vars: &[(&str, &str)]| {
|
||||
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
|
||||
};
|
||||
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
|
||||
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
|
||||
assert!(x11(X11, &both));
|
||||
assert!(!x11(Wayland, &both));
|
||||
for (value, wants_x11) in [("1", false), ("", false), ("yes", false), ("0", true), (" Off ", true), ("FALSE", true), ("no", true)] {
|
||||
let vars = [both[0], both[1], ("WORDCRAFT_WAYLAND", value)];
|
||||
for setting in [Auto, Wayland, X11] {
|
||||
assert_eq!(x11(setting, &vars), wants_x11, "WORDCRAFT_WAYLAND={value:?} beats {setting:?}");
|
||||
}
|
||||
}
|
||||
// Without an X server X11 isn't forced: winit connects to Wayland instead of failing.
|
||||
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
|
||||
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
|
||||
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
|
||||
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
|
||||
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
|
||||
}
|
||||
|
||||
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
|
||||
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
|
||||
#[test]
|
||||
|
||||
@@ -162,8 +162,6 @@ sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
display_name = "Max Murphy"
|
||||
sync_github_name = false
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -120,57 +120,14 @@ fn attr_in(tag: &str) -> Option<String> {
|
||||
Some(unescape(rest.get(..end)?))
|
||||
}
|
||||
|
||||
fn unescape(mut s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
while let Some((text, rest)) = s.split_once('&') {
|
||||
out.push_str(text);
|
||||
if let Some(end) = rest.find(['&', ';'])
|
||||
&& rest.as_bytes()[end] == b';'
|
||||
{
|
||||
let entity = &rest[..end];
|
||||
let decoded = match entity {
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"amp" => Some('&'),
|
||||
_ => entity.strip_prefix('#').and_then(|n| {
|
||||
let value = if let Some(hex) = n.strip_prefix('x') {
|
||||
(!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u32::from_str_radix(hex, 16).ok()).flatten()
|
||||
} else {
|
||||
(!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then(|| n.parse::<u32>().ok()).flatten()
|
||||
}?;
|
||||
char::from_u32(value)
|
||||
.filter(|c| matches!(*c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}'))
|
||||
}),
|
||||
};
|
||||
if let Some(c) = decoded {
|
||||
out.push(c);
|
||||
s = &rest[end + 1..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push('&');
|
||||
s = rest;
|
||||
}
|
||||
out.push_str(s);
|
||||
out
|
||||
fn unescape(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn drop_down_entities_decode_once_including_xml_whitespace() {
|
||||
let xml = r#"<w:ffData><w:name w:val="Name"/><w:ddList><w:listEntry w:val="A B	C D😀&#10;<>"'"/></w:ddList></w:ffData>"#;
|
||||
let f = LegacyForm::parse(xml).unwrap();
|
||||
assert_eq!(f.name(), "Name");
|
||||
assert_eq!(f.shown_text(), Some("A\nB\tC\rD😀 <>\"'"));
|
||||
let malformed = r#"<w:ffData><w:ddList><w:listEntry w:val="���&#-1;&#xZZ;&unknown;&"/></w:ddList></w:ffData>"#;
|
||||
assert_eq!(LegacyForm::parse(malformed).unwrap().shown_text(), Some("���&#-1;&#xZZ;&unknown;&"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_boxes_text_fields_and_drop_downs() {
|
||||
let cb = |inner: &str| format!(r#"<w:ffData><w:name w:val="Box1"/><w:enabled/><w:checkBox>{inner}</w:checkBox></w:ffData>"#);
|
||||
|
||||
@@ -90,8 +90,8 @@ pub use settings::{
|
||||
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
|
||||
THEME_COLORS, Watermark, Zoom, ZoomFit,
|
||||
};
|
||||
pub use text_walk::count_words;
|
||||
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
|
||||
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
|
||||
|
||||
/// A document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -16,12 +16,6 @@ pub type Arg = Vec<MNode>;
|
||||
/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Most matrix columns kept (writers pad every row to the widest, so this bounds a row's cost).
|
||||
pub const MAX_MATRIX_COLS: usize = 64;
|
||||
|
||||
/// Most matrix rows kept.
|
||||
pub const MAX_MATRIX_ROWS: usize = 1000;
|
||||
|
||||
/// An equation's structure, plus what's needed to write it back.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Math {
|
||||
|
||||
@@ -19,25 +19,6 @@ fn word_count() {
|
||||
assert_eq!(count_words(" -- "), 0);
|
||||
}
|
||||
|
||||
/// #2797: the status bar's character counts, worded like the Word Count dialog's: characters
|
||||
/// with spaces leave the paragraph breaks out, characters without spaces leave every
|
||||
/// whitespace character out.
|
||||
#[test]
|
||||
fn character_counts() {
|
||||
assert_eq!(count_chars(""), 0);
|
||||
assert_eq!(count_chars("Hello"), 5);
|
||||
// Paragraph breaks join the paragraphs but aren't characters.
|
||||
assert_eq!(count_chars("Hello\nworld\r\n"), 10);
|
||||
assert_eq!(count_chars("a\tb"), 3, "a tab is a character");
|
||||
// Every kind of whitespace goes, spaces, tabs and (non-ASCII) spaces between words.
|
||||
assert_eq!(count_chars_no_spaces("Hello world"), 10);
|
||||
assert_eq!(count_chars_no_spaces("a\tb c\u{a0}d"), 4, "including a non-breaking space");
|
||||
assert_eq!(count_chars_no_spaces(" \n\r\t "), 0);
|
||||
// Accented letters and emoji count as one character each, as they are.
|
||||
assert_eq!(count_chars("café 🚀"), 6);
|
||||
assert_eq!(count_chars_no_spaces("café 🚀"), 5);
|
||||
}
|
||||
|
||||
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
|
||||
#[test]
|
||||
fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
|
||||
@@ -35,18 +35,6 @@ fn is_cjk_word(c: char) -> bool {
|
||||
) && c.is_alphanumeric()
|
||||
}
|
||||
|
||||
/// Characters in `s`, as the Word Count dialog counts them ("Characters (with spaces)"):
|
||||
/// paragraph breaks aren't characters (#2797).
|
||||
pub fn count_chars(s: &str) -> usize {
|
||||
s.chars().filter(|c| *c != '\n' && *c != '\r').count()
|
||||
}
|
||||
|
||||
/// Characters in `s` ignoring every whitespace character, as the Word Count dialog counts them
|
||||
/// ("Characters (no spaces)"; #2797).
|
||||
pub fn count_chars_no_spaces(s: &str) -> usize {
|
||||
s.chars().filter(|c| !c.is_whitespace()).count()
|
||||
}
|
||||
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
|
||||
|
||||
use wordcraft_doc::math::{
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps,
|
||||
ScriptKind, parse_linear,
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
|
||||
};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -182,10 +181,7 @@ fn char_prop(w: &mut W, name: &str, c: Option<char>) {
|
||||
|
||||
fn write_matrix(w: &mut W, n: &MNode, depth: usize) {
|
||||
let MNode::Matrix { rows, col_jc } = n else { return };
|
||||
// Every row is padded to the widest one, so a ragged matrix is clamped to the reader's
|
||||
// caps rather than written as rows × widest cells (#3643).
|
||||
let rows = rows.get(..rows.len().min(MAX_MATRIX_ROWS)).unwrap_or_default();
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).clamp(1, MAX_MATRIX_COLS);
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
|
||||
w.open("m:m", &[]);
|
||||
w.open("m:mPr", &[]);
|
||||
w.open("m:mcs", &[]);
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
//! A ragged equation matrix doesn't make the saved `document.xml` grow with rows × widest row
|
||||
//! (#3643): one wide row plus many empty rows used to be padded out to a full grid.
|
||||
|
||||
use wordcraft_doc::math::{MNode, MRun, Math};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
|
||||
#[test]
|
||||
fn ragged_matrix_save_stays_small() {
|
||||
let n = 5000;
|
||||
let mut rows = vec![vec![vec![MNode::Run(MRun::new("a"))]; n]];
|
||||
rows.extend(std::iter::repeat_with(Vec::new).take(n));
|
||||
let math = Math { nodes: vec![MNode::Matrix { rows, col_jc: Vec::new() }], ..Default::default() };
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, InlineObject::Equation { linear: String::new(), display: true, math }, &CharProps::default()).unwrap();
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![para_block(p)];
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let size = zip.by_name("word/document.xml").unwrap().size();
|
||||
assert!(size < 1_000_000, "document.xml is {size} bytes");
|
||||
}
|
||||
@@ -199,21 +199,16 @@ fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
|
||||
}
|
||||
|
||||
/// What Copy and Cut return: the clipboard's text, and the copy as HTML (equations as MathML,
|
||||
/// #816) and as RTF for the host to put on the system clipboard too, so other programs keep the
|
||||
/// formatting; and for a copy of one equation alone, its MathML (#1297).
|
||||
/// #816) for the host to put on the system clipboard too, so other programs keep the formatting;
|
||||
/// and for a copy of one equation alone, its MathML (#1297).
|
||||
pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
(s.clipboard_html, s.clipboard_rtf) = match &s.clipboard {
|
||||
Some(f) => {
|
||||
let d = f.to_document();
|
||||
(wordcraft_formats::html::export_fragment(&d), wordcraft_formats::rtf::export(&d))
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
s.clipboard_html = match &s.clipboard {
|
||||
Some(f) => wordcraft_formats::html::export_fragment(&f.to_document()),
|
||||
None => String::new(),
|
||||
};
|
||||
let mut r = json!({"text": s.clipboard_text});
|
||||
for (k, v) in [("html", &s.clipboard_html), ("rtf", &s.clipboard_rtf)] {
|
||||
if !v.is_empty() {
|
||||
r[k] = json!(v);
|
||||
}
|
||||
if !s.clipboard_html.is_empty() {
|
||||
r["html"] = json!(s.clipboard_html);
|
||||
}
|
||||
// A copy of one equation alone, as MathML for the clipboard formats equation editors read
|
||||
// ("MathML" on Windows, `public.mathml.presentation` on macOS; #1297). Word looks for them
|
||||
|
||||
@@ -4,16 +4,20 @@ pub(super) fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(b) = p::bool(v, "includeTextBoxes") {
|
||||
s.prefs.count_notes = b;
|
||||
}
|
||||
let stories = s.counted_stories();
|
||||
let stories = if s.prefs.count_notes { s.doc.counted_stories() } else { vec![StoryRef::Body] };
|
||||
// A selected picture, shape or text box isn't a text selection: count the document.
|
||||
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() { s.counted_text() } else { s.selected_text() };
|
||||
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
|
||||
} else {
|
||||
s.selected_text()
|
||||
};
|
||||
let words = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.story_word_count(*st)).sum()
|
||||
} else {
|
||||
wordcraft_doc::count_words(&text)
|
||||
};
|
||||
let chars = wordcraft_doc::count_chars(&text);
|
||||
let chars_no_spaces = wordcraft_doc::count_chars_no_spaces(&text);
|
||||
let chars = text.chars().filter(|c| *c != '\n' && *c != '\r').count();
|
||||
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
|
||||
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
|
||||
let lines: usize = {
|
||||
let l = s.layout();
|
||||
|
||||
@@ -71,9 +71,6 @@ pub struct Session {
|
||||
/// HTML for the system clipboard beside [`Self::clipboard_text`]: the copy's formatting for
|
||||
/// programs that read HTML, equations as MathML (#816); empty when nothing was copied.
|
||||
pub clipboard_html: String,
|
||||
/// RTF for the system clipboard beside [`Self::clipboard_html`], for programs that read RTF
|
||||
/// rather than HTML; empty when nothing was copied.
|
||||
pub clipboard_rtf: String,
|
||||
/// Items collected by Copy and Cut this session, for the Clipboard pane.
|
||||
pub clip_history: crate::cmd::edit::ClipHistory,
|
||||
pub find: FindState,
|
||||
|
||||
@@ -244,7 +244,6 @@ impl Session {
|
||||
clipboard: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clipboard_rtf: _,
|
||||
clip_history: _,
|
||||
registry: _,
|
||||
author: _,
|
||||
|
||||
@@ -98,7 +98,6 @@ impl Session {
|
||||
pending_move,
|
||||
clipboard_text: String::new(),
|
||||
clipboard_html: String::new(),
|
||||
clipboard_rtf: String::new(),
|
||||
clip_history: Default::default(),
|
||||
find,
|
||||
goal_x,
|
||||
|
||||
@@ -268,18 +268,6 @@ impl Session {
|
||||
if self.prefs.count_notes { self.doc.word_count_including_notes() } else { self.doc.word_count() }
|
||||
}
|
||||
|
||||
/// The stories the Word Count dialog and the status bar count: the body plus notes and text
|
||||
/// boxes when [`Prefs::count_notes`] is on ([`Document::counted_stories`]).
|
||||
pub fn counted_stories(&self) -> Vec<StoryRef> {
|
||||
if self.prefs.count_notes { self.doc.counted_stories() } else { vec![StoryRef::Body] }
|
||||
}
|
||||
|
||||
/// The whole document's text the counts cover (#2797): the stories [`Session::counted_stories`]
|
||||
/// names, joined with paragraph breaks, as the Word Count dialog counts them.
|
||||
pub fn counted_text(&self) -> String {
|
||||
self.counted_stories().iter().map(|st| self.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// Selected plain text.
|
||||
pub fn selected_text(&self) -> String {
|
||||
// A column block or whole cells that aren't one run of the text: each piece's own text,
|
||||
|
||||
@@ -80,7 +80,6 @@ impl Session {
|
||||
pending_move: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clipboard_rtf: _,
|
||||
clip_history: _,
|
||||
find: _,
|
||||
goal_x: _,
|
||||
|
||||
+11
-141
@@ -10,15 +10,12 @@
|
||||
//! letters (Cyrillic, Greek, Arabic, Hebrew, CJK, Thai…), so a Russian sentence gets a Russian voice
|
||||
//! where one is installed instead of being spelled out letter by letter. The system's own voice
|
||||
//! stays whenever it already speaks the language (English text on an English system keeps the
|
||||
//! voice the user chose), and without a voice for the language the default voice speaks. Speech
|
||||
//! Dispatcher is the exception: it can't be asked for its default language, so it is told the
|
||||
//! language of every sentence.
|
||||
//! voice the user chose), and without a voice for the language the default voice speaks.
|
||||
//!
|
||||
//! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak-ng`
|
||||
//! / `espeak` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current sentence,
|
||||
//! or talks to Speech Dispatcher over its socket when neither espeak is installed, and moves on when
|
||||
//! the sentence ends. Commands only change the shared state and wake the worker, which stops the
|
||||
//! running sentence when the state changed under it.
|
||||
//! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak`
|
||||
//! / `spd-say` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current
|
||||
//! sentence, and moves on when it finishes. Commands only change the shared state and wake the
|
||||
//! worker, which stops the running sentence when the state changed under it.
|
||||
|
||||
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
|
||||
|
||||
@@ -405,90 +402,6 @@ fn mac_default_voice_language() -> &'static str {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
mod ssip;
|
||||
|
||||
/// What Read Aloud says when the system has none of the speech programs it knows.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MISSING_PROGRAM: &str = if cfg!(all(unix, not(target_os = "macos"))) {
|
||||
"Read Aloud needs a speech program: install espeak-ng or Speech Dispatcher from your distribution's packages"
|
||||
} else {
|
||||
"Read Aloud couldn't find the system's speech program"
|
||||
};
|
||||
|
||||
/// Read Aloud's speed (1 = normal) as Speech Dispatcher's rate, -100 to 100 with 0 the voice's
|
||||
/// normal speed: the slowest speed is -50 and the fastest 100.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
fn spd_rate(rate: f32) -> i32 {
|
||||
let rate = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 };
|
||||
let scaled = if rate >= 1.0 { (rate - 1.0) / (MAX_RATE - 1.0) * 100.0 } else { (rate - 1.0) * 100.0 };
|
||||
scaled.round().clamp(-100.0, 100.0) as i32
|
||||
}
|
||||
|
||||
/// Say one sentence through Speech Dispatcher ([`ssip`]), in the sentence's language whenever it
|
||||
/// has one, region included (its voices are per region: `en-GB`, `pt-BR`). Its default language is
|
||||
/// its own setting (`en-US` unless configured), which SSIP can't ask for, so English is named too.
|
||||
/// `None` when Speech Dispatcher isn't installed.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
fn speak_dispatcher(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Option<Spoken> {
|
||||
let stream = match ssip::connect(changed) {
|
||||
ssip::Connection::Open(s) => s,
|
||||
ssip::Connection::Missing => return None,
|
||||
ssip::Connection::Interrupted => return Some(Spoken::Interrupted),
|
||||
ssip::Connection::Unreachable(why) => return Some(Spoken::Failed(format!("Read Aloud couldn't reach Speech Dispatcher: {why}"))),
|
||||
};
|
||||
let lang = dispatcher_language(lang);
|
||||
let req = ssip::Request { text, rate: spd_rate(rate), lang: lang.as_deref() };
|
||||
Some(dispatcher_spoken(ssip::converse(&stream, &req, changed)))
|
||||
}
|
||||
|
||||
/// How a sentence Speech Dispatcher handled ended, for the worker.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
fn dispatcher_spoken(outcome: std::io::Result<ssip::Outcome>) -> Spoken {
|
||||
match outcome {
|
||||
Ok(ssip::Outcome::Done) => Spoken::Done,
|
||||
Ok(ssip::Outcome::Interrupted) => Spoken::Interrupted,
|
||||
Ok(ssip::Outcome::Halted) => Spoken::Halted(HALTED_BY_OTHER.into()),
|
||||
Ok(ssip::Outcome::NotStarted) => Spoken::Halted(NOT_STARTED.into()),
|
||||
Err(e) => Spoken::Failed(format!("Read Aloud couldn't use Speech Dispatcher: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why reading paused when another program cancelled or paused the sentence.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
const HALTED_BY_OTHER: &str =
|
||||
"Speech Dispatcher kept stopping the sentence for another program (a screen reader, or a stop for every program): press Play to go on";
|
||||
|
||||
/// Why reading paused when Speech Dispatcher never began the sentence.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
const NOT_STARTED: &str =
|
||||
"Speech Dispatcher didn't start the sentence (other speech may be queued before it, or its speech module may be stuck): press Play to try again";
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
thread_local! {
|
||||
/// Neither espeak command was found during this reading (the worker thread lives as long as
|
||||
/// one reading): the next sentences go straight to Speech Dispatcher.
|
||||
static NO_ESPEAK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The language tag Speech Dispatcher is given for a sentence in `lang`: its language and region,
|
||||
/// as SSIP's `SET LANGUAGE` takes them (`sr-Latn-RS` gives `sr-RS`, `zh-Hant-TW` gives `zh-TW`),
|
||||
/// when it names a language (Word's `x-none` doesn't).
|
||||
#[cfg(any(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")), test))]
|
||||
fn dispatcher_language(lang: Option<&str>) -> Option<String> {
|
||||
let tag = lang.and_then(plain_tag)?;
|
||||
let primary = primary_tag(tag)?;
|
||||
let region = tag
|
||||
.split('-')
|
||||
.skip(1)
|
||||
.take(2)
|
||||
.find(|t| (t.len() == 2 && t.chars().all(|c| c.is_ascii_alphabetic())) || (t.len() == 3 && t.chars().all(|c| c.is_ascii_digit())));
|
||||
Some(match region {
|
||||
Some(r) => format!("{primary}-{}", r.to_ascii_uppercase()),
|
||||
None => primary,
|
||||
})
|
||||
}
|
||||
|
||||
/// Should `speak` say the sentence again with the default voice? When it gave the program a voice
|
||||
/// and the program then exited with a failure: espeak-ng does that, silently, for a voice it
|
||||
/// doesn't know, and a missing voice must never mean silence. `exited_ok` is `None` when the
|
||||
@@ -658,10 +571,6 @@ struct Shared {
|
||||
/// A worker thread is running (cleared under the lock as it exits).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
running: bool,
|
||||
/// Bumped when a reading starts: a worker thread can outlive a reading (a pause, then a new
|
||||
/// Read Aloud), and what it learnt about the system's speech programs is per reading.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
readings: u64,
|
||||
}
|
||||
|
||||
/// The Read Aloud player.
|
||||
@@ -682,16 +591,7 @@ impl Default for ReadAloud {
|
||||
let off = cfg!(test) || std::env::var("WORDCRAFT_SPEECH").is_ok_and(|v| v == "off");
|
||||
ReadAloud {
|
||||
shared: Arc::new((
|
||||
Mutex::new(Shared {
|
||||
queue: Vec::new(),
|
||||
index: 0,
|
||||
state: State::Stopped,
|
||||
rate: 1.0,
|
||||
generation: 0,
|
||||
error: None,
|
||||
running: false,
|
||||
readings: 0,
|
||||
}),
|
||||
Mutex::new(Shared { queue: Vec::new(), index: 0, state: State::Stopped, rate: 1.0, generation: 0, error: None, running: false }),
|
||||
Condvar::new(),
|
||||
)),
|
||||
backend: if off { Backend::Hold } else { Backend::System },
|
||||
@@ -727,7 +627,6 @@ impl ReadAloud {
|
||||
s.queue = queue;
|
||||
s.index = 0;
|
||||
s.error = None;
|
||||
s.readings = s.readings.wrapping_add(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -741,7 +640,6 @@ impl ReadAloud {
|
||||
if s.index >= s.queue.len() {
|
||||
s.index = 0;
|
||||
}
|
||||
s.error = None;
|
||||
State::Playing
|
||||
}
|
||||
};
|
||||
@@ -855,18 +753,14 @@ impl Drop for ReadAloud {
|
||||
enum Spoken {
|
||||
Done,
|
||||
Interrupted,
|
||||
/// Another program stopped the sentence: reading pauses on it, saying why.
|
||||
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
|
||||
Halted(String),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
let (m, cv) = shared;
|
||||
let mut reading = None;
|
||||
loop {
|
||||
let (text, lang, generation, rate, readings) = {
|
||||
let (text, lang, generation, rate) = {
|
||||
let mut s = lock(m);
|
||||
while s.state == State::Paused {
|
||||
s = cv.wait(s).unwrap_or_else(PoisonError::into_inner);
|
||||
@@ -876,7 +770,7 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
return;
|
||||
}
|
||||
match s.queue.get(s.index) {
|
||||
Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate, s.readings),
|
||||
Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate),
|
||||
None => {
|
||||
s.state = State::Stopped;
|
||||
s.running = false;
|
||||
@@ -884,12 +778,6 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
}
|
||||
}
|
||||
};
|
||||
// A new reading asks the system afresh which speech programs it has.
|
||||
if reading != Some(readings) {
|
||||
reading = Some(readings);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
NO_ESPEAK.set(false);
|
||||
}
|
||||
let changed = || lock(m).generation != generation;
|
||||
let outcome = match backend {
|
||||
Backend::System => speak(&text, lang.as_deref(), rate, &changed),
|
||||
@@ -916,10 +804,6 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
}
|
||||
}
|
||||
Spoken::Interrupted => {}
|
||||
Spoken::Halted(why) => {
|
||||
s.state = State::Paused;
|
||||
s.error = Some(why);
|
||||
}
|
||||
Spoken::Failed(e) => {
|
||||
s.state = State::Stopped;
|
||||
s.error = Some(e);
|
||||
@@ -1022,8 +906,7 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let programs: &[&str] = if NO_ESPEAK.get() { &[] } else { &["espeak-ng", "espeak"] };
|
||||
for prog in programs {
|
||||
for prog in ["espeak-ng", "espeak"] {
|
||||
let args = espeak_args(&wpm, lang);
|
||||
let voiced = args.iter().any(|a| a == "-v");
|
||||
let mut c = Command::new(prog);
|
||||
@@ -1033,7 +916,6 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b
|
||||
}
|
||||
let _ = (&wpm, lang);
|
||||
let mut child = None;
|
||||
let mut not_found = true;
|
||||
let mut last_err = String::from("no speech program on this system");
|
||||
for (mut c, voiced) in tries {
|
||||
match c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() {
|
||||
@@ -1041,23 +923,11 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b
|
||||
child = Some((ch, voiced));
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
not_found &= e.kind() == std::io::ErrorKind::NotFound;
|
||||
last_err = e.to_string();
|
||||
}
|
||||
Err(e) => last_err = e.to_string(),
|
||||
}
|
||||
}
|
||||
let Some((mut child, voiced)) = child else {
|
||||
// Speech Dispatcher, which many desktops install without the espeak command.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if not_found {
|
||||
NO_ESPEAK.set(true);
|
||||
if let Some(spoken) = speak_dispatcher(text, lang, rate, changed) {
|
||||
return (spoken, false, None);
|
||||
}
|
||||
}
|
||||
let message = if not_found { MISSING_PROGRAM.to_string() } else { format!("Read Aloud couldn't start the speech program: {last_err}") };
|
||||
return (Spoken::Failed(message), false, None);
|
||||
return (Spoken::Failed(format!("Read Aloud couldn't start the speech program: {last_err}")), false, None);
|
||||
};
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = stdin.write_all(text.as_bytes());
|
||||
|
||||
@@ -1,927 +0,0 @@
|
||||
//! Speech Dispatcher through its own protocol (SSIP) on the user's socket, for systems that have
|
||||
//! Speech Dispatcher but not the `espeak-ng` command.
|
||||
//!
|
||||
//! Talking SSIP directly, instead of starting `spd-say`, keeps two promises `spd-say` can't: the
|
||||
//! sentence is always data (pipe mode runs a line starting with `!-!` as an SSIP command, and a
|
||||
//! document must never send commands), and stopping cancels only WordCraft's own speech
|
||||
//! (`CANCEL self`), where `spd-say -C` would silence every client, a screen reader included.
|
||||
//!
|
||||
//! One connection speaks one sentence: it names the client, asks for the begin, end, cancel and
|
||||
//! pause events, sets the rate and language, sends the text, and waits for the sentence's end
|
||||
//! event. A sentence another client's message cancels (a screen reader's announcement) is sent
|
||||
//! again after a pause, twice at most; a sentence cancelled once more or paused by another
|
||||
//! client halts reading on it ([`Outcome::Halted`]), and so does one that never began
|
||||
//! ([`Outcome::NotStarted`]).
|
||||
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often a wait for the end event looks at whether reading moved on.
|
||||
const POLL: Duration = Duration::from_millis(30);
|
||||
/// Longest wait for a reply to a command (the server answers at once; this only bounds a stuck one).
|
||||
const REPLY_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(5) };
|
||||
/// Longest reply line kept (a server sends short lines; this bounds a broken one).
|
||||
const MAX_LINE: usize = 4096;
|
||||
/// Speech Dispatcher's TCP port when `SPEECHD_ADDRESS` names none.
|
||||
const DEFAULT_PORT: u16 = 6560;
|
||||
/// How long a freshly started server gets to open its socket (it loads its speech modules first).
|
||||
const START_WAIT: Duration = Duration::from_secs(5);
|
||||
/// Longest wait for a TCP connection to one address.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
/// The end of a sentence is awaited this long after it began, plus [`SENTENCE_PER_CHAR`] for each
|
||||
/// character: far more than the slowest reading takes, so only a stuck server reaches it.
|
||||
const SENTENCE_BASE: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(30) };
|
||||
const SENTENCE_PER_CHAR: Duration = Duration::from_millis(400);
|
||||
/// Longest wait for a queued sentence to begin (behind other clients' speech, or paused before it
|
||||
/// began, which sends no pause event).
|
||||
const QUEUE_WAIT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(120) };
|
||||
/// The pauses before a sentence another client cancelled is sent again: the other client's
|
||||
/// message gets time to end, and the server holds ours back while a higher-priority one speaks.
|
||||
const RESEND_AFTER: [Duration; 2] =
|
||||
if cfg!(test) { [Duration::from_millis(50), Duration::from_millis(50)] } else { [Duration::from_secs(1), Duration::from_secs(3)] };
|
||||
|
||||
/// What to say and how.
|
||||
pub(super) struct Request<'a> {
|
||||
pub text: &'a str,
|
||||
/// Speech Dispatcher's rate, -100 to 100.
|
||||
pub rate: i32,
|
||||
/// A plain language tag (`hu`, `pt-BR`); anything else is left out.
|
||||
pub lang: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// How a sentence ended.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum Outcome {
|
||||
/// Spoken to the end.
|
||||
Done,
|
||||
/// Reading moved on: the sentence was cancelled.
|
||||
Interrupted,
|
||||
/// Another client stopped it: cancelled it again after it was sent again, or paused it.
|
||||
/// Reading halts on it instead of fighting the other client.
|
||||
Halted,
|
||||
/// The sentence never began: queued too long, behind other clients' speech, paused before it
|
||||
/// began (which sends no event), or with a speech module that doesn't start. Reading halts on it.
|
||||
NotStarted,
|
||||
}
|
||||
|
||||
/// Where Speech Dispatcher listens.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum Address {
|
||||
Unix(PathBuf),
|
||||
/// Host and port.
|
||||
Inet(String, u16),
|
||||
}
|
||||
|
||||
/// The server's address, found the way Speech Dispatcher's own client library finds it:
|
||||
/// `SPEECHD_ADDRESS` when set (`unix_socket[:path]`, `inet_socket[:host[:port]]`), else the socket
|
||||
/// `speech-dispatcher/speechd.sock` in the runtime folder, else in the cache folder
|
||||
/// (`XDG_CACHE_HOME`, else `~/.cache`). `None` for an address method it doesn't know, a port that
|
||||
/// isn't a number, or no folder to look in.
|
||||
pub(super) fn address(spec: Option<&str>, runtime_dir: Option<PathBuf>, cache_home: Option<PathBuf>, home: Option<PathBuf>) -> Option<Address> {
|
||||
let default_socket = || {
|
||||
let set = |p: Option<PathBuf>| p.filter(|p| !p.as_os_str().is_empty());
|
||||
set(runtime_dir)
|
||||
.or_else(|| set(cache_home))
|
||||
.or_else(|| set(home).map(|h| h.join(".cache")))
|
||||
.map(|d| d.join("speech-dispatcher").join("speechd.sock"))
|
||||
};
|
||||
let Some(spec) = spec.map(str::trim).filter(|a| !a.is_empty()) else { return default_socket().map(Address::Unix) };
|
||||
let (method, rest) = spec.split_once(':').unwrap_or((spec, ""));
|
||||
match method {
|
||||
"unix_socket" => Some(Address::Unix(if rest.is_empty() { default_socket()? } else { PathBuf::from(rest) })),
|
||||
"inet_socket" => {
|
||||
let mut p = rest.split(':');
|
||||
let host = p.next().filter(|h| !h.is_empty()).unwrap_or("127.0.0.1");
|
||||
let port = p.next().filter(|p| !p.is_empty()).map_or(Some(DEFAULT_PORT), |p| p.parse().ok())?;
|
||||
Some(Address::Inet(host.to_string(), port))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `host` this machine (the only place a server may be started)?
|
||||
fn is_local(host: &str) -> bool {
|
||||
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
|
||||
}
|
||||
|
||||
/// A connection to Speech Dispatcher.
|
||||
pub(super) enum Stream {
|
||||
Unix(UnixStream),
|
||||
Tcp(TcpStream),
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
fn try_clone(&self) -> std::io::Result<Stream> {
|
||||
Ok(match self {
|
||||
Stream::Unix(s) => Stream::Unix(s.try_clone()?),
|
||||
Stream::Tcp(s) => Stream::Tcp(s.try_clone()?),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads give up after [`POLL`] (so a wait can look at whether reading moved on), writes after
|
||||
/// [`REPLY_TIMEOUT`].
|
||||
fn set_timeouts(&self) -> std::io::Result<()> {
|
||||
match self {
|
||||
Stream::Unix(s) => {
|
||||
s.set_read_timeout(Some(POLL))?;
|
||||
s.set_write_timeout(Some(REPLY_TIMEOUT))
|
||||
}
|
||||
Stream::Tcp(s) => {
|
||||
s.set_read_timeout(Some(POLL))?;
|
||||
s.set_write_timeout(Some(REPLY_TIMEOUT))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for Stream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Stream::Unix(s) => s.read(buf),
|
||||
Stream::Tcp(s) => s.read(buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Stream {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Stream::Unix(s) => s.write(buf),
|
||||
Stream::Tcp(s) => s.write(buf),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
match self {
|
||||
Stream::Unix(s) => s.flush(),
|
||||
Stream::Tcp(s) => s.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to `address`; between the addresses a host name resolves to, give up once `changed()`.
|
||||
fn open(address: &Address, changed: &dyn Fn() -> bool) -> std::io::Result<Stream> {
|
||||
match address {
|
||||
Address::Unix(path) => UnixStream::connect(path).map(Stream::Unix),
|
||||
Address::Inet(host, port) => {
|
||||
let mut last = std::io::Error::from(ErrorKind::NotFound);
|
||||
for a in (host.as_str(), *port).to_socket_addrs()? {
|
||||
if changed() {
|
||||
return Err(moved_on());
|
||||
}
|
||||
match TcpStream::connect_timeout(&a, CONNECT_TIMEOUT) {
|
||||
Ok(s) => return Ok(Stream::Tcp(s)),
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How [`connect`] went.
|
||||
pub(super) enum Connection {
|
||||
Open(Stream),
|
||||
/// Speech Dispatcher isn't installed.
|
||||
Missing,
|
||||
/// It is installed but can't be reached: why.
|
||||
Unreachable(String),
|
||||
/// Reading moved on while the server was starting.
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
/// Connect to the user's Speech Dispatcher, starting it once if it isn't running (as `spd-say`
|
||||
/// does), unless the address is another machine's.
|
||||
pub(super) fn connect(changed: &dyn Fn() -> bool) -> Connection {
|
||||
let env = |k: &str| std::env::var_os(k).map(PathBuf::from);
|
||||
let spec = std::env::var("SPEECHD_ADDRESS").ok().filter(|s| !s.trim().is_empty());
|
||||
let Some(address) = address(spec.as_deref(), env("XDG_RUNTIME_DIR"), env("XDG_CACHE_HOME"), env("HOME")) else {
|
||||
return if spec.is_some() { Connection::Unreachable("SPEECHD_ADDRESS isn't an address it understands".into()) } else { Connection::Missing };
|
||||
};
|
||||
match open(&address, changed) {
|
||||
Ok(s) => return Connection::Open(s),
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => return Connection::Interrupted,
|
||||
Err(_) => {}
|
||||
}
|
||||
let mut start = std::process::Command::new("speech-dispatcher");
|
||||
start.arg("--spawn");
|
||||
// A server started for an address of its own must open it there.
|
||||
match (&address, spec.is_some()) {
|
||||
(_, false) => {}
|
||||
(Address::Unix(path), true) => {
|
||||
start.args(["--communication-method", "unix_socket", "--socket-path"]).arg(path);
|
||||
}
|
||||
(Address::Inet(host, port), true) if is_local(host) => {
|
||||
start.args(["--communication-method", "inet_socket", "--port", &port.to_string()]);
|
||||
}
|
||||
(Address::Inet(..), true) => return Connection::Unreachable("nothing answers at SPEECHD_ADDRESS".into()),
|
||||
}
|
||||
// `--spawn` exits once it has forked the server; the wait for it is still bounded and gives way
|
||||
// to Stop, so a start that hangs can't keep the reading thread (and Read Aloud with it) busy.
|
||||
let until = Instant::now() + START_WAIT;
|
||||
match start_server(&mut start, until, changed) {
|
||||
Started::Exited(true) => {}
|
||||
Started::Exited(false) => {
|
||||
return Connection::Unreachable("it isn't running and didn't start (its configuration may turn starting on demand off)".into());
|
||||
}
|
||||
Started::Missing => return Connection::Missing,
|
||||
Started::Failed(e) => return Connection::Unreachable(format!("it couldn't be started: {e}")),
|
||||
Started::Interrupted => return Connection::Interrupted,
|
||||
Started::TimedOut => {
|
||||
return open(&address, changed).map_or_else(|_| Connection::Unreachable("starting it didn't finish in time".into()), Connection::Open);
|
||||
}
|
||||
}
|
||||
// The server opens its socket a moment after it starts.
|
||||
loop {
|
||||
if changed() {
|
||||
return Connection::Interrupted;
|
||||
}
|
||||
if let Ok(s) = open(&address, changed) {
|
||||
return Connection::Open(s);
|
||||
}
|
||||
if Instant::now() >= until {
|
||||
return Connection::Unreachable("it started but doesn't answer".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// How running the server's start command went.
|
||||
#[derive(Debug)]
|
||||
enum Started {
|
||||
/// It exited, successfully or not.
|
||||
Exited(bool),
|
||||
/// The command isn't installed.
|
||||
Missing,
|
||||
/// It couldn't be run or waited for.
|
||||
Failed(std::io::Error),
|
||||
/// Reading moved on before it exited; it was stopped.
|
||||
Interrupted,
|
||||
/// It was still running at the deadline; it was stopped.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Run `command` (output discarded) and wait for it to exit, at most until `until` and only while
|
||||
/// reading stays put; a command still running then is stopped and reaped.
|
||||
fn start_server(command: &mut std::process::Command, until: Instant, changed: &dyn Fn() -> bool) -> Started {
|
||||
use std::process::Stdio;
|
||||
let mut child = match command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() {
|
||||
Ok(child) => child,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => return Started::Missing,
|
||||
Err(e) => return Started::Failed(e),
|
||||
};
|
||||
let stopped = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return Started::Exited(status.success()),
|
||||
Ok(None) if changed() => break Started::Interrupted,
|
||||
Ok(None) if Instant::now() >= until => break Started::TimedOut,
|
||||
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
|
||||
Err(e) => break Started::Failed(e),
|
||||
}
|
||||
};
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
stopped
|
||||
}
|
||||
|
||||
/// The sentence as SSIP message data: one line (a line break is a space when read aloud anyway),
|
||||
/// a leading `.` doubled so it can't end the message, and the terminating `.` line.
|
||||
pub(super) fn message_data(text: &str) -> String {
|
||||
let line: String = text.chars().map(|c| if c == '\r' || c == '\n' { ' ' } else { c }).collect();
|
||||
let dot = if line.starts_with('.') { "." } else { "" };
|
||||
format!("{dot}{line}\r\n.\r\n")
|
||||
}
|
||||
|
||||
/// Reads reply lines. Bytes wait in `pending` until their line is complete, so a character split
|
||||
/// across reads stays whole, and a line longer than [`MAX_LINE`] is an error, never a growing buffer.
|
||||
struct Lines<R> {
|
||||
reader: R,
|
||||
pending: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<R: Read> Lines<R> {
|
||||
fn new(reader: R) -> Self {
|
||||
Lines { reader, pending: Vec::new() }
|
||||
}
|
||||
|
||||
/// The next complete line without its line ending, or `Ok(None)` when none is complete after
|
||||
/// one read (a timeout, or part of a line): the caller looks at its deadline and whether reading
|
||||
/// moved on between calls, however slowly the bytes trickle in.
|
||||
fn next(&mut self) -> std::io::Result<Option<String>> {
|
||||
if let Some(line) = self.complete_line() {
|
||||
return Ok(Some(line));
|
||||
}
|
||||
if self.pending.len() > MAX_LINE {
|
||||
return Err(ErrorKind::InvalidData.into());
|
||||
}
|
||||
let mut buf = [0u8; 512];
|
||||
match self.reader.read(&mut buf) {
|
||||
Ok(0) => Err(ErrorKind::UnexpectedEof.into()),
|
||||
Ok(n) => {
|
||||
self.pending.extend_from_slice(buf.get(..n).unwrap_or_default());
|
||||
Ok(self.complete_line())
|
||||
}
|
||||
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first complete line in `pending`, taken out of it.
|
||||
fn complete_line(&mut self) -> Option<String> {
|
||||
let end = self.pending.iter().position(|&b| b == b'\n')?;
|
||||
let line: Vec<u8> = self.pending.drain(..=end).collect();
|
||||
Some(String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The three-digit code of a final reply line (`225 OK MESSAGE QUEUED`); `None` for a
|
||||
/// continuation line (`225-12`).
|
||||
fn final_code(line: &str) -> Option<u16> {
|
||||
let code = line.get(..3)?.parse().ok()?;
|
||||
(line.as_bytes().get(3) == Some(&b' ') || line.len() == 3).then_some(code)
|
||||
}
|
||||
|
||||
/// A `2xx` reply, or an error naming the code.
|
||||
fn ok(code: u16) -> std::io::Result<()> {
|
||||
if (200..300).contains(&code) { Ok(()) } else { Err(std::io::Error::other(format!("Speech Dispatcher refused a command ({code})"))) }
|
||||
}
|
||||
|
||||
/// The error a conversation stops with when reading moved on.
|
||||
fn moved_on() -> std::io::Error {
|
||||
std::io::Error::new(ErrorKind::Interrupted, "reading moved on")
|
||||
}
|
||||
|
||||
/// The begin (701), end (702), cancel (703) and pause (704) events.
|
||||
const BEGIN: u16 = 701;
|
||||
const END: u16 = 702;
|
||||
const CANCEL: u16 = 703;
|
||||
const PAUSE: u16 = 704;
|
||||
|
||||
/// One conversation with the server.
|
||||
struct Conversation<'a, W, R> {
|
||||
w: W,
|
||||
lines: Lines<R>,
|
||||
/// The last end, cancel or pause event, until it's taken.
|
||||
event: Option<u16>,
|
||||
/// The message just queued began.
|
||||
begun: bool,
|
||||
/// Whether reading moved on (checked between lines and at every read timeout).
|
||||
changed: &'a dyn Fn() -> bool,
|
||||
}
|
||||
|
||||
impl<W: Write, R: Read> Conversation<'_, W, R> {
|
||||
/// The reply to the command just sent: its final code. An end, cancel or pause event that arrives
|
||||
/// meanwhile is kept. Gives up after [`REPLY_TIMEOUT`], also when the server keeps sending
|
||||
/// other lines, and as soon as reading moves on.
|
||||
fn reply(&mut self) -> std::io::Result<u16> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(line) = self.lines.next()? {
|
||||
match final_code(&line) {
|
||||
Some(BEGIN) => self.begun = true,
|
||||
Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code),
|
||||
Some(code) if (700..800).contains(&code) => {}
|
||||
Some(code) => return Ok(code),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if start.elapsed() > REPLY_TIMEOUT {
|
||||
return Err(ErrorKind::TimedOut.into());
|
||||
}
|
||||
if (self.changed)() {
|
||||
return Err(moved_on());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one command line and read its reply code.
|
||||
fn command(&mut self, cmd: &str) -> std::io::Result<u16> {
|
||||
self.w.write_all(cmd.as_bytes())?;
|
||||
self.w.write_all(b"\r\n")?;
|
||||
self.reply()
|
||||
}
|
||||
|
||||
/// What became of the message just queued: its end, cancel or pause event, or
|
||||
/// [`Fate::NotStarted`] when it didn't begin within [`QUEUE_WAIT`]. Once it began, its end is
|
||||
/// waited for until `limit` has passed (a stuck server: an error). An error as soon as reading
|
||||
/// moves on.
|
||||
fn fate(&mut self, limit: Duration) -> std::io::Result<Fate> {
|
||||
let queued = Instant::now();
|
||||
let mut began = self.begun.then(Instant::now);
|
||||
loop {
|
||||
match self.event.take() {
|
||||
Some(END) => return Ok(Fate::End),
|
||||
Some(CANCEL) => return Ok(Fate::Cancelled),
|
||||
Some(_) => return Ok(Fate::Paused),
|
||||
None => {}
|
||||
}
|
||||
if (self.changed)() {
|
||||
return Err(moved_on());
|
||||
}
|
||||
match began {
|
||||
None if queued.elapsed() > QUEUE_WAIT => return Ok(Fate::NotStarted),
|
||||
Some(t) if t.elapsed() > limit => return Err(std::io::Error::new(ErrorKind::TimedOut, "the sentence didn't end")),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(line) = self.lines.next()? {
|
||||
match final_code(&line) {
|
||||
Some(BEGIN) => {
|
||||
began.get_or_insert_with(Instant::now);
|
||||
}
|
||||
Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait `pause`, reading what the server sends meanwhile; an error as soon as reading moves on.
|
||||
fn wait(&mut self, pause: Duration) -> std::io::Result<()> {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < pause {
|
||||
if (self.changed)() {
|
||||
return Err(moved_on());
|
||||
}
|
||||
self.lines.next()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set up the client, say the sentence, and wait for it to end. Only [`converse`] cancels our
|
||||
/// own speech, and it stops waiting first, so a cancel or pause event comes from another
|
||||
/// client. A cancel is most often another client's message, a screen reader's announcement:
|
||||
/// the sentence is sent again after a pause ([`RESEND_AFTER`]), when the announcement has
|
||||
/// ended or the server holds ours back until it has. Cancelled again after that, paused, or
|
||||
/// never begun, the sentence halts reading.
|
||||
fn speak(&mut self, req: &Request<'_>) -> std::io::Result<Outcome> {
|
||||
ok(self.command("SET SELF CLIENT_NAME user:wordcraft:read-aloud")?)?;
|
||||
ok(self.command("SET SELF NOTIFICATION BEGIN on")?)?;
|
||||
ok(self.command("SET SELF NOTIFICATION END on")?)?;
|
||||
ok(self.command("SET SELF NOTIFICATION CANCEL on")?)?;
|
||||
ok(self.command("SET SELF NOTIFICATION PAUSE on")?)?;
|
||||
ok(self.command(&format!("SET SELF RATE {}", req.rate))?)?;
|
||||
// A language the server doesn't know leaves its default: no error. The tag is checked
|
||||
// again here, at the edge of the protocol: it goes on a command line.
|
||||
if let Some(lang) = req.lang.and_then(super::plain_tag) {
|
||||
self.command(&format!("SET SELF LANGUAGE {lang}"))?;
|
||||
}
|
||||
let chars = u32::try_from(req.text.chars().count()).unwrap_or(u32::MAX);
|
||||
let limit = SENTENCE_BASE.saturating_add(SENTENCE_PER_CHAR.saturating_mul(chars));
|
||||
let mut resends = RESEND_AFTER.iter();
|
||||
loop {
|
||||
self.event = None;
|
||||
self.begun = false;
|
||||
ok(self.command("SPEAK")?)?;
|
||||
self.w.write_all(message_data(req.text).as_bytes())?;
|
||||
ok(self.reply()?)?;
|
||||
match self.fate(limit)? {
|
||||
Fate::End => return Ok(Outcome::Done),
|
||||
Fate::Cancelled => match resends.next() {
|
||||
Some(pause) => self.wait(*pause)?,
|
||||
None => return Ok(Outcome::Halted),
|
||||
},
|
||||
Fate::Paused => return Ok(Outcome::Halted),
|
||||
Fate::NotStarted => return Ok(Outcome::NotStarted),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a queued message.
|
||||
enum Fate {
|
||||
End,
|
||||
Cancelled,
|
||||
Paused,
|
||||
NotStarted,
|
||||
}
|
||||
|
||||
/// The last words after a sentence that didn't finish.
|
||||
const CLOSE: &[u8] = b"\r\n.\r\nCANCEL self\r\nQUIT\r\n";
|
||||
|
||||
/// Speak `req` over `stream`, cancelling it when `changed()`.
|
||||
pub(super) fn converse(stream: &Stream, req: &Request<'_>, changed: &dyn Fn() -> bool) -> std::io::Result<Outcome> {
|
||||
stream.set_timeouts()?;
|
||||
let mut c = Conversation { w: stream.try_clone()?, lines: Lines::new(stream.try_clone()?), event: None, begun: false, changed };
|
||||
let result = c.speak(req);
|
||||
// The last words wait for no answer: a server that stopped answering mustn't hold up Stop.
|
||||
// After anything but a finished sentence our message may still be queued, or the server may
|
||||
// be reading message data: an empty line and `.` end a message being sent (outside one they
|
||||
// are two refused commands), and `CANCEL self` drops ours.
|
||||
let done = matches!(result, Ok(Outcome::Done));
|
||||
let _ = c.w.write_all(if done { b"QUIT\r\n" } else { CLOSE });
|
||||
match result {
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => Ok(Outcome::Interrupted),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
#[test]
|
||||
fn the_address_follows_the_environment() {
|
||||
let unix = |p: &str| Some(Address::Unix(PathBuf::from(p)));
|
||||
let run = || Some(PathBuf::from("/run/user/1"));
|
||||
assert_eq!(address(Some("unix_socket:/tmp/s.sock"), None, None, None), unix("/tmp/s.sock"));
|
||||
assert_eq!(address(None, run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock"));
|
||||
// `unix_socket` alone is the default socket, as in Speech Dispatcher's client library.
|
||||
assert_eq!(address(Some("unix_socket"), run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock"));
|
||||
assert_eq!(address(Some("unix_socket:"), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock"));
|
||||
// Without a runtime folder: the cache folder, `XDG_CACHE_HOME` first.
|
||||
assert_eq!(address(Some(" "), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock"));
|
||||
assert_eq!(address(None, Some("".into()), Some("/c".into()), Some("/home/u".into())), unix("/c/speech-dispatcher/speechd.sock"));
|
||||
// A TCP address, with the default host and port filled in.
|
||||
assert_eq!(address(Some("inet_socket:localhost:6561"), run(), None, None), Some(Address::Inet("localhost".into(), 6561)));
|
||||
assert_eq!(address(Some("inet_socket"), None, None, None), Some(Address::Inet("127.0.0.1".into(), DEFAULT_PORT)));
|
||||
assert_eq!(address(Some("inet_socket:host"), None, None, None), Some(Address::Inet("host".into(), DEFAULT_PORT)));
|
||||
assert_eq!(address(Some("inet_socket:host:port"), None, None, None), None);
|
||||
assert_eq!(address(Some("inet_socket:host:99999"), None, None, None), None);
|
||||
assert_eq!(address(Some("carrier_pigeon:x"), run(), None, None), None);
|
||||
assert_eq!(address(None, None, None, None), None);
|
||||
assert!(is_local("localhost") && is_local("127.0.0.1") && is_local("::1"));
|
||||
assert!(!is_local("example.org") && !is_local("192.0.2.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_text_is_always_message_data() {
|
||||
// `spd-say -e` would run this line as an SSIP command; here it's spoken text.
|
||||
assert_eq!(message_data("!-!SET SELF RATE 50"), "!-!SET SELF RATE 50\r\n.\r\n");
|
||||
// A line of a single `.` would end the message early, and a line break would start a
|
||||
// new line that could be one.
|
||||
assert_eq!(message_data(".\r\nQUIT"), ".. QUIT\r\n.\r\n");
|
||||
assert_eq!(message_data("a\n.\nb"), "a . b\r\n.\r\n");
|
||||
assert_eq!(final_code("225 OK MESSAGE QUEUED"), Some(225));
|
||||
assert_eq!(final_code("225-17"), None);
|
||||
assert_eq!(final_code("x"), None);
|
||||
}
|
||||
|
||||
/// Hands out the given reads one by one, then reports a timeout.
|
||||
struct Reads(std::collections::VecDeque<Vec<u8>>);
|
||||
|
||||
impl Read for Reads {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
match self.0.pop_front() {
|
||||
Some(chunk) if !chunk.is_empty() => {
|
||||
let n = chunk.len().min(buf.len());
|
||||
buf[..n].copy_from_slice(&chunk[..n]);
|
||||
if n < chunk.len() {
|
||||
self.0.push_front(chunk[n..].to_vec());
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
_ => Err(ErrorKind::WouldBlock.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_split_by_a_read_timeout_stays_whole() {
|
||||
// `é` is C3 A9; the read times out (the empty read) between its two bytes.
|
||||
let mut lines = Lines::new(Reads(vec![b"249-Caf\xC3".to_vec(), Vec::new(), b"\xA9\tfr\tnone\r\n702 END\r\n".to_vec()].into()));
|
||||
assert_eq!(lines.next().unwrap(), None, "half a line");
|
||||
assert_eq!(lines.next().unwrap(), None, "the timeout");
|
||||
assert_eq!(lines.next().unwrap().as_deref(), Some("249-Café\tfr\tnone"));
|
||||
assert_eq!(lines.next().unwrap().as_deref(), Some("702 END"));
|
||||
assert_eq!(lines.next().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_without_an_end_is_an_error_not_a_growing_buffer() {
|
||||
// A peer that never stops sending and never ends the line.
|
||||
let mut lines = Lines::new(std::io::repeat(b'x'));
|
||||
let mut calls = 0;
|
||||
let err = loop {
|
||||
calls += 1;
|
||||
assert!(calls < 100, "never gave up");
|
||||
match lines.next() {
|
||||
Ok(None) => {}
|
||||
Ok(Some(l)) => panic!("{l}"),
|
||||
Err(e) => break e,
|
||||
}
|
||||
};
|
||||
assert_eq!(err.kind(), ErrorKind::InvalidData);
|
||||
assert!(lines.pending.len() <= MAX_LINE + 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trickle_of_bytes_hands_control_back_after_each_read() {
|
||||
// One byte per read and never a line end: every call returns, so the caller can look at
|
||||
// its deadline and at whether reading moved on.
|
||||
let mut lines = Lines::new(Reads(vec![b"2".to_vec(), b"0".to_vec(), b"0".to_vec(), b" OK\r\n".to_vec()].into()));
|
||||
assert_eq!(lines.next().unwrap(), None);
|
||||
assert_eq!(lines.next().unwrap(), None);
|
||||
assert_eq!(lines.next().unwrap(), None);
|
||||
assert_eq!(lines.next().unwrap().as_deref(), Some("200 OK"));
|
||||
}
|
||||
|
||||
/// How the stand-in server ends a message.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Ending {
|
||||
/// The end event.
|
||||
End,
|
||||
/// Nothing until `CANCEL self`.
|
||||
Hold,
|
||||
/// Cancelled by another client every time.
|
||||
CancelledByOther,
|
||||
/// Cancelled by another client the first time only.
|
||||
CancelledOnce,
|
||||
/// Queued and never begun (paused before it began: no event at all).
|
||||
NeverBegins,
|
||||
/// Paused by another client (`PAUSE all`).
|
||||
PausedByOther,
|
||||
}
|
||||
|
||||
/// A Speech Dispatcher stand-in: answers every command with `2xx` and ends each message as
|
||||
/// `ending` says. Returns the lines it received.
|
||||
fn server(mut s: UnixStream, ending: Ending) -> std::thread::JoinHandle<Vec<String>> {
|
||||
std::thread::spawn(move || {
|
||||
let mut got = Vec::new();
|
||||
let mut r = BufReader::new(s.try_clone().unwrap());
|
||||
let mut in_data = false;
|
||||
let mut messages = 0;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if r.read_line(&mut line).unwrap_or(0) == 0 {
|
||||
return got;
|
||||
}
|
||||
let line = line.trim_end_matches(['\r', '\n']).to_string();
|
||||
got.push(line.clone());
|
||||
let reply: &[u8] = if in_data {
|
||||
if line != "." {
|
||||
continue;
|
||||
}
|
||||
in_data = false;
|
||||
messages += 1;
|
||||
match ending {
|
||||
Ending::CancelledOnce if messages == 1 => {
|
||||
b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n"
|
||||
}
|
||||
Ending::CancelledOnce => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n702-1\r\n702-7\r\n702 END\r\n",
|
||||
Ending::NeverBegins => b"225-1\r\n225 OK MESSAGE QUEUED\r\n",
|
||||
Ending::CancelledByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n",
|
||||
Ending::PausedByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n704-1\r\n704-7\r\n704 PAUSED\r\n",
|
||||
Ending::End => b"225-1\r\n225 OK MESSAGE QUEUED\r\n702-1\r\n702-7\r\n702 END\r\n",
|
||||
Ending::Hold => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n",
|
||||
}
|
||||
} else if line == "SPEAK" {
|
||||
in_data = true;
|
||||
b"230 OK RECEIVING DATA\r\n"
|
||||
} else if line == "CANCEL self" {
|
||||
b"703-1\r\n703-7\r\n703 CANCELED\r\n210 OK CANCELLED\r\n"
|
||||
} else if line == "QUIT" {
|
||||
let _ = s.write_all(b"231 HAPPY HACKING\r\n");
|
||||
return got;
|
||||
} else {
|
||||
b"200 OK\r\n"
|
||||
};
|
||||
// The client doesn't wait for the answers to its last words.
|
||||
let _ = s.write_all(reply);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn speaks(got: &[String]) -> usize {
|
||||
got.iter().filter(|l| *l == "SPEAK").count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_is_spoken_to_its_end() {
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::End);
|
||||
let req = Request { text: "!-!QUIT", rate: 50, lang: Some("pt-BR") };
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);
|
||||
let got = h.join().unwrap();
|
||||
assert!(got.contains(&"SET SELF RATE 50".to_string()), "{got:?}");
|
||||
// The region stays: Speech Dispatcher's voices are per region (`en-GB`, `pt-BR`).
|
||||
assert!(got.contains(&"SET SELF LANGUAGE pt-BR".to_string()), "{got:?}");
|
||||
let at = got.iter().position(|l| l == "SPEAK").unwrap();
|
||||
assert_eq!(got[at + 1..at + 3], ["!-!QUIT".to_string(), ".".to_string()], "the text went as data");
|
||||
assert_eq!(speaks(&got), 1);
|
||||
assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "nothing cancelled");
|
||||
assert_eq!(got.last().map(String::as_str), Some("QUIT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_a_screen_reader_cuts_off_is_said_again_after_a_pause() {
|
||||
// One announcement: the sentence is said again once it has had time to end.
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::CancelledOnce);
|
||||
let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);
|
||||
assert!(start.elapsed() >= RESEND_AFTER[0], "it waited before saying it again");
|
||||
let got = h.join().unwrap();
|
||||
assert_eq!(speaks(&got), 2, "{got:?}");
|
||||
assert!(got.contains(&"SET SELF NOTIFICATION BEGIN on".to_string()), "{got:?}");
|
||||
assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_another_client_keeps_stopping_halts_reading() {
|
||||
// Cancelled every time (another client stopping everything, a failing module): two
|
||||
// tries more, then reading halts, never moving on silently. A pause halts at once.
|
||||
for (ending, sent) in [(Ending::CancelledByOther, 1 + RESEND_AFTER.len()), (Ending::PausedByOther, 1)] {
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, ending);
|
||||
let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None };
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Halted);
|
||||
let got = h.join().unwrap();
|
||||
assert_eq!(speaks(&got), sent, "{got:?}");
|
||||
assert!(got.contains(&"SET SELF NOTIFICATION PAUSE on".to_string()), "{got:?}");
|
||||
// Only our own message is dropped.
|
||||
assert!(got.contains(&"CANCEL self".to_string()) && !got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")), "{got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_withheld_end_times_out_and_the_next_sentence_plays() {
|
||||
// The server accepts the sentence, begins it, stays connected and never reports its end:
|
||||
// the wait gives up with an error, drops the message (`CANCEL self`), and closes.
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::Hold);
|
||||
let req = Request { text: "Withheld.", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
let err = converse(&Stream::Unix(client), &req, &|| false).unwrap_err();
|
||||
assert_eq!(err.kind(), ErrorKind::TimedOut, "{err}");
|
||||
assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR * 9 + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());
|
||||
let got = h.join().unwrap();
|
||||
assert_eq!(speaks(&got), 1);
|
||||
assert!(got.contains(&"CANCEL self".to_string()) && got.last().map(String::as_str) == Some("QUIT"), "{got:?}");
|
||||
// Playback recovers: the next sentence, on a new connection as every sentence gets,
|
||||
// is spoken to its end.
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::End);
|
||||
assert_eq!(converse(&Stream::Unix(client), &Request { text: "Next.", rate: 0, lang: None }, &|| false).unwrap(), Outcome::Done);
|
||||
assert_eq!(speaks(&h.join().unwrap()), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_that_never_begins_halts_reading_instead_of_failing() {
|
||||
// `PAUSE all` before our message began sends no event: the time queued has its own
|
||||
// bound, and reaching it halts reading (Play carries on) rather than stopping with an error.
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::NeverBegins);
|
||||
let req = Request { text: "Queued behind another client.", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::NotStarted);
|
||||
assert!(start.elapsed() >= QUEUE_WAIT && start.elapsed() < QUEUE_WAIT + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());
|
||||
assert!(h.join().unwrap().contains(&"CANCEL self".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_that_never_ends_times_out() {
|
||||
// A stuck speech module: no end event ever. The wait is bounded by the sentence's length.
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::Hold);
|
||||
let req = Request { text: "x", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut);
|
||||
assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());
|
||||
assert!(h.join().unwrap().contains(&"CANCEL self".to_string()), "the stuck message is dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_on_cancels_only_our_own_speech() {
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::Hold);
|
||||
let req = Request { text: "Long sentence.", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
let changed = || start.elapsed() > Duration::from_millis(300);
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &changed).unwrap(), Outcome::Interrupted);
|
||||
let got = h.join().unwrap();
|
||||
assert_eq!(speaks(&got), 1, "{got:?}");
|
||||
assert!(got.contains(&"CANCEL self".to_string()), "{got:?}");
|
||||
assert!(!got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")));
|
||||
assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE")), "no language, no command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_on_stops_waiting_for_a_slow_server() {
|
||||
// A server that reads but never answers: Stop acts at once, not after the reply timeout.
|
||||
let (client, mut srv) = UnixStream::pair().unwrap();
|
||||
let h = std::thread::spawn(move || {
|
||||
let mut all = String::new();
|
||||
let _ = srv.read_to_string(&mut all);
|
||||
all
|
||||
});
|
||||
let req = Request { text: "x", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| true).unwrap(), Outcome::Interrupted);
|
||||
assert!(start.elapsed() < REPLY_TIMEOUT, "{:?}", start.elapsed());
|
||||
// Stopped while the server may be about to read message data: the empty line and `.` end
|
||||
// any message first, so `CANCEL self` is never taken for text.
|
||||
let all = h.join().unwrap();
|
||||
assert!(all.ends_with("\r\n\r\n.\r\nCANCEL self\r\nQUIT\r\n"), "{all:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_only_sends_events_times_out() {
|
||||
// Events and continuation lines are no reply: they can't keep a command waiting forever.
|
||||
let (client, mut srv) = UnixStream::pair().unwrap();
|
||||
let h = std::thread::spawn(move || while srv.write_all(b"701 BEGIN\r\n225-1\r\n").is_ok() {});
|
||||
let req = Request { text: "x", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
let client = Stream::Unix(client);
|
||||
assert_eq!(converse(&client, &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut);
|
||||
assert!(start.elapsed() < REPLY_TIMEOUT * 3, "{:?}", start.elapsed());
|
||||
drop(client);
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_language_tag_is_never_sent() {
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::End);
|
||||
let req = Request { text: "x", rate: 0, lang: Some("hu\r\nCANCEL all") };
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);
|
||||
let got = h.join().unwrap();
|
||||
assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE") || l.starts_with("CANCEL")), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_hangs_up_is_an_error_not_a_hang() {
|
||||
let (client, mut srv) = UnixStream::pair().unwrap();
|
||||
let h = std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 64];
|
||||
let _ = srv.read(&mut buf);
|
||||
});
|
||||
let req = Request { text: "x", rate: 0, lang: None };
|
||||
assert!(converse(&Stream::Unix(client), &req, &|| false).is_err());
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_command_that_exits_reports_how() {
|
||||
let far = Instant::now() + Duration::from_secs(30);
|
||||
let run = |script: &str| start_server(std::process::Command::new("sh").args(["-c", script]), far, &|| false);
|
||||
assert!(matches!(run("exit 0"), Started::Exited(true)));
|
||||
assert!(matches!(run("exit 1"), Started::Exited(false)));
|
||||
let missing = start_server(&mut std::process::Command::new("no-such-program-for-wordcraft"), far, &|| false);
|
||||
assert!(matches!(missing, Started::Missing), "{missing:?}");
|
||||
}
|
||||
|
||||
/// A start command that never exits, writing its process id to `pid` first (whole, by renaming).
|
||||
fn hanging_start(pid: &std::path::Path) -> std::process::Command {
|
||||
let mut c = std::process::Command::new("sh");
|
||||
c.args(["-c", "echo $$ > \"$1.part\" && mv \"$1.part\" \"$1\" && exec sleep 30", "sh"]).arg(pid);
|
||||
c
|
||||
}
|
||||
|
||||
/// A fresh path for a start command's process id.
|
||||
fn pid_file(name: &str) -> std::path::PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("wordcraft-ssip-{}-{name}.pid", std::process::id()));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let _ = std::fs::remove_file(p.with_extension("pid.part"));
|
||||
p
|
||||
}
|
||||
|
||||
/// The process id the start command wrote (whole, or still in its `.part` file when it was
|
||||
/// stopped before renaming it); `None` when it was stopped before writing any.
|
||||
fn written_pid(pid: &std::path::Path) -> Option<String> {
|
||||
let part = pid.with_extension("pid.part");
|
||||
let id = std::fs::read_to_string(pid).or_else(|_| std::fs::read_to_string(&part)).ok();
|
||||
let _ = std::fs::remove_file(pid);
|
||||
let _ = std::fs::remove_file(part);
|
||||
id.map(|id| id.trim().to_string()).filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
/// Is process `id` gone: reaped, not merely killed and left a zombie (which `kill -0` still
|
||||
/// finds)?
|
||||
fn gone(id: &str) -> bool {
|
||||
let probe = std::process::Command::new("sh").args(["-c", "kill -0 \"$1\" 2>/dev/null", "sh", id]).status().unwrap();
|
||||
!probe.success()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_command_that_hangs_is_stopped_at_the_deadline() {
|
||||
// A start stopped before the stand-in wrote its id is tried again with a longer deadline
|
||||
// (a loaded machine may not run it at once); one that never wrote it left nothing to reap.
|
||||
for deadline in [Duration::from_secs(1), Duration::from_secs(3), Duration::from_secs(10)] {
|
||||
let pid = pid_file("deadline");
|
||||
let begun = Instant::now();
|
||||
let got = start_server(&mut hanging_start(&pid), begun + deadline, &|| false);
|
||||
assert!(matches!(got, Started::TimedOut), "{got:?}");
|
||||
assert!(begun.elapsed() < deadline + Duration::from_secs(4), "{:?}", begun.elapsed());
|
||||
if let Some(id) = written_pid(&pid) {
|
||||
assert!(gone(&id), "the start command was reaped");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_ends_the_wait_for_a_start_command_at_once() {
|
||||
let pid = pid_file("stop");
|
||||
let begun = Instant::now();
|
||||
// Stop once the command is surely running.
|
||||
let got = start_server(&mut hanging_start(&pid), begun + Duration::from_secs(30), &|| pid.exists());
|
||||
assert!(matches!(got, Started::Interrupted), "{got:?}");
|
||||
assert!(begun.elapsed() < Duration::from_secs(5), "{:?}", begun.elapsed());
|
||||
let id = written_pid(&pid).expect("stopped only once the id was written");
|
||||
assert!(gone(&id), "the start command was reaped");
|
||||
}
|
||||
}
|
||||
@@ -186,76 +186,6 @@ fn player_finishes_on_its_own() {
|
||||
assert_eq!(r.state(), State::Playing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_reading_counts_as_a_new_one() {
|
||||
// The worker forgets what it learnt about the speech programs when a reading starts, even
|
||||
// when the same worker thread carries on (a pause, then Read Aloud again).
|
||||
let mut r = ReadAloud::default();
|
||||
r.backend = Backend::Hold;
|
||||
let readings = |r: &ReadAloud| lock(&r.shared.0).readings;
|
||||
let doc = Document::from_text("One. Two.");
|
||||
r.start(sentences(&doc, &Pos::body(0, 0), None, true));
|
||||
let first = readings(&r);
|
||||
r.play_pause();
|
||||
r.start(sentences(&doc, &Pos::body(0, 0), None, true));
|
||||
assert_eq!(readings(&r), first + 1);
|
||||
r.play_pause();
|
||||
r.play_pause();
|
||||
assert_eq!(readings(&r), first + 1, "pause and play are the same reading");
|
||||
r.stop();
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn a_sentence_that_never_began_says_so_not_another_program() {
|
||||
// No begin event within the queue bound: no other program need be involved, so the
|
||||
// message names the sentence not starting, not another program stopping it.
|
||||
let halted = |o| match dispatcher_spoken(Ok(o)) {
|
||||
Spoken::Halted(m) => m,
|
||||
_ => String::new(),
|
||||
};
|
||||
assert_eq!(halted(ssip::Outcome::NotStarted), NOT_STARTED);
|
||||
assert!(!NOT_STARTED.contains("another program"));
|
||||
assert_eq!(halted(ssip::Outcome::Halted), HALTED_BY_OTHER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_after_a_failed_sentence_plays_it_again() {
|
||||
// A sentence whose end never came stops reading with an error (as the worker does on a
|
||||
// timeout); Play starts it again and the message goes.
|
||||
let mut r = ReadAloud::default();
|
||||
r.backend = Backend::Hold;
|
||||
r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true));
|
||||
r.skip(1);
|
||||
{
|
||||
let mut s = lock(&r.shared.0);
|
||||
s.state = State::Stopped;
|
||||
s.error = Some("Read Aloud couldn't use Speech Dispatcher: the sentence didn't end".into());
|
||||
}
|
||||
r.play_pause();
|
||||
assert_eq!((r.state(), r.progress().0, r.error()), (State::Playing, 1, None));
|
||||
r.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_after_a_halt_carries_on_and_clears_the_message() {
|
||||
// Another program stopped the sentence: reading paused on it with a message, and Play
|
||||
// goes on from the same sentence.
|
||||
let mut r = ReadAloud::default();
|
||||
r.backend = Backend::Hold;
|
||||
r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true));
|
||||
{
|
||||
let mut s = lock(&r.shared.0);
|
||||
s.state = State::Paused;
|
||||
s.error = Some("stopped by another program".into());
|
||||
}
|
||||
r.play_pause();
|
||||
assert_eq!(r.state(), State::Playing);
|
||||
assert_eq!(r.progress().0, 0);
|
||||
assert_eq!(r.error(), None);
|
||||
r.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_queue_does_not_play() {
|
||||
let mut r = ReadAloud::default();
|
||||
@@ -341,38 +271,6 @@ fn espeak_gets_the_language() {
|
||||
assert_eq!(espeak_args("180", Some("uk")), ["-s", "180", "-v", "uk", "--stdin"]);
|
||||
assert_eq!(espeak_args("180", None), ["-s", "180", "--stdin"]);
|
||||
assert_eq!(espeak_args("180", Some("--evil")), ["-s", "180", "--stdin"]);
|
||||
assert_eq!(espeak_args("180", Some("hu-HU")), ["-s", "180", "-v", "hu", "--stdin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speech_dispatcher_rate_follows_the_speed() {
|
||||
assert_eq!(spd_rate(1.0), 0);
|
||||
assert_eq!(spd_rate(MIN_RATE), -50);
|
||||
assert_eq!(spd_rate(MAX_RATE), 100);
|
||||
assert_eq!(spd_rate(2.0), 50);
|
||||
assert_eq!(spd_rate(10.0), 100);
|
||||
assert_eq!(spd_rate(0.0), -50);
|
||||
assert_eq!(spd_rate(f32::NAN), 0);
|
||||
assert_eq!(spd_rate(1.5), 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speech_dispatcher_keeps_the_region() {
|
||||
let tag = |t| dispatcher_language(Some(t));
|
||||
assert_eq!(tag("en-GB").as_deref(), Some("en-GB"));
|
||||
assert_eq!(tag(" pt-BR ").as_deref(), Some("pt-BR"));
|
||||
assert_eq!(tag("zh-TW").as_deref(), Some("zh-TW"));
|
||||
assert_eq!(tag("hu").as_deref(), Some("hu"));
|
||||
assert_eq!(tag("HU-hu").as_deref(), Some("hu-HU"));
|
||||
// SSIP takes language and region: script and variant subtags are left out.
|
||||
assert_eq!(tag("sr-Latn-RS").as_deref(), Some("sr-RS"));
|
||||
assert_eq!(tag("zh-Hant-TW").as_deref(), Some("zh-TW"));
|
||||
assert_eq!(tag("es-419").as_deref(), Some("es-419"));
|
||||
assert_eq!(tag("de-CH-1996").as_deref(), Some("de-CH"));
|
||||
assert_eq!(tag("sr-Cyrl").as_deref(), Some("sr"));
|
||||
assert_eq!(dispatcher_language(Some("x-none")), None);
|
||||
assert_eq!(dispatcher_language(Some("hu\r\nQUIT")), None);
|
||||
assert_eq!(dispatcher_language(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1346,74 +1346,6 @@ fn copying_bold_text_puts_html_on_the_clipboard() {
|
||||
assert_eq!(s.clipboard_html, html);
|
||||
}
|
||||
|
||||
/// Bold and italic of a paragraph's character, in a document.
|
||||
fn emphasis_at(d: &wordcraft_doc::Document, block: usize, off: usize) -> (bool, bool) {
|
||||
match d.body.get(block).map(|b| &**b) {
|
||||
Some(wordcraft_doc::Block::Para(p)) => {
|
||||
let c = p.props_of_char(off);
|
||||
(c.bold.unwrap_or(false), c.italic.unwrap_or(false))
|
||||
}
|
||||
_ => (false, false),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_puts_rtf_on_the_clipboard() {
|
||||
// gap-clipboard-rtf: a copy carries RTF beside its text and HTML, for programs that read RTF.
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "plain bold\nsome italic"}));
|
||||
s.doc.format_range(&Pos::body(0, 6), &Pos::body(0, 10), &|c| c.bold = Some(true)).unwrap();
|
||||
s.doc.format_range(&Pos::body(1, 5), &Pos::body(1, 11), &|c| c.italic = Some(true)).unwrap();
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
let rtf = r["rtf"].as_str().unwrap_or_default().to_string();
|
||||
assert!(rtf.starts_with("{\\rtf1"), "{r}");
|
||||
assert_eq!(s.clipboard_rtf, rtf);
|
||||
let d = wordcraft_formats::rtf::import(rtf.as_bytes()).unwrap();
|
||||
let paras: Vec<String> =
|
||||
d.body.iter().filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None }).collect();
|
||||
assert_eq!(paras.get(..2), Some(&["plain bold".to_string(), "some italic".to_string()][..]), "{paras:?}");
|
||||
assert_eq!(emphasis_at(&d, 0, 0), (false, false));
|
||||
assert_eq!(emphasis_at(&d, 0, 7), (true, false));
|
||||
assert_eq!(emphasis_at(&d, 1, 7), (false, true));
|
||||
|
||||
// Pasting it elsewhere keeps the formatting.
|
||||
let mut t = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut t, "edit.paste", json!({"rtf": rtf}));
|
||||
assert!(text(&t).starts_with("plain bold\nsome italic"), "{}", text(&t));
|
||||
assert_eq!(emphasis_at(&t.doc, 0, 7), (true, false));
|
||||
assert_eq!(emphasis_at(&t.doc, 1, 7), (false, true));
|
||||
|
||||
// Cut carries it too.
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 10}}));
|
||||
let r = run(&mut s, "edit.cut", json!({}));
|
||||
assert!(r["rtf"].as_str().is_some_and(|t| t.starts_with("{\\rtf1") && t.contains("bold")), "{r}");
|
||||
assert_eq!(text(&s).lines().next(), Some("plain "));
|
||||
|
||||
// Nothing selected: nothing copied, no RTF.
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
assert!(r.get("rtf").is_none(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_hostile_rtf_never_panics() {
|
||||
let deep = format!("{{\\rtf1 {}x{}", "{\\b ".repeat(5000), "}".repeat(5000));
|
||||
let junk = ["{\\rtf1", "{\\rtf1 {\\b bo", "{\\rtf1\\ansi\\u-99999999999? \\bin999999999 x}", "{\\rtf1 \\'zz\\fonttbl{", "{\\rtf\u{0}\u{FFFF}}"];
|
||||
for rtf in junk.iter().map(|j| j.to_string()).chain([deep]) {
|
||||
for id in ["edit.paste", "edit.pasteMerge", "edit.pasteSpecial"] {
|
||||
let mut t = Session::new(wordcraft_doc::Document::new());
|
||||
// `Session::run` turns a panic into an error: look for it.
|
||||
for v in [json!({"rtf": rtf, "as": "rtf"}), json!({"rtf": rtf, "text": "fallback"})] {
|
||||
if let Err(e) = t.run(id, &v) {
|
||||
assert!(!e.to_string().contains("panicked"), "{id}: {e}");
|
||||
}
|
||||
let _ = t.layout();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -213,32 +213,10 @@ fn header<'a>(h: &'a [(String, String)], name: &str) -> Option<&'a str> {
|
||||
|
||||
/// A `; name=value` parameter of a header value (quotes removed), name case-insensitive.
|
||||
fn param<'a>(value: &'a str, name: &str) -> Option<&'a str> {
|
||||
// Split outside quoted strings and comments (RFC 2045 §5.1, RFC 822 §3.3).
|
||||
let mut quoted = false;
|
||||
let mut escaped = false;
|
||||
let mut comment_depth = 0usize;
|
||||
value
|
||||
.split(|c| {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if (quoted || comment_depth > 0) && c == '\\' {
|
||||
escaped = true;
|
||||
} else if !quoted && c == '(' {
|
||||
comment_depth = comment_depth.saturating_add(1);
|
||||
} else if comment_depth > 0 {
|
||||
if c == ')' {
|
||||
comment_depth -= 1;
|
||||
}
|
||||
} else if c == '"' {
|
||||
quoted = !quoted;
|
||||
}
|
||||
c == ';' && !quoted && comment_depth == 0
|
||||
})
|
||||
.skip(1)
|
||||
.find_map(|p| {
|
||||
let (k, v) = p.split_once('=')?;
|
||||
k.trim().eq_ignore_ascii_case(name).then(|| v.trim().trim_matches('"'))
|
||||
})
|
||||
value.split(';').skip(1).find_map(|p| {
|
||||
let (k, v) = p.split_once('=')?;
|
||||
k.trim().eq_ignore_ascii_case(name).then(|| v.trim().trim_matches('"'))
|
||||
})
|
||||
}
|
||||
|
||||
/// Gather the leaf parts of a (possibly multipart) entity.
|
||||
@@ -422,26 +400,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_parameters_do_not_supply_a_false_boundary() {
|
||||
for name in [r#"name="notes; boundary=wrong""#, r#"name="notes\"; boundary=wrong""#, r#"name="notes (10\" screen; boundary=wrong""#] {
|
||||
let page = "Content-Type: text/html\r\n\r\n<p>Kept content</p>";
|
||||
let message = message(page).replace("multipart/related; boundary=", &format!("multipart/related; {name}; boundary="));
|
||||
let doc = import(message.as_bytes());
|
||||
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Kept content", "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_in_comments_do_not_hide_the_boundary() {
|
||||
for comment in [r#"(10" screen)"#, r#"(outer (10" screen); boundary=wrong)"#, r#"(10\)" screen; boundary=wrong)"#] {
|
||||
let page = "Content-Type: text/html\r\n\r\n<p>Kept content</p>";
|
||||
let message = message(page).replace("multipart/related; boundary=", &format!("multipart/related {comment}; boundary="));
|
||||
let doc = import(message.as_bytes());
|
||||
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Kept content", "{comment}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_part_charset_decides_the_text() {
|
||||
let page = "Content-Type: text/html; charset=windows-1250\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n<p>=E8lanak</p>";
|
||||
|
||||
@@ -13,14 +13,7 @@ use super::{
|
||||
/// Cell identity is independent of optional table-style formatting.
|
||||
pub(crate) enum BoxContext<'a> {
|
||||
Other,
|
||||
/// Outer cell edges relative to its text origin (not including wrap distances).
|
||||
Cell {
|
||||
text: Option<&'a para::CellText>,
|
||||
left: f32,
|
||||
right: f32,
|
||||
/// False for a turned text frame: its x axis becomes page y after rotation.
|
||||
clamp: bool,
|
||||
},
|
||||
Cell(Option<&'a para::CellText>),
|
||||
}
|
||||
|
||||
pub(crate) struct BoxInput<'a> {
|
||||
@@ -39,7 +32,7 @@ impl<'a> BoxInput<'a> {
|
||||
}
|
||||
|
||||
pub(crate) fn cell(story: StoryRef, blocks: &'a Blocks, prefix: &'a [u32], width: f32, cell: Option<&'a para::CellText>, depth: usize) -> Self {
|
||||
Self { story, blocks, prefix, width, context: BoxContext::Cell { text: cell, left: 0.0, right: width, clamp: false }, depth, frame: None }
|
||||
Self { story, blocks, prefix, width, context: BoxContext::Cell(cell), depth, frame: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,9 +191,9 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
|
||||
// inline (restored for the story around this box when it's done).
|
||||
let outer_hide = std::mem::replace(&mut ctx.hide_deleted, ctx.opts.hides_deleted(story));
|
||||
let empty_cell = para::CellText::default();
|
||||
let (in_cell, table, cell_edges) = match context {
|
||||
BoxContext::Other => (false, None, None),
|
||||
BoxContext::Cell { text, left, right, clamp } => (true, Some(text.unwrap_or(&empty_cell)), clamp.then_some((left, right))),
|
||||
let (table, in_cell) = match context {
|
||||
BoxContext::Other => (None, false),
|
||||
BoxContext::Cell(table) => (Some(table.unwrap_or(&empty_cell)), true),
|
||||
};
|
||||
// Word 16.113.3: the override applies in modes 11/12/14, but not mode 15.
|
||||
let allow_same_style_space =
|
||||
@@ -297,16 +290,7 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
|
||||
// Only paragraph-relative placement excludes space-before. Line anchors
|
||||
// and frameless page/margin fallbacks retain the text-top origin.
|
||||
let float_y = if float.v_rel == Anchor::Paragraph { para_top } else { y };
|
||||
let mut r = float_rect(frame, (0.0, width), float_y, w, h, float);
|
||||
// Only move an offset-positioned float when its box crosses the outer
|
||||
// cell edge. Cell text margins and wrap distances do not move the object.
|
||||
if let Some((left, right)) = cell_edges
|
||||
&& !float.outside_cell
|
||||
&& float.h_align.is_none()
|
||||
&& float.wrap != Wrap::InFrontOfText
|
||||
{
|
||||
r.x = keep_in_cell(r.x, r.w, left, right);
|
||||
}
|
||||
let r = float_rect(frame, (0.0, width), float_y, w, h, float);
|
||||
let outline = ctx.wrap_outline(o);
|
||||
excl.extend(wrap_area(r, float, outline.as_deref().map(Vec::as_slice)));
|
||||
floats.insert(oi, r);
|
||||
@@ -523,19 +507,6 @@ pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, te
|
||||
r
|
||||
}
|
||||
|
||||
/// Keep a float's box within the outer cell edges, relative to the cell's text origin.
|
||||
/// Offsets that fit are unchanged, including those inside the text margins. Wrap distances
|
||||
/// affect surrounding text only. An oversized object starts at the outer left edge.
|
||||
pub(crate) fn keep_in_cell(x: f32, w: f32, left: f32, right: f32) -> f32 {
|
||||
let left = if left.is_finite() { left } else { 0.0 };
|
||||
let end = right - w;
|
||||
if !end.is_finite() || end <= left {
|
||||
return left;
|
||||
}
|
||||
// max/min also place a non-finite offset at a finite edge without panicking.
|
||||
x.max(left).min(end)
|
||||
}
|
||||
|
||||
/// A text box's default internal margins (Word's: 0.1" left/right, 0.05" top/bottom); a box's
|
||||
/// own come from its [`wordcraft_doc::TextBody`].
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -275,14 +275,42 @@ fn cell_clip(clips: &Clips, story: StoryRef, path: &[u32]) -> Option<Rect> {
|
||||
/// The text boxes among `items` whose text is warped (Text Effects › Transform, drawn bent): their
|
||||
/// story, frame and warp. Empty, without a walk of the document, when none is.
|
||||
fn warped_boxes(doc: &Document, items: &[Placed]) -> Vec<(u32, Rect, wordcraft_doc::wordart::TextWarp)> {
|
||||
// Keep only requested offsets. A page may show a few boxes from a much longer paragraph,
|
||||
// and several placements may share an offset or a text-box story.
|
||||
let mut needed: HashMap<_, HashMap<_, Option<&wordcraft_doc::wordart::TextWarp>>> = HashMap::new();
|
||||
for it in items {
|
||||
crate::work::steps(1);
|
||||
if let Placed::Object { story, path, off, text_box: Some(_), .. } = it {
|
||||
needed.entry((*story, path)).or_default().insert(*off, None);
|
||||
}
|
||||
}
|
||||
for ((story, path), offsets) in &mut needed {
|
||||
let Some(p) = doc.para(*story, path) else { continue };
|
||||
offsets.retain(|off, _| p.text.get(*off..).is_some_and(|tail| tail.starts_with(wordcraft_doc::para::OBJ)));
|
||||
let Some(last) = offsets.keys().max().copied() else { continue };
|
||||
// Stop at the last requested byte offset, including when it precedes a long run of
|
||||
// ordinary text. Pair markers and objects exactly as Paragraph::objects_with_offsets.
|
||||
let markers = p
|
||||
.text
|
||||
.char_indices()
|
||||
.take_while(|(off, _)| *off <= last)
|
||||
.inspect(|_| crate::work::steps(1))
|
||||
.filter(|(_, c)| *c == wordcraft_doc::para::OBJ);
|
||||
for ((off, _), object) in markers.zip(&p.objects) {
|
||||
if let Some(warp) = offsets.get_mut(&off)
|
||||
&& let InlineObject::Shape { extra, .. } = object
|
||||
{
|
||||
*warp = extra.warp.as_ref().filter(|w| !w.is_plain());
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|it| {
|
||||
crate::work::steps(1);
|
||||
let Placed::Object { rect, story, path, off, text_box: Some(id), .. } = it else { return None };
|
||||
match doc.para(*story, path)?.object_at(*off)? {
|
||||
InlineObject::Shape { extra, .. } => extra.warp.clone().filter(|w| !w.is_plain()).map(|w| (*id, *rect, w)),
|
||||
_ => None,
|
||||
}
|
||||
let warp = needed.get(&(*story, path))?.get(off)?.as_ref()?;
|
||||
Some((*id, *rect, (*warp).clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -556,3 +584,174 @@ mod tests_clips {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_warp_lookups {
|
||||
use super::*;
|
||||
use wordcraft_doc::para::{OBJ, ShapeKind};
|
||||
use wordcraft_doc::wordart::TextWarp;
|
||||
use wordcraft_doc::{Block, CharProps, Paragraph, Path};
|
||||
|
||||
fn shape(warp: Option<TextWarp>) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 100.0,
|
||||
h: 30.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(7),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: wordcraft_doc::connector::ShapeExtra { warp, ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
fn placed(story: StoryRef, path: Path, off: usize, id: Option<u32>) -> Placed {
|
||||
Placed::Object {
|
||||
rect: Rect::new(off as f32, 2.0, 100.0, 30.0),
|
||||
spin: Default::default(),
|
||||
story,
|
||||
path,
|
||||
off,
|
||||
text_box: id,
|
||||
wrap: Default::default(),
|
||||
origin: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_warp_lookups_preserve_item_order_and_missing_objects() {
|
||||
let arch = TextWarp::new("textArchUp").unwrap();
|
||||
let wave = TextWarp::new("textWave1").unwrap();
|
||||
let objects = vec![
|
||||
InlineObject::BookmarkStart { name: "original".into() },
|
||||
shape(None),
|
||||
shape(TextWarp::new("textPlain")),
|
||||
shape(Some(arch.clone())),
|
||||
InlineObject::FieldEnd,
|
||||
shape(Some(wave.clone())),
|
||||
];
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("é{}λ", OBJ.to_string().repeat(objects.len() + 1));
|
||||
p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = objects;
|
||||
let mut d = Document::from_text("");
|
||||
d.body = vec![Block::Para(p).into()];
|
||||
let item = |index, id| placed(StoryRef::Body, Path::top(0), 2 + index * OBJ.len_utf8(), id);
|
||||
let items = vec![
|
||||
item(5, Some(11)),
|
||||
item(0, Some(8)),
|
||||
item(1, Some(9)),
|
||||
item(2, Some(10)),
|
||||
item(3, Some(11)),
|
||||
item(3, Some(12)),
|
||||
item(5, None),
|
||||
item(6, Some(13)),
|
||||
placed(StoryRef::Body, Path::top(0), 3, Some(14)),
|
||||
placed(StoryRef::Body, Path::top(0), 0, Some(14)),
|
||||
placed(StoryRef::Body, Path::top(1), 2, Some(14)),
|
||||
placed(StoryRef::Part(7), Path::top(0), 2, Some(14)),
|
||||
];
|
||||
let got = warped_boxes(&d, &items);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(11, Rect::new(17.0, 2.0, 100.0, 30.0), wave),
|
||||
(11, Rect::new(11.0, 2.0, 100.0, 30.0), arch.clone()),
|
||||
(12, Rect::new(11.0, 2.0, 100.0, 30.0), arch)
|
||||
]
|
||||
);
|
||||
// Extra stored objects without a matching marker are not addressable.
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.text.truncate(2 + 4 * OBJ.len_utf8());
|
||||
assert_eq!(warped_boxes(&d, &items), got[1..]);
|
||||
assert!(warped_boxes(&d, &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_warp_lookups_keep_paragraph_and_story_keys_separate() {
|
||||
let presets = ["textArchUp", "textWave1", "textArchDown", "textSlantUp"];
|
||||
let para = |preset| {
|
||||
let mut p = Paragraph::new();
|
||||
p.text = OBJ.to_string();
|
||||
p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = vec![shape(TextWarp::new(preset))];
|
||||
p
|
||||
};
|
||||
let mut d = Document::from_text("");
|
||||
d.body = vec![Block::Para(para(presets[0])).into(), Block::Para(para(presets[1])).into()];
|
||||
let mut table = wordcraft_doc::Table::new(1, 1, 100.0);
|
||||
table.rows[0].cells[0].blocks = vec![Block::Para(para(presets[2])).into()];
|
||||
d.body.push(Block::Table(table).into());
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, vec![Block::Para(para(presets[3])).into()]);
|
||||
let items = vec![
|
||||
placed(StoryRef::Body, Path::top(0), 0, Some(1)),
|
||||
placed(StoryRef::Body, Path::top(1), 0, Some(1)),
|
||||
placed(StoryRef::Body, Path(vec![2, 0, 0, 0]), 0, Some(1)),
|
||||
placed(StoryRef::Part(id), Path::top(0), 0, Some(1)),
|
||||
];
|
||||
assert_eq!(warped_boxes(&d, &items), presets.map(|p| (1, Rect::new(0.0, 2.0, 100.0, 30.0), TextWarp::new(p).unwrap())));
|
||||
}
|
||||
|
||||
/// #3614: deciding whether text boxes have a warp is part of every page repaint, including
|
||||
/// pages whose boxes are all plain. The steps (items looked at and paragraph characters
|
||||
/// walked, `crate::work`), not a timing: 8x the boxes must cost about 8x the steps, where a
|
||||
/// rescan of the paragraph prefix for every box would cost about 64x.
|
||||
#[test]
|
||||
fn text_box_warp_lookups_do_not_rescan_the_paragraph_prefix() {
|
||||
let make = |n: usize| {
|
||||
let mut objects = Vec::new();
|
||||
let mut items = Vec::new();
|
||||
for i in 0..n {
|
||||
objects.extend((0..16).map(|_| InlineObject::BookmarkEnd { name: "padding".into() }));
|
||||
items.push(placed(StoryRef::Body, Path::top(0), objects.len() * OBJ.len_utf8(), Some(i as u32)));
|
||||
objects.push(shape(None));
|
||||
}
|
||||
let mut p = Paragraph::new();
|
||||
p.text = OBJ.to_string().repeat(objects.len());
|
||||
p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = objects;
|
||||
let mut d = Document::from_text("");
|
||||
d.body = vec![Block::Para(p).into()];
|
||||
(d, items)
|
||||
};
|
||||
let (small, big) = (make(500), make(500 * crate::tests::SCALE));
|
||||
let scan = |(doc, items): &(Document, Vec<Placed>)| crate::work::count(|| assert!(std::hint::black_box(warped_boxes(doc, items)).is_empty()));
|
||||
let (s, b) = (scan(&small), scan(&big));
|
||||
let ratio = b as f64 / s.max(1) as f64;
|
||||
eprintln!("text-box warp discovery steps: {s} -> {b}, {ratio:.1}x for 8x boxes");
|
||||
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (crate::work)");
|
||||
assert!(ratio <= 1.5 * crate::tests::SCALE as f64, "text-box warp discovery rescanned paragraph prefixes: {ratio:.1}x ({s} -> {b} steps)");
|
||||
}
|
||||
|
||||
/// #3614: an invalid request must not extend the prefix walk into unrelated trailing text.
|
||||
#[test]
|
||||
fn text_box_warp_lookups_stop_at_the_last_valid_requested_offset() {
|
||||
let make = |suffix: usize| {
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("{OBJ}{}", "x".repeat(suffix));
|
||||
p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = vec![shape(TextWarp::new("textArchUp"))];
|
||||
let mut d = Document::from_text("");
|
||||
d.body = vec![Block::Para(p).into()];
|
||||
d
|
||||
};
|
||||
let (short, long) = (make(0), make(1_000_000));
|
||||
let items = vec![
|
||||
placed(StoryRef::Body, Path::top(0), 0, Some(7)),
|
||||
placed(StoryRef::Body, Path::top(0), usize::MAX, Some(8)),
|
||||
placed(StoryRef::Body, Path::top(0), 1, Some(9)),
|
||||
placed(StoryRef::Body, Path::top(0), OBJ.len_utf8(), Some(10)),
|
||||
];
|
||||
let expected = vec![(7, Rect::new(0.0, 2.0, 100.0, 30.0), TextWarp::new("textArchUp").unwrap())];
|
||||
assert_eq!(warped_boxes(&short, &items), expected);
|
||||
assert_eq!(warped_boxes(&long, &items), expected);
|
||||
let steps = |doc: &Document| crate::work::count(|| drop(std::hint::black_box(warped_boxes(doc, &items))));
|
||||
let (s, l) = (steps(&short), steps(&long));
|
||||
eprintln!("text-box warp suffix steps: {s} -> {l} with one million trailing bytes");
|
||||
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (crate::work)");
|
||||
assert_eq!(s, l, "text-box warp lookup walked the trailing text: {s} -> {l} steps");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,11 +324,11 @@ fn between(a: &Path, b: &Path, l: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// The position in turned text (a table cell's text direction) of `story` under page point
|
||||
/// (x, y), if there is turned text there in a paragraph `skip` doesn't leave out.
|
||||
fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32, skip: &dyn Fn(&Path) -> bool) -> Option<Pos> {
|
||||
/// (x, y), if there is turned text there.
|
||||
fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {
|
||||
items_of(page, story).find_map(|it| {
|
||||
let Placed::Lines { story: s, path, para, l0, l1, x: ox, y: oy, turn } = it else { return None };
|
||||
if *s != story || skip(path) || !it.turned_bounds()?.expand(2.0).contains(Point::new(x, y)) {
|
||||
if *s != story || !it.turned_bounds()?.expand(2.0).contains(Point::new(x, y)) {
|
||||
return None;
|
||||
}
|
||||
let (u, v) = crate::unturn_point(*turn, *ox, *oy, x, y);
|
||||
@@ -402,29 +402,22 @@ impl DocLayout {
|
||||
/// The position nearest to (x, y) on `page`, in `story`.
|
||||
pub fn hit(&self, page: usize, x: f32, y: f32, story: StoryRef) -> Option<Pos> {
|
||||
let p = self.pages.get(page)?;
|
||||
if let Some(pos) = turned_hit(p, story, x, y) {
|
||||
return Some(pos);
|
||||
}
|
||||
// Like Word, a click outside a floating table doesn't land in its cells while there's
|
||||
// other text on the page: below one, it goes to the text beside or after it (#2038).
|
||||
let floats = if story == StoryRef::Body { float_table_areas(p) } else { Vec::new() };
|
||||
let away = |path: &Path| floats.iter().any(|(t, r)| path.0.len() > t.0.len() && path.0.starts_with(&t.0) && !r.contains(Point::new(x, y)));
|
||||
// Turned cell text takes a click up to 2pt outside it, so outside a floating table too.
|
||||
if let Some(pos) = turned_hit(p, story, x, y, &away) {
|
||||
return Some(pos);
|
||||
}
|
||||
let lines = page_lines(p, story);
|
||||
if lines.is_empty() {
|
||||
// A page of turned text (a vertical section): the nearest turned line.
|
||||
return nearest_turned(p, story, x, y, &away).or_else(|| nearest_turned(p, story, x, y, &|_: &Path| false));
|
||||
}
|
||||
let nearest = |a: &&LineHit, b: &&LineHit| score(a, x, y).total_cmp(&score(b, x, y));
|
||||
let at = |l: &LineHit| Pos { story: l.story, path: l.path.clone(), off: l.para.off_at_x(l.li, x - l.x) };
|
||||
if let Some(best) = lines.iter().filter(|l| !away(l.path)).min_by(nearest) {
|
||||
return Some(at(best));
|
||||
}
|
||||
// Only a floating table's upright cells are left: other turned text first, then turned
|
||||
// text under the point (the table's too), then the nearest of those cells.
|
||||
nearest_turned(p, story, x, y, &away)
|
||||
.or_else(|| turned_hit(p, story, x, y, &|_: &Path| false))
|
||||
.or_else(|| lines.iter().min_by(nearest).map(at))
|
||||
let best = lines.iter().filter(|l| !away(l.path)).min_by(nearest).or_else(|| lines.iter().min_by(nearest))?;
|
||||
let off = best.para.off_at_x(best.li, x - best.x);
|
||||
Some(Pos { story: best.story, path: best.path.clone(), off })
|
||||
}
|
||||
|
||||
/// The story whose text is under a point (body, footnotes, endnotes, text boxes; not
|
||||
|
||||
@@ -515,10 +515,7 @@ impl<'e> Breaker<'e> {
|
||||
_ => 0.0,
|
||||
};
|
||||
if shift > 0.0 && slack > 0.0 {
|
||||
// Ordinary tabs move with paragraph alignment; positional tabs keep their anchors.
|
||||
let whole_line = display.is_none() && pl.label.is_none() && !self.rtl_para && !pl.ptabs.iter().any(|(ci, ..)| *ci >= c0 && *ci < c1);
|
||||
let shift_from = if whole_line { c0 } else { align_from };
|
||||
for v in sc.xs.iter_mut().skip(shift_from - c0) {
|
||||
for v in sc.xs.iter_mut().skip(align_from - c0) {
|
||||
*v += shift;
|
||||
}
|
||||
// After a tab the label stays, as the text before the tab does.
|
||||
|
||||
@@ -8,7 +8,6 @@ use wordcraft_doc::styles::{PartSet, TablePart, TableStyleProps};
|
||||
use wordcraft_doc::{Block, Blocks, Paragraph, Row, StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::boxes::BoxContext;
|
||||
use crate::para::{CellText, ClKind, ParaLayout};
|
||||
use crate::{BoxInput, BoxLayout, Ctx, Placed, layout_box};
|
||||
|
||||
@@ -622,13 +621,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
turned = Some(Turned { turn, len, across, snap, region, ci, top: margins[0] + band_t });
|
||||
(items, len)
|
||||
} else {
|
||||
let BoxLayout { mut items, mut height, floats_bottom, last_top, .. } = layout_box(
|
||||
ctx,
|
||||
BoxInput {
|
||||
context: BoxContext::Cell { text, left: -margins[1], right: x1 - x0 - margins[1], clamp: true },
|
||||
..BoxInput::cell(story, &cell.blocks, &cpath, cw, text, depth)
|
||||
},
|
||||
);
|
||||
let BoxLayout { mut items, mut height, floats_bottom, last_top, .. } =
|
||||
layout_box(ctx, BoxInput::cell(story, &cell.blocks, &cpath, cw, text, depth));
|
||||
// A hidden end-of-cell mark: an empty last paragraph takes no room (Word for
|
||||
// Windows: a row of empty such cells is only its borders).
|
||||
if cell.props.hide_mark && cell.blocks.last().and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty()) {
|
||||
|
||||
@@ -7,7 +7,6 @@ use wordcraft_doc::{Pos, Table};
|
||||
mod autospace;
|
||||
mod bidi;
|
||||
mod budget;
|
||||
mod cell_floats;
|
||||
mod complex;
|
||||
mod grid;
|
||||
mod ligature;
|
||||
@@ -1488,37 +1487,6 @@ fn right_tab_aligns_text_end() {
|
||||
assert!((end - 472.0).abs() < 1.0, "{end}");
|
||||
}
|
||||
|
||||
/// Native Word moves every ordinary-tab group with centered or right paragraph alignment.
|
||||
#[test]
|
||||
fn ordinary_tabs_move_as_line() {
|
||||
use wordcraft_doc::props::{TabAlign, TabLeader, TabStop};
|
||||
for stops in [
|
||||
vec![(180.0, TabAlign::Left)],
|
||||
vec![(234.0, TabAlign::Center)],
|
||||
vec![(234.0, TabAlign::Center), (364.5, TabAlign::Right)],
|
||||
vec![(234.0, TabAlign::Center), (468.0, TabAlign::Right)],
|
||||
] {
|
||||
let text = if stops.len() == 1 { "Label\tfilename" } else { "Label\tfilename\ttime" };
|
||||
let mut d = Document::from_text(text);
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| {
|
||||
p.tabs = Some(stops.iter().map(|(pos, align)| TabStop { pos: *pos, align: *align, leader: TabLeader::None }).collect());
|
||||
})
|
||||
.unwrap();
|
||||
let left = lay(&d);
|
||||
let end = left.caret(&Pos::body(0, text.len())).unwrap().x;
|
||||
let slack = 540.0 - end;
|
||||
for (align, fraction) in [(Align::Center, 0.5), (Align::Right, 1.0)] {
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.align = Some(align)).unwrap();
|
||||
let aligned = lay(&d);
|
||||
for off in [0, 6, text.len()] {
|
||||
let before = left.caret(&Pos::body(0, off)).unwrap().x;
|
||||
let after = aligned.caret(&Pos::body(0, off)).unwrap().x;
|
||||
assert!((after - before - slack * fraction).abs() < 0.1, "{align:?} {stops:?} offset {off}: {before} -> {after}, slack {slack}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph `before` + positional tab + `after`, with left and right indents and a custom
|
||||
/// left tab stop at 50 pt that the positional tab must ignore.
|
||||
fn ptab_doc(before: &str, after: &str, ptab: InlineObject, indents: (f32, f32)) -> Document {
|
||||
@@ -2267,162 +2235,6 @@ fn turned_up_cell_text_reads_bottom_to_top() {
|
||||
assert_eq!((hit.path, hit.off), (Path(vec![1, 0, 1, 0]), 0));
|
||||
}
|
||||
|
||||
/// #2038 with turned cell text: a click just outside a floating table, in the 2pt around turned
|
||||
/// text that still takes a click, goes to the text beside or after the table, not into a cell; a
|
||||
/// click on the table's edge still lands in one. A table that doesn't float keeps such a click.
|
||||
#[test]
|
||||
fn a_click_just_outside_a_floating_table_skips_its_turned_cell_text() {
|
||||
use TextDirection::{Down, Horizontal, Up};
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
// (the section's direction, the cells' direction, whether the table floats)
|
||||
let cases = [
|
||||
(Horizontal, Down, true),
|
||||
(Horizontal, Up, true),
|
||||
(Down, Horizontal, true),
|
||||
(Up, Horizontal, true),
|
||||
(Horizontal, Down, false),
|
||||
(Horizontal, Up, false),
|
||||
];
|
||||
for (section, cells, floats) in cases {
|
||||
let case = format!("section {section:?}, cells {cells:?}, floating {floats}");
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
d.last_section.text_direction = section;
|
||||
let mut t = Table::new(2, 2, 200.0);
|
||||
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Turned", Default::default()))];
|
||||
c.props.text_direction = cells;
|
||||
}
|
||||
if floats {
|
||||
t.props.float = Some(TableFloat {
|
||||
h_rel: Anchor::Column,
|
||||
v_rel: Anchor::Paragraph,
|
||||
x: 150.0,
|
||||
dist: [9.0, 0.0, 9.0, 0.0],
|
||||
overlap: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let page = &l.pages[0];
|
||||
assert_eq!(page.float_tables.len(), usize::from(floats), "{case}");
|
||||
let area = page
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| if let Placed::Cell { rect, .. } = it { Some(*rect) } else { None })
|
||||
.reduce(|a, b| a.union(&b))
|
||||
.unwrap();
|
||||
let turned: Vec<Rect> = page
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Body, path, .. } if path.0.len() > 1 => it.turned_bounds(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!turned.is_empty(), "{case}: the cells' text is turned");
|
||||
// Points on each side of the table (on its edge, and 1 and 2pt outside) across from turned
|
||||
// cell text, which takes a click up to 2pt from its bounds.
|
||||
let near = |p: &Point| turned.iter().any(|r| r.expand(2.0).contains(*p));
|
||||
let side = |r: &Rect, out: f32| {
|
||||
let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0);
|
||||
[Point::new(cx, area.y - out), Point::new(cx, area.bottom() + out), Point::new(area.x - out, cy), Point::new(area.right() + out, cy)]
|
||||
};
|
||||
let outside: Vec<Point> = turned.iter().flat_map(|r| [side(r, 1.0), side(r, 2.0)]).flatten().filter(near).collect();
|
||||
let edge: Vec<Point> = turned.iter().flat_map(|r| side(r, 0.0)).filter(near).collect();
|
||||
assert!(!outside.is_empty() && !edge.is_empty(), "{case}: turned text within 2pt of the edge {area:?}: {turned:?}");
|
||||
for p in outside {
|
||||
let hit = l.hit(0, p.x, p.y, StoryRef::Body).unwrap();
|
||||
assert_eq!(hit.path.0.len() == 1, floats, "{case}: a click at {p:?} landed in {:?}", hit.path);
|
||||
}
|
||||
for p in edge {
|
||||
let hit = l.hit(0, p.x, p.y, StoryRef::Body).unwrap();
|
||||
assert!(hit.path.0.len() > 1, "{case}: a click on the edge at {p:?} landed in {:?}", hit.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same rule when nothing upright but a floating table's cells is on the page (the page's
|
||||
/// paragraphs taken off the laid-out page): a click outside the table goes to other turned text
|
||||
/// there, and one by turned text of the table's own still lands in that cell, not in the nearest
|
||||
/// upright one.
|
||||
#[test]
|
||||
fn a_click_by_a_floating_table_with_no_upright_text_beside_it_prefers_turned_text() {
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
let table = |dirs: [TextDirection; 2], floats: bool| {
|
||||
let mut t = Table::new(1, 2, 200.0);
|
||||
for (c, dir) in t.rows[0].cells.iter_mut().zip(dirs) {
|
||||
c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Cell text", Default::default()))];
|
||||
c.props.text_direction = dir;
|
||||
}
|
||||
if floats {
|
||||
t.props.float = Some(TableFloat {
|
||||
h_rel: Anchor::Column,
|
||||
v_rel: Anchor::Paragraph,
|
||||
x: 150.0,
|
||||
dist: [9.0, 0.0, 9.0, 0.0],
|
||||
overlap: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
wordcraft_doc::Block::Table(t)
|
||||
};
|
||||
let lay_bare = |d: &Document| {
|
||||
let mut l = lay(d);
|
||||
l.pages[0].items.retain(|it| !matches!(it, Placed::Lines { path, .. } if path.0.len() == 1));
|
||||
l
|
||||
};
|
||||
let cell_area = |l: &DocLayout, t: u32| {
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Cell { rect, table, .. } if table.0 == [t] => Some(*rect),
|
||||
_ => None,
|
||||
})
|
||||
.reduce(|a, b| a.union(&b))
|
||||
.unwrap()
|
||||
};
|
||||
// A floating table with an upright cell and a turned one: a click 1pt above the turned
|
||||
// text lands in it, as it did before the floating-table rule.
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), table([TextDirection::Horizontal, TextDirection::Down], true)).unwrap();
|
||||
let l = lay_bare(&d);
|
||||
assert_eq!(l.pages[0].float_tables, [Path::top(1)]);
|
||||
let area = cell_area(&l, 1);
|
||||
let turned = turned_lines(&l).turned_bounds().unwrap();
|
||||
let p = Point::new(turned.x + turned.w / 2.0, area.y - 1.0);
|
||||
assert!(turned.expand(2.0).contains(p) && !area.contains(p), "{p:?} {turned:?} {area:?}");
|
||||
assert_eq!(l.hit(0, p.x, p.y, StoryRef::Body).unwrap().path, Path(vec![1, 0, 1, 0]));
|
||||
// An upright floating table and, below it, a table in the text flow with turned cells: a
|
||||
// click below the floating one goes to that turned text, not to the floating table.
|
||||
let mut d = Document::from_text("before\nbeside\nbeside\nbeside\nafter");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), table([TextDirection::Horizontal; 2], true)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(5), table([TextDirection::Down; 2], false)).unwrap();
|
||||
let l = lay_bare(&d);
|
||||
assert_eq!(l.pages[0].float_tables, [Path::top(1)]);
|
||||
let (area, flow) = (cell_area(&l, 1), cell_area(&l, 5));
|
||||
assert!(flow.y > area.bottom() + 12.0, "{area:?} {flow:?}");
|
||||
let hit = l.hit(0, area.x + area.w / 2.0, area.bottom() + 6.0, StoryRef::Body).unwrap();
|
||||
assert_eq!(hit.path.0.first(), Some(&5), "{hit:?}");
|
||||
// Both: a click 1pt above the floating table's turned cell goes to the other turned text,
|
||||
// which comes before the table's own.
|
||||
let mut d = Document::from_text(&format!("before\n{}after", "beside\n".repeat(6)));
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), table([TextDirection::Horizontal, TextDirection::Down], true)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(8), table([TextDirection::Down; 2], false)).unwrap();
|
||||
let l = lay_bare(&d);
|
||||
assert_eq!(l.pages[0].float_tables, [Path::top(1)]);
|
||||
let (area, flow) = (cell_area(&l, 1), cell_area(&l, 8));
|
||||
assert!(flow.y > area.bottom() + 12.0, "{area:?} {flow:?}");
|
||||
let turned = turned_lines(&l).turned_bounds().unwrap();
|
||||
let p = Point::new(turned.x + turned.w / 2.0, area.y - 1.0);
|
||||
assert!(turned.expand(2.0).contains(p) && !area.contains(p), "{p:?} {turned:?} {area:?}");
|
||||
let hit = l.hit(0, p.x, p.y, StoryRef::Body).unwrap();
|
||||
assert_eq!(hit.path.0.first(), Some(&8), "{hit:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turned_text_wraps_in_an_exact_row() {
|
||||
let d = turned_table(TextDirection::Down, &"word ".repeat(30), Some(72.0));
|
||||
@@ -6031,110 +5843,6 @@ fn floats_in_headers_and_table_cells_are_drawn() {
|
||||
assert!(c[0].x > 72.0 && c[0].x < 72.0 + 234.0, "inside the first cell: {:?}", c[0]);
|
||||
}
|
||||
|
||||
/// A float laid out in its cell (`layoutInCell`) whose offset would take it out of the cell is
|
||||
/// moved in to the cell's outer side (a letterhead logo anchored left of its header cell). One laid out on the page (`layoutInCell="0"`) keeps its offset.
|
||||
#[test]
|
||||
fn cell_floats_laid_out_in_the_cell_stay_in_it() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let cell_w = 234.0;
|
||||
let place = |x: f32, outside_cell: bool, wrap: Wrap| {
|
||||
let mut d = Document::from_text("Body text.");
|
||||
let mut t = Table::new(1, 2, 468.0);
|
||||
let mut cp = wordcraft_doc::Paragraph::with_text("cell", Default::default());
|
||||
let f = Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, outside_cell, ..Default::default() };
|
||||
cp.insert_object(0, rect_shape(80.0, 48.0, f), &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)];
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let c = shapes(&lay(&d).pages[0].items);
|
||||
assert_eq!(c.len(), 1, "cell float drawn");
|
||||
c[0].x
|
||||
};
|
||||
// An offset inside the cell is kept: the cell's text starts 20 pt left of this one.
|
||||
let text_left = place(20.0, false, Wrap::Tight) - 20.0;
|
||||
assert!(text_left > 72.0 && text_left < 72.0 + 12.0, "the cell's text starts past its margin: {text_left}");
|
||||
let cell_left = text_left - 5.4;
|
||||
// Anchored 95 pt left of the column: moved in to the cell's outer left side.
|
||||
let left = place(-95.0, false, Wrap::Tight);
|
||||
assert!((left - cell_left).abs() < 0.5, "pulled in to the left side: {left} vs {text_left}");
|
||||
// Past the right side: moved in to it.
|
||||
let right = place(cell_w + 40.0, false, Wrap::Tight);
|
||||
// (within a point: the cell's border room is not split evenly between its two sides)
|
||||
assert!((right - (cell_left + cell_w - 80.0)).abs() < 1.0, "pulled in to the right side: {right}");
|
||||
// Not laid out in the cell: the offset is kept, off the cell or not.
|
||||
let out = place(-95.0, true, Wrap::Tight);
|
||||
assert!((out - (text_left - 95.0)).abs() < 0.5, "layoutInCell=0 keeps the offset: {out}");
|
||||
// Front-of-text shapes do not wrap and keep their offsets even with layoutInCell on.
|
||||
for offset in [-95.0, cell_w + 40.0] {
|
||||
let front = place(offset, false, Wrap::InFrontOfText);
|
||||
assert!((front - (text_left + offset)).abs() < 0.5, "front-of-text offset preserved: {front}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Cell confinement uses the finite, nonnegative width that float placement actually draws.
|
||||
#[test]
|
||||
fn cell_float_bounds_use_the_placed_width() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let place = |width, x, wrap, outside_cell| {
|
||||
let mut d = Document::from_text("Body");
|
||||
let mut t = Table::new(1, 2, 468.0);
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("cell", Default::default());
|
||||
let f = Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, outside_cell, ..Default::default() };
|
||||
p.insert_object(0, rect_shape(width, 48.0, f), &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let objects = shapes(&lay(&d).pages[0].items);
|
||||
assert_eq!(objects.len(), 1);
|
||||
objects[0]
|
||||
};
|
||||
// Invalid stored widths are placed as zero-width objects; offsets at either side must
|
||||
// match an explicitly zero-width object, rather than inventing extra room in the cell.
|
||||
for offset in [-95.0, 300.0] {
|
||||
let zero = place(0.0, offset, Wrap::Tight, false);
|
||||
for stored in [-100.0, f32::NEG_INFINITY, f32::NAN, f32::INFINITY] {
|
||||
let placed = place(stored, offset, Wrap::Tight, false);
|
||||
assert_eq!(placed.w, 0.0);
|
||||
assert!((placed.x - zero.x).abs() < 0.01, "stored width {stored}, offset {offset}: {placed:?} vs {zero:?}");
|
||||
}
|
||||
// Neither opt-out path gains confinement from the width normalization.
|
||||
for (wrap, outside_cell) in [(Wrap::InFrontOfText, false), (Wrap::Tight, true)] {
|
||||
let zero = place(0.0, offset, wrap, outside_cell);
|
||||
let negative = place(-100.0, offset, wrap, outside_cell);
|
||||
assert_eq!(negative.w, zero.w);
|
||||
assert!((negative.x - zero.x).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_in_cell_bounds() {
|
||||
use super::boxes::keep_in_cell;
|
||||
assert_eq!(keep_in_cell(-95.0, 80.0, 0.0, 234.0), 0.0);
|
||||
assert_eq!(keep_in_cell(300.0, 80.0, 0.0, 234.0), 234.0 - 80.0);
|
||||
assert_eq!(keep_in_cell(20.0, 80.0, 0.0, 234.0), 20.0);
|
||||
// Wider than the cell, or exactly its width: starts at the outer left side.
|
||||
assert_eq!(keep_in_cell(50.0, 300.0, 0.0, 234.0), 0.0);
|
||||
assert_eq!(keep_in_cell(50.0, 234.0, 0.0, 234.0), 0.0);
|
||||
// Nearly full: no clearance is added to the object's position.
|
||||
assert_eq!(keep_in_cell(-95.0, 230.0, 0.0, 234.0), 0.0);
|
||||
assert_eq!(keep_in_cell(300.0, 230.0, 0.0, 234.0), 4.0);
|
||||
// Cell edges include its margins; negative offsets can be wholly inside the cell.
|
||||
assert_eq!(keep_in_cell(-5.0, 80.0, 0.0, 234.0), 0.0);
|
||||
assert_eq!(keep_in_cell(-5.0, 80.0, -4.0, 234.0), -4.0);
|
||||
assert_eq!(keep_in_cell(-5.15, 63.75, -5.4, 448.2), -5.15);
|
||||
assert_eq!(keep_in_cell(395.95, 47.25, -5.4, 448.2), 395.95);
|
||||
// Clamp limits stay finite and ordered even at small scales or with malformed geometry.
|
||||
for width in [0.1_f32, 0.3, 1.0, 234.0] {
|
||||
let object = width * 0.98;
|
||||
for offset in [-width, 0.0, width, f32::NAN, f32::INFINITY] {
|
||||
let x = keep_in_cell(offset, object, 0.0, width);
|
||||
assert!(x.is_finite() && x >= 0.0 && x + object <= width + f32::EPSILON * width.max(1.0));
|
||||
}
|
||||
}
|
||||
assert_eq!(keep_in_cell(10.0, 80.0, 0.0, f32::NAN), 0.0);
|
||||
assert_eq!(keep_in_cell(10.0, f32::NAN, 0.0, 234.0), 0.0);
|
||||
assert_eq!(keep_in_cell(10.0, 80.0, f32::NAN, 234.0), 10.0);
|
||||
}
|
||||
|
||||
/// Paragraph anchors exclude space-before; line and frameless page/margin anchors retain it.
|
||||
#[test]
|
||||
fn box_float_origins_respect_vertical_anchor_with_space_before() {
|
||||
@@ -13727,29 +13435,3 @@ fn a_table_moved_by_a_page_break_shows_its_new_page_number() {
|
||||
.collect();
|
||||
assert_eq!(shown, ["2"]);
|
||||
}
|
||||
|
||||
/// Cell confinement must not change page-vertical placement through the rotated text axis.
|
||||
#[test]
|
||||
fn turned_cell_float_keeps_its_along_text_offset() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let place = |direction, outside_cell| {
|
||||
let mut doc = Document::from_text("Synthetic body");
|
||||
let mut table = Table::new(1, 2, 468.0);
|
||||
table.rows[0].props.height = Some(100.0);
|
||||
table.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact;
|
||||
table.rows[0].cells[0].props.text_direction = direction;
|
||||
let mut para = Paragraph::with_text("Synthetic cell", Default::default());
|
||||
let float = Float { wrap: Wrap::Tight, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 150.0, outside_cell, ..Default::default() };
|
||||
para.insert_object(0, rect_shape(20.0, 10.0, float), &Default::default()).unwrap();
|
||||
table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(para)];
|
||||
doc.insert_block(StoryRef::Body, &Path::top(1), Block::Table(table)).unwrap();
|
||||
let objects = shapes(&lay(&doc).pages[0].items);
|
||||
assert_eq!(objects.len(), 1);
|
||||
objects[0]
|
||||
};
|
||||
for direction in [TextDirection::Down, TextDirection::Up] {
|
||||
let confined = place(direction, false);
|
||||
let reference = place(direction, true);
|
||||
assert!((confined.y - reference.y).abs() < 0.01, "{direction:?}: {confined:?} vs {reference:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
//! Synthetic letterhead geometry. No external documents or image assets are used.
|
||||
use super::*;
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::{Paragraph, PartKind};
|
||||
|
||||
fn picture(name: &str, w: f32, h: f32, x: f32, y: f32, wrap: Wrap, dist: f32) -> InlineObject {
|
||||
InlineObject::Image {
|
||||
media: name.into(),
|
||||
w,
|
||||
h,
|
||||
alt: name.into(),
|
||||
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, y, dist, ..Default::default() },
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_rect(items: &[Placed]) -> Rect {
|
||||
items.iter().find_map(|p| if let Placed::Cell { rect, .. } = p { Some(*rect) } else { None }).unwrap()
|
||||
}
|
||||
|
||||
/// A body-table letterhead: a central front-of-text box and two pictures close to the cell's
|
||||
/// outer edges. Both picture boxes fit; neither their offsets nor the box may move inward.
|
||||
#[test]
|
||||
fn cell_letterhead_keeps_picture_offsets_inside_the_outer_edges() {
|
||||
let mut d = Document::from_text("Synthetic body");
|
||||
d.last_section.page_w = 612.0;
|
||||
d.last_section.page_h = 792.0;
|
||||
d.last_section.margin_left = 70.9;
|
||||
d.last_section.margin_right = 31.5;
|
||||
d.last_section.margin_top = 31.2;
|
||||
let mut t = Table::new(1, 1, 453.6);
|
||||
t.props.style = None;
|
||||
t.props.width = Some(453.6);
|
||||
t.props.indent = Some(21.05);
|
||||
let border = Some(Border { style: BorderStyle::Single, width: 0.5, ..Default::default() });
|
||||
t.props.borders = Some(Borders { top: border, left: border, bottom: border, right: border, ..Default::default() });
|
||||
t.rows[0].props.height = Some(63.75);
|
||||
let mut p = Paragraph::default();
|
||||
let box_story = d.add_part(PartKind::TextBox, vec![wordcraft_doc::para_block(Paragraph::with_text("Synthetic letterhead", Default::default()))]);
|
||||
let mut central = rect_shape(332.25, 52.5, Float { wrap: Wrap::InFrontOfText, x: 59.9, y: 5.8, dist: 9.0, ..Default::default() });
|
||||
if let InlineObject::Shape { story, .. } = &mut central {
|
||||
*story = Some(box_story);
|
||||
}
|
||||
p.insert_object(0, central, &Default::default()).unwrap();
|
||||
p.insert_object(0, picture("right", 47.25, 61.6, 395.95, 1.4, Wrap::Through, 9.0), &Default::default()).unwrap();
|
||||
p.insert_object(0, picture("left", 63.75, 56.25, -5.15, 2.0, Wrap::Through, 9.0), &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
|
||||
let layout = lay(&d);
|
||||
let items = &layout.pages[0].items;
|
||||
let cell = cell_rect(items);
|
||||
let mut checked = 0;
|
||||
for item in items {
|
||||
if let Placed::Object { rect, origin, text_box, .. } = item {
|
||||
let (x, y) = if text_box.is_some() {
|
||||
(59.9, 5.8)
|
||||
} else if rect.w > 60.0 {
|
||||
(-5.15, 2.0)
|
||||
} else {
|
||||
(395.95, 1.4)
|
||||
};
|
||||
assert!(rect.x >= cell.x - 0.01 && rect.right() <= cell.right() + 0.01, "the original box fits the outer cell: {rect:?}, {cell:?}");
|
||||
assert!((rect.x - origin.x - x).abs() < 0.5, "offset {x} was moved: {rect:?}, origin {origin:?}");
|
||||
assert!((rect.y - origin.y - y).abs() < 0.01, "vertical placement is unchanged");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(checked, 3, "two pictures and one central text box");
|
||||
}
|
||||
|
||||
/// A centred header table wider than the text column, with a picture anchored far to its left.
|
||||
/// The picture touches the outer cell edge, not the text inset or the wrap-distance boundary.
|
||||
#[test]
|
||||
fn cell_letterhead_pulls_an_outside_picture_to_the_outer_left_edge() {
|
||||
for dist in [0.0, 9.0, 24.0] {
|
||||
let mut d = Document::from_text("Synthetic body");
|
||||
d.last_section.page_w = 612.0;
|
||||
d.last_section.page_h = 792.0;
|
||||
d.last_section.margin_left = 70.85;
|
||||
d.last_section.margin_right = 70.85;
|
||||
d.last_section.header = 5.65;
|
||||
let mut t = Table::new(1, 1, 498.05);
|
||||
t.props.style = None;
|
||||
t.props.width_pct = Some(105.9);
|
||||
t.props.align = Some(Align::Center);
|
||||
t.rows[0].props.height = Some(67.95);
|
||||
t.rows[0].cells[0].props.valign = Some(VAlign::Center);
|
||||
let mut p = Paragraph::with_text("Synthetic header", Default::default());
|
||||
p.insert_object(0, picture("logo", 79.0, 47.55, -94.7, 2.3, Wrap::BehindText, dist), &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
let header = d.add_part(PartKind::Header, vec![Arc::new(Block::Table(t))]);
|
||||
d.last_section.headers.default = Some(header);
|
||||
let layout = lay(&d);
|
||||
let items = &layout.pages[0].header;
|
||||
let cell = cell_rect(items);
|
||||
assert!((cell.x - 56.976).abs() < 0.5, "the centred outer cell begins at {cell:?}");
|
||||
let objects: Vec<_> =
|
||||
items.iter().filter_map(|p| if let Placed::Object { rect, origin, .. } = p { Some((*rect, *origin)) } else { None }).collect();
|
||||
assert_eq!(objects.len(), 1);
|
||||
let (rect, origin) = objects[0];
|
||||
assert!((rect.x - cell.x).abs() < 0.5, "wrap distance {dist}: picture {rect:?}, outer cell {cell:?}");
|
||||
assert!((rect.y - origin.y - 2.3).abs() < 0.01, "vertical placement is unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
/// Confinement follows the particular cell, including asymmetric margins mirrored for RTL.
|
||||
#[test]
|
||||
fn cell_float_outer_edges_include_asymmetric_and_mirrored_margins() {
|
||||
for rtl in [false, true] {
|
||||
for (offset, at_left) in [(-100.0, true), (300.0, false)] {
|
||||
let mut d = Document::from_text("Body");
|
||||
let mut t = Table::new(1, 2, 468.0);
|
||||
t.props.bidi_visual = rtl;
|
||||
t.props.cell_margins = Some([0.0, 12.0, 0.0, 3.0]);
|
||||
let mut p = Paragraph::default();
|
||||
p.insert_object(0, picture("logo", 40.0, 20.0, offset, 2.0, Wrap::BehindText, 9.0), &Default::default()).unwrap();
|
||||
t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
|
||||
let layout = lay(&d);
|
||||
let items = &layout.pages[0].items;
|
||||
let cell = items.iter().find_map(|p| if let Placed::Cell { rect, cell: 1, .. } = p { Some(*rect) } else { None }).unwrap();
|
||||
let picture = items.iter().find_map(|p| if let Placed::Image { rect, .. } = p { Some(*rect) } else { None }).unwrap();
|
||||
let edge = if at_left { picture.x - cell.x } else { picture.right() - cell.right() };
|
||||
assert!(edge.abs() < 0.5, "rtl {rtl}, offset {offset}: picture {picture:?}, cell {cell:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ impl WordApp {
|
||||
sent_full_screen,
|
||||
seen_full_screen,
|
||||
escape_free,
|
||||
status_counts,
|
||||
word_count,
|
||||
missing_fonts,
|
||||
last_autosave,
|
||||
equation_prev_tab,
|
||||
@@ -169,7 +169,7 @@ impl WordApp {
|
||||
sent_full_screen,
|
||||
seen_full_screen,
|
||||
escape_free,
|
||||
status_counts,
|
||||
word_count,
|
||||
missing_fonts,
|
||||
last_autosave,
|
||||
equation_prev_tab,
|
||||
|
||||
@@ -658,17 +658,6 @@ impl WordApp {
|
||||
self.read_system_animations();
|
||||
json!({"animations": self.ui.animations.code(), "enabled": self.animations_on(), "system": self.system_animations})
|
||||
}
|
||||
"ui.statusCount" => {
|
||||
// `words` (the default), `characters` or `charactersNoSpaces`: what the status
|
||||
// bar's live count shows; no value reads it (#2797).
|
||||
if let Some(v) = s("value") {
|
||||
match crate::StatusCount::from_code(v) {
|
||||
Some(c) => self.ui.status_count = c,
|
||||
None => return Some(Err(format!("unknown status count {v:?}; use words, characters or charactersNoSpaces"))),
|
||||
}
|
||||
}
|
||||
json!({"statusCount": self.ui.status_count.code()})
|
||||
}
|
||||
"ui.language" => {
|
||||
// `auto` (follow the system) or a language code; anything else is an error.
|
||||
if let Some(v) = s("value") {
|
||||
@@ -790,18 +779,6 @@ impl WordApp {
|
||||
}
|
||||
json!({"startScreen": self.ui.start_screen})
|
||||
}
|
||||
"ui.displayServer" => {
|
||||
// File › Options › Advanced › Linux display server (#2184): `value` `auto`,
|
||||
// `wayland` or `x11`; no value reads it. The desktop app reads it at the next launch.
|
||||
match p.get("value") {
|
||||
None => {}
|
||||
Some(v) => match v.as_str().and_then(crate::DisplayServer::from_code) {
|
||||
Some(d) => self.ui.display_server = d,
|
||||
None => return Some(Err(format!("unknown display server {v}; use auto, wayland or x11"))),
|
||||
},
|
||||
}
|
||||
json!({"displayServer": self.ui.display_server.code()})
|
||||
}
|
||||
// Shape Format › Text › Create Link: the next click on an empty text box links the
|
||||
// selected box to it (`shape.link` with that box as the target); Esc cancels.
|
||||
// Picture Format › Size › Crop: crop mode on the selected picture, or off (`crop`).
|
||||
@@ -1059,8 +1036,6 @@ pub(crate) fn works_without_document(id: &str) -> bool {
|
||||
| "ui.discord"
|
||||
| "ui.caretAnimation"
|
||||
| "ui.startScreen"
|
||||
| "ui.displayServer"
|
||||
| "ui.statusCount"
|
||||
| "ui.checkForUpdates"
|
||||
) || id.starts_with("help.")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::json;
|
||||
use wordcraft_doc::{PartKind, StoryRef};
|
||||
|
||||
use crate::theme::{Tokens, TypeRung, fit_text, medium, paint_text, regular, semibold, text_width};
|
||||
use crate::{StatusCount, StatusCounts, WordApp, icons};
|
||||
use crate::{WordApp, icons};
|
||||
|
||||
/// Why the caret's language isn't spell-checked (dictionary unreadable or missing), if it isn't.
|
||||
pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::PathBuf, String)>, missing: Option<&str>) -> Option<String> {
|
||||
@@ -627,29 +627,15 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
}
|
||||
// The live count: words (the default, as Word's status bar counts), characters
|
||||
// with spaces, or characters without spaces — whichever the person chose (#2797).
|
||||
let unit = app.ui.status_count;
|
||||
let counts = app.cached_status_counts();
|
||||
let words = app.cached_word_count();
|
||||
// A selected picture/shape/text box isn't a text selection: show the total.
|
||||
let wtxt = if app.session.sel.is_collapsed() || crate::objects::selected(app).is_some() {
|
||||
status_count_text(unit, counts.in_unit(unit), None)
|
||||
crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())])
|
||||
} else {
|
||||
let selected = crate::count_in_unit(&app.session.selected_text(), unit);
|
||||
status_count_text(unit, counts.in_unit(unit), Some(selected))
|
||||
let sw = wordcraft_doc::count_words(&app.session.selected_text());
|
||||
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())])
|
||||
};
|
||||
let counted = st(ui, &wtxt);
|
||||
// Right-click, as Word's status bar offers: choose what the count shows (#2797).
|
||||
counted.context_menu(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
for c in crate::StatusCount::ALL {
|
||||
if ui.selectable_label(app.ui.status_count == c, tl!(c.label())).clicked() {
|
||||
let _ = app.run("ui.statusCount", json!({"value": c.code()}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if counted.clicked() {
|
||||
if st(ui, &wtxt).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
|
||||
}
|
||||
if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) {
|
||||
@@ -917,43 +903,14 @@ impl WordApp {
|
||||
wordcraft_engine::cmd::font::warns(font, &shown).then_some(shown)
|
||||
}
|
||||
|
||||
/// The document's counts, recomputed only when the document (or the count option) changes:
|
||||
/// words as the status bar and the Word Count dialog count them, and characters with and
|
||||
/// without spaces as the dialog does (#2797).
|
||||
pub fn cached_status_counts(&mut self) -> StatusCounts {
|
||||
// The count option changes the counts without a document change.
|
||||
/// Word count, recomputed only when the document changes.
|
||||
pub fn cached_word_count(&mut self) -> usize {
|
||||
// The count option changes the count without a document change.
|
||||
let key = self.session.rev().wrapping_mul(2) | u64::from(self.session.prefs.count_notes);
|
||||
if self.status_counts.0 != key {
|
||||
let text = self.session.counted_text();
|
||||
self.status_counts = (
|
||||
key,
|
||||
StatusCounts {
|
||||
words: self.session.word_count(),
|
||||
chars: wordcraft_doc::count_chars(&text),
|
||||
chars_no_spaces: wordcraft_doc::count_chars_no_spaces(&text),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.status_counts.1
|
||||
}
|
||||
}
|
||||
|
||||
/// The status bar's count line (#2797): the whole document's `total`, or "selected of total" when
|
||||
/// part of it is selected, worded as the Word Count dialog words the same counts.
|
||||
pub(crate) fn status_count_text(unit: StatusCount, total: usize, selected: Option<usize>) -> String {
|
||||
match (unit, selected) {
|
||||
(StatusCount::Words, None) => crate::i18n::fmt(tl!("{words} words"), &[("words", &total.to_string())]),
|
||||
(StatusCount::Words, Some(n)) => {
|
||||
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &n.to_string()), ("words", &total.to_string())])
|
||||
}
|
||||
(StatusCount::Characters, None) => crate::i18n::fmt(tl!("{chars} characters"), &[("chars", &total.to_string())]),
|
||||
(StatusCount::Characters, Some(n)) => {
|
||||
crate::i18n::fmt(tl!("{selected} of {chars} characters"), &[("selected", &n.to_string()), ("chars", &total.to_string())])
|
||||
}
|
||||
(StatusCount::CharactersNoSpaces, None) => crate::i18n::fmt(tl!("{chars} characters (no spaces)"), &[("chars", &total.to_string())]),
|
||||
(StatusCount::CharactersNoSpaces, Some(n)) => {
|
||||
crate::i18n::fmt(tl!("{selected} of {chars} characters (no spaces)"), &[("selected", &n.to_string()), ("chars", &total.to_string())])
|
||||
if self.word_count.0 != key {
|
||||
self.word_count = (key, self.session.word_count());
|
||||
}
|
||||
self.word_count.1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,113 +91,6 @@ fn status_bar_controls_hold_still_across_zoom_levels() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2797: the count line's wording per unit — words (the default), characters with spaces,
|
||||
/// characters without spaces — whole document or "selected of total".
|
||||
#[test]
|
||||
fn the_count_line_words_each_unit() {
|
||||
for (unit, total, selected, expected) in [
|
||||
(StatusCount::Words, 12, None, "12 words"),
|
||||
(StatusCount::Words, 12, Some(3), "3 of 12 words"),
|
||||
(StatusCount::Characters, 23, None, "23 characters"),
|
||||
(StatusCount::Characters, 23, Some(6), "6 of 23 characters"),
|
||||
(StatusCount::CharactersNoSpaces, 21, None, "21 characters (no spaces)"),
|
||||
(StatusCount::CharactersNoSpaces, 21, Some(6), "6 of 21 characters (no spaces)"),
|
||||
] {
|
||||
assert_eq!(status_count_text(unit, total, selected), expected, "{unit:?} with {selected:?} selected");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2797: the status bar's totals are the Word Count dialog's numbers (words, characters with
|
||||
/// spaces, characters without spaces), and its selection line counts the selection the same way.
|
||||
#[test]
|
||||
fn the_status_bar_totals_are_the_word_count_dialogs_numbers() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.run("text.insert", json!({"text": "Hello world.\nSecond line"})).unwrap();
|
||||
// Straight from the engine: `a.run` would only open the Word Count dialog (#240).
|
||||
let stats = a.session.run("review.wordCount", &json!({})).unwrap();
|
||||
let counts = a.cached_status_counts();
|
||||
assert_eq!(counts.words, stats["words"].as_u64().unwrap_or_default() as usize, "words, as the dialog counts them");
|
||||
assert_eq!(counts.chars, stats["charactersWithSpaces"].as_u64().unwrap_or_default() as usize, "characters with spaces");
|
||||
assert_eq!(counts.chars_no_spaces, stats["characters"].as_u64().unwrap_or_default() as usize, "characters without spaces");
|
||||
// "world." selected: its own count of the chosen unit over the document's total.
|
||||
a.run("select.text", json!({"text": "world."})).unwrap();
|
||||
let line = |unit: StatusCount| {
|
||||
let selected = crate::count_in_unit(&a.session.selected_text(), unit);
|
||||
status_count_text(unit, counts.in_unit(unit), Some(selected))
|
||||
};
|
||||
assert_eq!(line(StatusCount::Words), "1 of 4 words");
|
||||
assert_eq!(line(StatusCount::Characters), "6 of 23 characters");
|
||||
assert_eq!(line(StatusCount::CharactersNoSpaces), "6 of 21 characters (no spaces)");
|
||||
}
|
||||
|
||||
/// #2797: the status bar draws the unit the person chose, and right-clicking the count offers
|
||||
/// the three units, the choice taking effect at once and kept between runs.
|
||||
#[test]
|
||||
fn the_status_bar_count_is_chosen_from_its_context_menu() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.run("text.insert", json!({"text": "Hello world.\nSecond line"})).unwrap();
|
||||
let ctx = ctx();
|
||||
ctx.enable_accesskit();
|
||||
let mut n = 0;
|
||||
// What the status bar drew last frame: each widget's label and where it sits.
|
||||
let drawn = |a: &mut WordApp, n: &mut u32, events: Vec<egui::Event>| -> Vec<(String, egui::Rect)> {
|
||||
*n += 1;
|
||||
let input = egui::RawInput {
|
||||
time: Some(f64::from(*n) / 60.0),
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| status_bar(a, ui));
|
||||
out.textures_delta.clear();
|
||||
let update = out.platform_output.accesskit_update.take().unwrap();
|
||||
update
|
||||
.nodes
|
||||
.into_iter()
|
||||
.filter_map(|(_, node)| {
|
||||
let b = node.bounds()?;
|
||||
Some((node.label()?.to_string(), egui::Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let at =
|
||||
|seen: &[(String, egui::Rect)], label: &str| seen.iter().find(|(l, _)| l == label).unwrap_or_else(|| panic!("{label} is drawn")).1.center();
|
||||
let button = |at: egui::Pos2, button: egui::PointerButton, pressed: bool| egui::Event::PointerButton {
|
||||
pos: at,
|
||||
button,
|
||||
pressed,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
// Words are counted: the default unit.
|
||||
let mut seen = drawn(&mut a, &mut n, vec![]);
|
||||
assert!(seen.iter().any(|(l, _)| l == "4 words"), "{seen:?}");
|
||||
// Right-click the count: the menu lists the three units.
|
||||
let count = at(&seen, "4 words");
|
||||
drawn(&mut a, &mut n, vec![egui::Event::PointerMoved(count), button(count, egui::PointerButton::Secondary, true)]);
|
||||
drawn(&mut a, &mut n, vec![button(count, egui::PointerButton::Secondary, false)]);
|
||||
seen = drawn(&mut a, &mut n, vec![]);
|
||||
for label in ["Words", "Characters (with spaces)", "Characters (no spaces)"] {
|
||||
assert!(seen.iter().any(|(l, _)| l == label), "the menu lists {label}: {seen:?}");
|
||||
}
|
||||
// Pick characters with spaces: the count changes at once, with no document change.
|
||||
let pick = at(&seen, "Characters (with spaces)");
|
||||
drawn(&mut a, &mut n, vec![egui::Event::PointerMoved(pick), button(pick, egui::PointerButton::Primary, true)]);
|
||||
drawn(&mut a, &mut n, vec![button(pick, egui::PointerButton::Primary, false)]);
|
||||
assert_eq!(a.ui.status_count, StatusCount::Characters, "the menu's choice is taken");
|
||||
assert_eq!(a.run("ui.statusCount", json!({})).unwrap()["statusCount"], "characters", "and answered to scripts");
|
||||
seen = drawn(&mut a, &mut n, vec![]);
|
||||
assert!(seen.iter().any(|(l, _)| l == "23 characters"), "{seen:?}");
|
||||
// With a selection: "selected of total".
|
||||
a.run("select.text", json!({"text": "world."})).unwrap();
|
||||
seen = drawn(&mut a, &mut n, vec![]);
|
||||
assert!(seen.iter().any(|(l, _)| l == "6 of 23 characters"), "{seen:?}");
|
||||
// The choice comes back after a restart.
|
||||
let mut b = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
let saved = serde_json::to_vec(&a.prefs()).unwrap();
|
||||
b.apply_prefs(crate::UiState::from_json(&saved).unwrap());
|
||||
assert_eq!(b.ui.status_count, StatusCount::Characters, "kept between runs");
|
||||
}
|
||||
|
||||
/// #2402: a long message (the path of a file that isn't there) is shortened so that it stops
|
||||
/// before the controls on the right, instead of being drawn over the Focus button.
|
||||
#[test]
|
||||
|
||||
@@ -75,7 +75,7 @@ impl WordApp {
|
||||
self.read_aloud_at = None;
|
||||
self.info_editing = None;
|
||||
// Caches keyed by the revision alone: another document's revision can be the same number.
|
||||
self.status_counts = crate::window_ui::NOT_COUNTED;
|
||||
self.word_count = crate::window_ui::NOT_COUNTED;
|
||||
self.find_fonts_for = None;
|
||||
self.previews.document_changed();
|
||||
// The Start screen's placeholder (#1129) goes once a document is open beside it.
|
||||
|
||||
+1
-147
@@ -138,142 +138,6 @@ struct AwaitingSave {
|
||||
rev: u64,
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Linux display server (#2184, `ui.displayServer`): how the desktop
|
||||
/// app connects on Linux and the BSDs. It's read before the window opens, so a change applies at
|
||||
/// the next launch; `WORDCRAFT_WAYLAND` overrides it. An unknown saved value reads as Automatic.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum DisplayServer {
|
||||
/// The app's own choice: XWayland whenever there's an X server, for file drag-and-drop.
|
||||
#[default]
|
||||
Auto,
|
||||
Wayland,
|
||||
X11,
|
||||
}
|
||||
|
||||
impl DisplayServer {
|
||||
/// In menu order.
|
||||
pub const ALL: [DisplayServer; 3] = [DisplayServer::Auto, DisplayServer::Wayland, DisplayServer::X11];
|
||||
|
||||
/// The value used in `ui.json` and by the `ui.displayServer` command.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
DisplayServer::Auto => "auto",
|
||||
DisplayServer::Wayland => "wayland",
|
||||
DisplayServer::X11 => "x11",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(code: &str) -> Option<DisplayServer> {
|
||||
DisplayServer::ALL.into_iter().find(|d| d.code().eq_ignore_ascii_case(code.trim()))
|
||||
}
|
||||
|
||||
/// The English label; only Automatic is translated, the others are names.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
DisplayServer::Auto => "Automatic",
|
||||
DisplayServer::Wayland => "Wayland",
|
||||
DisplayServer::X11 => "X11 (XWayland)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the status bar's live count shows (#2797): words (the default, as Word's status bar
|
||||
/// counts), characters with spaces, or characters without spaces — the same three the Word Count
|
||||
/// dialog reports, worded the same way. Right-click the count in the status bar, or File ›
|
||||
/// Options › Display, to choose; kept between runs in `ui.json`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum StatusCount {
|
||||
#[default]
|
||||
Words,
|
||||
Characters,
|
||||
CharactersNoSpaces,
|
||||
}
|
||||
|
||||
impl StatusCount {
|
||||
/// In menu order.
|
||||
pub const ALL: [StatusCount; 3] = [StatusCount::Words, StatusCount::Characters, StatusCount::CharactersNoSpaces];
|
||||
|
||||
/// The value used in `ui.json` and by the `ui.statusCount` command.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
StatusCount::Words => "words",
|
||||
StatusCount::Characters => "characters",
|
||||
StatusCount::CharactersNoSpaces => "charactersNoSpaces",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(code: &str) -> Option<StatusCount> {
|
||||
StatusCount::ALL.into_iter().find(|c| c.code().eq_ignore_ascii_case(code.trim()))
|
||||
}
|
||||
|
||||
/// The English label (translated with `tl!` where shown): the Word Count dialog's own words
|
||||
/// for these counts.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
StatusCount::Words => "Words",
|
||||
StatusCount::Characters => "Characters (with spaces)",
|
||||
StatusCount::CharactersNoSpaces => "Characters (no spaces)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DisplayServer {
|
||||
fn from(s: String) -> Self {
|
||||
DisplayServer::from_code(&s).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DisplayServer> for String {
|
||||
fn from(d: DisplayServer) -> Self {
|
||||
d.code().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown saved value reads as the default rather than discarding the whole `ui.json`.
|
||||
impl From<String> for StatusCount {
|
||||
fn from(s: String) -> Self {
|
||||
StatusCount::from_code(&s).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StatusCount> for String {
|
||||
fn from(c: StatusCount) -> Self {
|
||||
c.code().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole-document counts the status bar shows (#2797), computed together whenever the document
|
||||
/// changes: words ([`wordcraft_engine::Session::word_count`]), characters and characters without
|
||||
/// spaces (the Word Count dialog's [`wordcraft_doc::count_chars`] and [`wordcraft_doc::count_chars_no_spaces`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StatusCounts {
|
||||
pub words: usize,
|
||||
pub chars: usize,
|
||||
pub chars_no_spaces: usize,
|
||||
}
|
||||
|
||||
impl StatusCounts {
|
||||
/// The total in the unit the status bar is showing.
|
||||
pub fn in_unit(self, unit: StatusCount) -> usize {
|
||||
match unit {
|
||||
StatusCount::Words => self.words,
|
||||
StatusCount::Characters => self.chars,
|
||||
StatusCount::CharactersNoSpaces => self.chars_no_spaces,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` (a selection, say) counted in the unit the status bar is showing.
|
||||
pub fn count_in_unit(s: &str, unit: StatusCount) -> usize {
|
||||
match unit {
|
||||
StatusCount::Words => wordcraft_doc::count_words(s),
|
||||
StatusCount::Characters => wordcraft_doc::count_chars(s),
|
||||
StatusCount::CharactersNoSpaces => wordcraft_doc::count_chars_no_spaces(s),
|
||||
}
|
||||
}
|
||||
|
||||
/// UI state that persists between runs (and that agents can read and set).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -419,13 +283,6 @@ pub struct UiState {
|
||||
pub update_fields_before_print: bool,
|
||||
/// The custom dictionary (File › Options › Proofing, Add to Dictionary), kept between runs.
|
||||
pub dictionary: Vec<String>,
|
||||
/// File › Options › Advanced › Linux display server (#2184): read by the desktop app before
|
||||
/// the window opens.
|
||||
pub display_server: DisplayServer,
|
||||
/// What the status bar's live count shows (#2797): words (the default), characters with
|
||||
/// spaces, or characters without spaces. Right-click the count in the status bar, or File ›
|
||||
/// Options › Display. Junk saved values read as words.
|
||||
pub status_count: StatusCount,
|
||||
}
|
||||
|
||||
/// The ribbon tab the app opens on, and the one it falls back to when the tab that was showing
|
||||
@@ -501,8 +358,6 @@ impl Default for UiState {
|
||||
print_backgrounds: false,
|
||||
update_fields_before_print: false,
|
||||
dictionary: Vec::new(),
|
||||
display_server: DisplayServer::Auto,
|
||||
status_count: StatusCount::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -732,8 +587,7 @@ pub struct WordApp {
|
||||
/// The document's fonts that aren't installed, for the status bar and the font box, and the
|
||||
/// layout they were read from.
|
||||
pub missing_fonts: crate::chrome::MissingFontsMemo,
|
||||
/// The document's [`StatusCounts`] and the key they were computed for; see [`WordApp::cached_status_counts`].
|
||||
pub status_counts: (u64, StatusCounts),
|
||||
pub word_count: (u64, usize),
|
||||
last_autosave: f64,
|
||||
/// The tab shown before the Equation tab came up (restored when editing ends).
|
||||
pub(crate) equation_prev_tab: Option<String>,
|
||||
|
||||
@@ -313,9 +313,6 @@ fn display(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
numeral_picker(app, ui);
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Status bar");
|
||||
status_count_picker(app, ui);
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Printing");
|
||||
ui.checkbox(&mut app.ui.print_backgrounds, tl!("Print the page color"));
|
||||
ui.checkbox(&mut app.ui.update_fields_before_print, tl!("Update fields before printing"));
|
||||
@@ -648,11 +645,6 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
|
||||
{
|
||||
let _ = app.run("ui.caretAnimation", json!({"value": caret}));
|
||||
}
|
||||
if cfg!(all(unix, not(target_os = "macos"))) {
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Linux display server");
|
||||
display_server_picker(app, ui);
|
||||
}
|
||||
// The web app has no folders to name.
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
ui.add_space(8.0);
|
||||
@@ -726,25 +718,6 @@ fn theme_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What the status bar's live count shows (#2797): words (as Word's status bar counts), characters
|
||||
/// with spaces, or characters without spaces. Right-clicking the count in the status bar offers
|
||||
/// the same choices.
|
||||
fn status_count_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::StatusCount;
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Status bar count:"));
|
||||
let combo = craft_ui::ComboBox::from_id_salt("status_count").selected_text(tl!(app.ui.status_count.label())).width(220.0).show_ui(ui, |ui| {
|
||||
for c in StatusCount::ALL {
|
||||
if ui.selectable_label(app.ui.status_count == c, tl!(c.label())).clicked() {
|
||||
let _ = app.run("ui.statusCount", json!({"value": c.code()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(label.id);
|
||||
});
|
||||
note(ui, "You can also right-click the count in the status bar to change it.");
|
||||
}
|
||||
|
||||
/// Interface size (#475, #822): Auto, which keeps the system's display scale and shows the size it
|
||||
/// draws at, or an absolute size, so "100%" means one screen pixel per point.
|
||||
fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
@@ -806,25 +779,6 @@ fn animations_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux display server (#2184): Automatic, Wayland or X11 (XWayland). The desktop app reads it
|
||||
/// before the window opens, so a change applies at the next launch.
|
||||
fn display_server_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::DisplayServer;
|
||||
let name = |d: DisplayServer| if d == DisplayServer::Auto { tl!(d.label()) } else { d.label() };
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Display server:"));
|
||||
let combo = craft_ui::ComboBox::from_id_salt("display_server").selected_text(name(app.ui.display_server)).width(220.0).show_ui(ui, |ui| {
|
||||
for d in DisplayServer::ALL {
|
||||
if ui.selectable_label(app.ui.display_server == d, name(d)).clicked() {
|
||||
let _ = app.run("ui.displayServer", json!({"value": d.code()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(label.id);
|
||||
});
|
||||
note(ui, "Applies the next time WordCraft starts.");
|
||||
}
|
||||
|
||||
/// Interface language: follow the system (the default) or pick one (#8).
|
||||
fn language_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::i18n::{AUTO, Lang};
|
||||
|
||||
@@ -5,7 +5,7 @@ use wordcraft_geom::Unit;
|
||||
use super::{decimal, length, parse_length, unit};
|
||||
|
||||
use crate::theme::Appearance;
|
||||
use crate::{Services, StatusCount, UiState, WordApp};
|
||||
use crate::{Services, UiState, WordApp};
|
||||
|
||||
#[test]
|
||||
fn unknown_settings_keys_load_in_linear_time() {
|
||||
@@ -155,7 +155,6 @@ fn options_survive_a_restart_and_junk_in_ui_json() {
|
||||
.unwrap();
|
||||
a.ui.recent_count = 30;
|
||||
a.ui.print_backgrounds = true;
|
||||
a.ui.status_count = StatusCount::CharactersNoSpaces;
|
||||
let saved = serde_json::to_vec(&a.prefs()).unwrap();
|
||||
let mut b = app();
|
||||
b.apply_prefs(UiState::from_json(&saved).unwrap());
|
||||
@@ -163,16 +162,13 @@ fn options_survive_a_restart_and_junk_in_ui_json() {
|
||||
assert_eq!(b.session.prefs.units, Some(Unit::Centimeters));
|
||||
assert_eq!(b.session.initials, "VK");
|
||||
assert_eq!((b.ui.recent_count, b.ui.print_backgrounds), (30, true));
|
||||
assert_eq!(b.ui.status_count, StatusCount::CharactersNoSpaces, "#2797: what the status bar counts");
|
||||
assert!(wordcraft_engine::proof::user_dictionary().contains(&"zqxoptionsword".to_string()));
|
||||
|
||||
let junk = br#"{"theme": "dark", "recentCount": "lots", "printBackgrounds": 7, "optionsPane": ["x"], "dark": true,
|
||||
"statusCount": "paragraphs",
|
||||
"editing": {"measurementUnits": "furlongs", "typingReplacesSelection": false, "marks": {"tabs": "yes", "spaces": true}}}"#;
|
||||
let ui = UiState::from_json(junk).unwrap();
|
||||
assert_eq!(ui.theme, Appearance::Dark);
|
||||
assert_eq!((ui.recent_count, ui.print_backgrounds, ui.options_pane.as_str()), (12, false, "general"));
|
||||
assert_eq!(ui.status_count, StatusCount::Words, "#2797: a wrong value reads the default");
|
||||
assert_eq!(ui.legacy_dark, Some(true));
|
||||
assert_eq!(ui.editing.units, None);
|
||||
assert!(!ui.editing.typing_replaces_selection);
|
||||
@@ -250,14 +246,6 @@ fn options_panes_open_and_change_settings() {
|
||||
h.get_by_label("Spaces").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.prefs.marks.spaces, "Display › Spaces");
|
||||
// #2797: what the status bar's count shows, chosen here as in its right-click menu. The
|
||||
// Display pane's other combo box is Numerals, drawn above this one.
|
||||
h.get_all_by_role(egui::accesskit::Role::ComboBox).last().expect("the status bar count picker").click();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Characters (with spaces)").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.status_count, StatusCount::Characters, "Display › Status bar count");
|
||||
assert_eq!(h.state_mut().run("ui.statusCount", json!({})).unwrap()["statusCount"], "characters", "the command answers the same");
|
||||
h.get_by_label("Proofing").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.options_pane, "proofing");
|
||||
|
||||
@@ -23,7 +23,10 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) {
|
||||
if ra.state() == State::Playing {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(120));
|
||||
}
|
||||
if let Some(e) = error_notice(&mut app.read_aloud_error, app.session.read_aloud.error()) {
|
||||
if let Some(e) = app.session.read_aloud.error()
|
||||
&& app.read_aloud_error.as_deref() != Some(e.as_str())
|
||||
{
|
||||
app.read_aloud_error = Some(e.clone());
|
||||
app.status(e);
|
||||
}
|
||||
// The caret follows the reading only in the document being read.
|
||||
@@ -38,23 +41,6 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The notice for the player's error `now`, given the one last `shown`: each message is shown
|
||||
/// once, and when the error clears (Play after a halt) the last one is forgotten, so the same
|
||||
/// message coming back is shown again.
|
||||
fn error_notice(shown: &mut Option<String>, now: Option<String>) -> Option<String> {
|
||||
match now {
|
||||
None => {
|
||||
*shown = None;
|
||||
None
|
||||
}
|
||||
Some(e) if shown.as_deref() == Some(e.as_str()) => None,
|
||||
Some(e) => {
|
||||
*shown = Some(e.clone());
|
||||
Some(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The page rectangles (in page units) of the sentence being read, to highlight.
|
||||
pub fn highlight(app: &mut WordApp) -> Option<(wordcraft_engine::doc::Pos, wordcraft_engine::doc::Pos)> {
|
||||
if !app.session.read_aloud.open || !reads_shown_document(app) {
|
||||
@@ -184,19 +170,3 @@ fn button(ui: &mut Ui, t: &Tokens, icon: &str, tip: &str, enabled: bool) -> bool
|
||||
crate::icons::paint(ui.painter(), r, icon, c, if enabled { t.accent } else { Color32::TRANSPARENT });
|
||||
enabled && resp.clicked()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::error_notice;
|
||||
|
||||
#[test]
|
||||
fn the_same_error_after_play_is_shown_again() {
|
||||
let mut shown = None;
|
||||
let halt = || Some("stopped for another program".to_string());
|
||||
assert_eq!(error_notice(&mut shown, halt()), halt(), "shown once");
|
||||
assert_eq!(error_notice(&mut shown, halt()), None, "not on every frame");
|
||||
// Play clears the error; the same halt later is a new notice.
|
||||
assert_eq!(error_notice(&mut shown, None), None);
|
||||
assert_eq!(error_notice(&mut shown, halt()), halt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +63,12 @@ pub struct Services {
|
||||
/// Desktop: the HTML on the system clipboard — another program's formatted copy (a Word
|
||||
/// document, a web page) — if any. Paste keeps its formatting (#687); see [`system_clipboard`].
|
||||
pub clipboard_html: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
/// Desktop: put a copy on the system clipboard as (text, html, rtf, mathml): its text, with
|
||||
/// its HTML (equations as MathML, #816) and RTF beside it where the platform takes them, so
|
||||
/// other programs keep the formatting (#1523); an empty one isn't offered. `mathml` is a copy
|
||||
/// of one equation alone (else empty), for the platform's MathML formats, which Word reads a
|
||||
/// pasted equation from (#1297). `true` when the text and HTML are there; otherwise the copy's
|
||||
/// text goes through egui as usual.
|
||||
pub set_clipboard_rich: Option<Box<dyn Fn(&str, &str, &str, &str) -> bool>>,
|
||||
/// Desktop: put HTML on the system clipboard with its plain-text alternative (html, text, and
|
||||
/// for a copy of one equation alone its MathML, else empty), so other programs keep the
|
||||
/// formatting (#1523) and equations (#816). The MathML goes in the platform's MathML formats
|
||||
/// as well, which Word reads a pasted equation from (#1297). `true` when the HTML and text are
|
||||
/// there; otherwise the copy's text goes through egui as usual.
|
||||
pub set_clipboard_html: Option<Box<dyn Fn(&str, &str, &str) -> bool>>,
|
||||
/// Desktop: the RTF on the system clipboard, where the platform offers it (#687).
|
||||
pub clipboard_rtf: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
/// Desktop: the names of the formats on the system clipboard (MIME types, Windows format
|
||||
|
||||
@@ -67,11 +67,11 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option<Re
|
||||
None
|
||||
}
|
||||
|
||||
/// Put a copy on the system clipboard: its HTML and RTF beside the text (the formatting, #1523,
|
||||
/// and equations as MathML, #816; a lone equation in the MathML formats too, #1297) when the host
|
||||
/// can set them, else the text through egui. egui sets its text at the end of the frame, which
|
||||
/// would replace the rest, so only one of them runs.
|
||||
pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str, rtf: &str, mathml: &str) {
|
||||
/// Put a copy on the system clipboard: its HTML beside the text (the formatting, #1523, and
|
||||
/// equations as MathML, #816; a lone equation in the MathML formats too, #1297) when the host can
|
||||
/// set it, else the text through egui. egui sets its text at the end of the frame, which would
|
||||
/// replace the HTML, so only one of them runs.
|
||||
pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str, mathml: &str) {
|
||||
if text.is_empty() {
|
||||
// A copied picture or shape has no text, only HTML (#2276): empty the clipboard's text,
|
||||
// else Paste would find the text copied before it and paste that instead of our copy.
|
||||
@@ -80,7 +80,7 @@ pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str, rt
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !(html.is_empty() && rtf.is_empty()) && app.services.set_clipboard_rich.as_ref().is_some_and(|f| f(text, html, rtf, mathml)) {
|
||||
if !html.is_empty() && app.services.set_clipboard_html.as_ref().is_some_and(|f| f(html, text, mathml)) {
|
||||
return;
|
||||
}
|
||||
ctx.copy_text(text.to_string());
|
||||
@@ -89,7 +89,7 @@ pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str, rt
|
||||
/// Put what Copy or Cut returned on the system clipboard (see [`put`]).
|
||||
pub(crate) fn put_result(app: &WordApp, ctx: &egui::Context, r: &Value) {
|
||||
let s = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("");
|
||||
put(app, ctx, s("text"), s("html"), s("rtf"), s("mathml"));
|
||||
put(app, ctx, s("text"), s("html"), s("mathml"));
|
||||
}
|
||||
|
||||
/// Format names worth showing: not the clipboard protocol's own bookkeeping targets.
|
||||
@@ -249,12 +249,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn copying_an_equation_puts_its_html_on_the_system_clipboard() {
|
||||
// #816: the host gets HTML with the equation as MathML beside the text, and RTF.
|
||||
let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(String, String, String, String)>::new()));
|
||||
// #816: the host gets HTML with the equation as MathML beside the text.
|
||||
let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(String, String, String)>::new()));
|
||||
let sink = got.clone();
|
||||
let mut a = app(Services {
|
||||
set_clipboard_rich: Some(Box::new(move |t, h, r, m| {
|
||||
sink.borrow_mut().push((h.to_string(), t.to_string(), m.to_string(), r.to_string()));
|
||||
set_clipboard_html: Some(Box::new(move |h, t, m| {
|
||||
sink.borrow_mut().push((h.to_string(), t.to_string(), m.to_string()));
|
||||
true
|
||||
})),
|
||||
..Default::default()
|
||||
@@ -271,8 +271,7 @@ mod tests {
|
||||
assert!(got_now[0].0.contains("<math") && got_now[0].1.contains("y=2x_s"), "{got_now:?}");
|
||||
// With text around it, no MathML formats: Word would paste the equation alone (#1297).
|
||||
assert!(got_now[0].2.is_empty(), "{got_now:?}");
|
||||
// Text without an equation gets HTML too, so other programs keep its formatting (#1523),
|
||||
// and RTF for the programs that read that (gap-clipboard-rtf).
|
||||
// Text without an equation gets HTML too, so other programs keep its formatting (#1523).
|
||||
a.run("select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}})).unwrap();
|
||||
let r = a.run("edit.copy", json!({})).unwrap();
|
||||
put_result(&a, &ctx, &r);
|
||||
@@ -280,7 +279,6 @@ mod tests {
|
||||
assert_eq!(got_now.len(), 2);
|
||||
assert!(got_now[1].0.contains("Speed") && !got_now[1].0.contains("<math") && got_now[1].1 == "Speed", "{got_now:?}");
|
||||
assert!(got_now[1].2.is_empty(), "{got_now:?}");
|
||||
assert!(got_now[1].3.starts_with("{\\rtf1") && got_now[1].3.contains("Speed"), "{got_now:?}");
|
||||
// The equation alone: its MathML for the platform's MathML formats too (#1297).
|
||||
a.run("select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 9}})).unwrap();
|
||||
let r = a.run("edit.copy", json!({})).unwrap();
|
||||
|
||||
@@ -605,25 +605,6 @@ fn caret_animation_is_a_ui_command_and_survives_a_restart() {
|
||||
assert_eq!(a.run("ui.caretAnimation", json!({"value": true})).unwrap()["caretAnimation"], true);
|
||||
}
|
||||
|
||||
/// #2184: the Linux display server is a UI command, kept in `ui.json` for the desktop app to
|
||||
/// read at the next launch; junk is refused, and an unknown saved value reads as Automatic.
|
||||
#[test]
|
||||
fn display_server_is_a_ui_command_and_survives_a_restart() {
|
||||
let mut a = app();
|
||||
assert_eq!(a.run("ui.displayServer", json!({})).unwrap(), json!({"displayServer": "auto"}), "automatic by default; no value reads it");
|
||||
assert_eq!(a.run("ui.displayServer", json!({"value": " X11 "})).unwrap(), json!({"displayServer": "x11"}));
|
||||
for junk in [json!({"value": "mir"}), json!({"value": 1}), json!({"value": null}), json!({"value": ["wayland"]})] {
|
||||
assert!(a.run("ui.displayServer", junk).is_err());
|
||||
}
|
||||
assert_eq!(a.ui.display_server, DisplayServer::X11, "a rejected value keeps the setting");
|
||||
let saved = serde_json::to_string(&a.prefs()).unwrap();
|
||||
assert!(saved.contains(r#""displayServer":"x11""#), "{saved}");
|
||||
assert_eq!(UiState::from_json(saved.as_bytes()).unwrap().display_server, DisplayServer::X11);
|
||||
for old in [r#"{"tab":"Insert"}"#, r#"{"displayServer":"mir"}"#, r#"{"displayServer":7}"#] {
|
||||
assert_eq!(UiState::from_json(old.as_bytes()).unwrap().display_server, DisplayServer::Auto, "{old}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1945: showing or hiding the Quick Access Toolbar is a UI command (no value toggles it),
|
||||
/// kept between runs; settings saved before it existed show the toolbar; it works with no
|
||||
/// document open, like the other interface settings.
|
||||
@@ -692,7 +673,7 @@ fn start_screen_shows_file_home_and_esc_leaves_no_document() {
|
||||
for id in ["format.bold", "text.insert", "file.save", "file.saveAs", "file.print", "edit.paste", "insert.table", "ui.print"] {
|
||||
assert!(!widgets::enabled(a, id), "{id} is off with no document");
|
||||
}
|
||||
for id in ["file.new", "file.open", "ui.backstage", "ui.tab", "ui.discord", "file.options", "ui.theme", "ui.statusCount"] {
|
||||
for id in ["file.new", "file.open", "ui.backstage", "ui.tab", "ui.discord", "file.options", "ui.theme"] {
|
||||
assert!(widgets::enabled(a, id), "{id} works with no document");
|
||||
}
|
||||
assert!(!chrome::qat_enabled(a, "file.save"));
|
||||
@@ -5529,29 +5510,6 @@ fn interface_theme_resolves_against_the_os_appearance() {
|
||||
assert_eq!(a.run("ui.theme", json!({})).unwrap()["theme"], "light");
|
||||
}
|
||||
|
||||
/// #2797: `ui.statusCount` reads and sets what the status bar's live count shows; a value it
|
||||
/// doesn't know is refused, and the choice is written to `ui.json` and read back.
|
||||
#[test]
|
||||
fn the_status_bar_count_is_a_ui_command() {
|
||||
let mut a = app();
|
||||
assert_eq!(a.run("ui.statusCount", json!({})).unwrap(), json!({"statusCount": "words"}), "words, the default");
|
||||
assert!(a.run("ui.statusCount", json!({"value": "paragraphs"})).is_err(), "junk is refused");
|
||||
// A value that isn't a string reads the setting, like `ui.theme`'s.
|
||||
assert_eq!(a.run("ui.statusCount", json!({"value": 7})).unwrap(), json!({"statusCount": "words"}));
|
||||
assert_eq!(a.run("ui.statusCount", json!({"value": "characters"})).unwrap()["statusCount"], "characters");
|
||||
assert_eq!(a.ui.status_count, StatusCount::Characters);
|
||||
// Like the theme setting, the code is read without regard to case or surrounding space.
|
||||
assert_eq!(a.run("ui.statusCount", json!({"value": " CharactersNoSpaces "})).unwrap()["statusCount"], "charactersNoSpaces");
|
||||
assert_eq!(a.ui.status_count, StatusCount::CharactersNoSpaces);
|
||||
let saved = serde_json::to_vec(&a.prefs()).unwrap();
|
||||
assert_eq!(UiState::from_json(&saved).unwrap().status_count, StatusCount::CharactersNoSpaces, "kept between runs");
|
||||
assert_eq!(UiState::from_json(br#"{"statusCount": "words"}"#).unwrap().status_count, StatusCount::Words);
|
||||
// A `ui.json` with a wrong value in this one setting keeps the rest and reads the default.
|
||||
assert_eq!(UiState::from_json(br#"{"statusCount": "paragraphs"}"#).unwrap().status_count, StatusCount::Words);
|
||||
assert_eq!(UiState::from_json(br#"{"statusCount": 7}"#).unwrap().status_count, StatusCount::Words);
|
||||
assert_eq!(UiState::from_json(br#"{"statusCount": ["characters"]}"#).unwrap().status_count, StatusCount::Words);
|
||||
}
|
||||
|
||||
/// One app frame with the OS appearance egui reports; returns the window commands it sent.
|
||||
fn theme_frame(ctx: &egui::Context, a: &mut WordApp, os: Option<egui::Theme>) -> Vec<egui::ViewportCommand> {
|
||||
let input = egui::RawInput { system_theme: os, ..Default::default() };
|
||||
|
||||
@@ -38,7 +38,7 @@ pub(crate) struct WindowUi {
|
||||
pub(crate) sent_full_screen: bool,
|
||||
pub(crate) seen_full_screen: Option<bool>,
|
||||
pub(crate) escape_free: bool,
|
||||
pub(crate) status_counts: (u64, crate::StatusCounts),
|
||||
pub(crate) word_count: (u64, usize),
|
||||
/// Per window, as each window shows its own layout: one shared list would be read again
|
||||
/// on every frame of every window.
|
||||
pub(crate) missing_fonts: crate::chrome::MissingFontsMemo,
|
||||
@@ -69,8 +69,8 @@ pub(crate) struct WindowUi {
|
||||
pub(crate) alt_modifier_seen: bool,
|
||||
}
|
||||
|
||||
/// Status-bar counts that match no document revision, so the next frame counts.
|
||||
pub(crate) const NOT_COUNTED: (u64, crate::StatusCounts) = (u64::MAX, crate::StatusCounts { words: 0, chars: 0, chars_no_spaces: 0 });
|
||||
/// A word count that matches no document revision, so the next frame counts.
|
||||
pub(crate) const NOT_COUNTED: (u64, usize) = (u64::MAX, 0);
|
||||
|
||||
impl WindowUi {
|
||||
/// A new window showing open document `document`: nothing open over the page, Home tab.
|
||||
@@ -111,7 +111,7 @@ impl WindowUi {
|
||||
sent_full_screen: false,
|
||||
seen_full_screen: None,
|
||||
escape_free: false,
|
||||
status_counts: NOT_COUNTED,
|
||||
word_count: NOT_COUNTED,
|
||||
missing_fonts: Default::default(),
|
||||
last_autosave: 0.0,
|
||||
equation_prev_tab: None,
|
||||
@@ -238,7 +238,7 @@ impl WordApp {
|
||||
sent_full_screen,
|
||||
seen_full_screen,
|
||||
escape_free,
|
||||
status_counts,
|
||||
word_count,
|
||||
missing_fonts,
|
||||
last_autosave,
|
||||
equation_prev_tab,
|
||||
@@ -269,8 +269,6 @@ impl WordApp {
|
||||
animations: _,
|
||||
legacy_dark: _,
|
||||
nav_tab: _,
|
||||
display_server: _,
|
||||
status_count: _,
|
||||
nav_pane: _,
|
||||
show_discord: _,
|
||||
author: _,
|
||||
@@ -338,7 +336,7 @@ impl WordApp {
|
||||
sent_full_screen,
|
||||
seen_full_screen,
|
||||
escape_free,
|
||||
status_counts,
|
||||
word_count,
|
||||
missing_fonts,
|
||||
last_autosave,
|
||||
equation_prev_tab,
|
||||
|
||||
@@ -54,8 +54,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
||||
|
||||
| Method | Params | Result |
|
||||
|---|---|---|
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.displayServer` (`{"value": "auto"\|"wayland"\|"x11"}`; no value reads it; File › Options › Advanced › Linux display server, which the desktop app reads on Linux and the BSDs at its next launch; `WORDCRAFT_WAYLAND` overrides it, #2184), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.statusCount` (`{"value": "words"\|"characters"\|"charactersNoSpaces"}`; no value reads it; what the status bar's count shows — words (the default), characters with spaces, or characters without spaces — kept between runs, #2797), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
|
||||
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
|
||||
| `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
|
||||
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+9
-11
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# Hardware parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** trivial (Linux Read Aloud: a sentence a screen reader cuts off is said again); previously trivial (ink page lookup work, #3614); previously trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** trivial (ink page lookup work, #3614); previously trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -17,7 +17,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
|
||||
| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | 🟡 ink, stroke eraser, Ink to Shape incl. one- and two-stroke arrows with open or closed heads (#307, #395); pressure and Ink to Math missing | 🟡 same | 🟡 same | 🟡 same | 50% | (Draw tab, 15–25) |
|
||||
| Multiple monitors, per-monitor DPI, remembered window position, a window that fits its screen | ✅ | ✅ (#38, #76, #1215) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 |
|
||||
| Microphone: Dictate | ✅ (cloud speech service) | ❌ | ❌ | ❌ | ❌ | 0% | 8–15 + owner (speech model choice) |
|
||||
| Speakers: Read Aloud | ✅ | ✅ system speech, speed, sentence skip (#190) | ✅ | 🟡 espeak-ng, then espeak, then Speech Dispatcher (over its socket), each sentence in its language's voice; packages recommend a speech program; silent in the Flatpak (no audio access, no speech program) | 🟡 | 75% | 1–2 |
|
||||
| Speakers: Read Aloud | ✅ | ✅ system speech, speed, sentence skip (#190) | ✅ | 🟡 | 🟡 | 75% | 1–2 |
|
||||
| Touch screens and touchpads: pinch zoom, inertial scroll | ✅ | ✅ pinch (#179) | ✅ | ✅ inertial scroll (#252) | ✅ | 85% | 0–1 |
|
||||
| Scanners/cameras (Insert from device / Continuity Camera on Mac) | ✅ | ❌ | ❌ | ❌ | ❌ | 0% | 2–4 |
|
||||
|
||||
@@ -33,9 +33,6 @@ File > Print offers 2, 4, 6, 9 or 16 logical pages per physical sheet side and l
|
||||
| 2026-10-30 | trivial | Only the PDF sent to the printer leaves out link annotations; Save these pages as PDF (`file.printPdf`) keeps them (#4734) |
|
||||
| 2026-10-29 | trivial | A document with links prints: the print job's PDF leaves out link annotations, which the spooler refused to fit to the paper (#4734) |
|
||||
| 2026-10-28 | trivial | A window restored to a place no monitor covers opens centred on the primary monitor. The check compared monitor sizes only, so when a monitor was replaced by one of the same size (a dock unplugged, a display rearranged), the window opened off screen and the app looked like it quit at once |
|
||||
| 2026-10-26 | trivial | Linux Read Aloud: a sentence another program's message cancels (a screen reader's announcement) is said again after a short pause, twice at most; cancelled again, paused by another program, or never begun, it pauses reading with a message |
|
||||
| 2026-10-21 | trivial | Linux Read Aloud review fixes: Speech Dispatcher is found at `SPEECHD_ADDRESS` (`unix_socket`, `inet_socket`) or the default socket, gets the sentence's language and region, and a sentence another program cancels or pauses (a screen reader's message, a stop for every program) pauses reading on it with a message instead of being skipped or said over the other program |
|
||||
| 2026-10-18 | trivial | Linux Read Aloud: Speech Dispatcher (over its socket, with the sentence's language; stopping cancels only WordCraft's own speech) when neither espeak-ng nor espeak is installed, a message naming the program to install when none is, and a speech program recommended by the `.deb` (`espeak-ng` or `speech-dispatcher`) and `.rpm` (`espeak-ng`) packages |
|
||||
| 2026-10-15 | trivial | Pen / stylus row: Ink to Shape also makes two-stroke arrows and arrows with a closed triangle head |
|
||||
| 2026-10-17 | minor | File › Print's Scale to paper size (#2108): pick Letter, Legal, A4 or any Layout › Size paper and every page is scaled to it and centred, the paper turned to each page's orientation, in the preview, the print job (with the spooler's media) and Save these pages as PDF; the document keeps its page size. `paper` param on `ui.print`/`file.printPdf` |
|
||||
| 2026-10-14 | trivial | Pen / stylus row corrected: ink, eraser and Ink to Shape (with arrows) work; pressure and Ink to Math missing |
|
||||
|
||||
+4
-10
File diff suppressed because one or more lines are too long
+2
-6
File diff suppressed because one or more lines are too long
@@ -24,8 +24,7 @@ description: |-
|
||||
|
||||
# The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
|
||||
# GPU (Vulkan, EGL/GL) are loaded at runtime, so they are dependencies/recommendations here
|
||||
# rather than ELF NEEDED entries. Read Aloud speaks through espeak-ng (or Speech Dispatcher), a
|
||||
# program it starts, so that is a recommendation too.
|
||||
# rather than ELF NEEDED entries.
|
||||
overrides:
|
||||
deb:
|
||||
depends:
|
||||
@@ -42,7 +41,6 @@ overrides:
|
||||
- libxrandr2
|
||||
- mesa-vulkan-drivers
|
||||
- xdg-desktop-portal
|
||||
- espeak-ng | speech-dispatcher
|
||||
rpm:
|
||||
# Library sonames rather than package names, so the same requirements resolve on Fedora/RHEL
|
||||
# and openSUSE (which has no libxkbcommon, vulkan-loader or libglvnd-egl package).
|
||||
@@ -60,7 +58,6 @@ overrides:
|
||||
- libXrandr
|
||||
- mesa-vulkan-drivers
|
||||
- xdg-desktop-portal
|
||||
- espeak-ng
|
||||
|
||||
contents:
|
||||
- src: ${STAGE}/usr/bin/wordcraft
|
||||
|
||||
Reference in New Issue
Block a user