Compare commits

...
26 Commits
Author SHA1 Message Date
FredApps 6bf96e65e4 Merge pull request #6225 from batistalucasv/fix/6205-image-copy-paste
Clipboard: transfer copied images to system clipboard and preserve media across internal paste (#6205)
2026-11-12 20:20:29 +01:00
Codex d54fb78544 test: avoid range indexing in layout and eraser fixtures (#6225) 2026-11-12 19:43:54 +01:00
Codex aa6deaee03 test: use mutable cells in LaTeX table setup (#6225) 2026-11-12 19:39:50 +01:00
Codex 869350d4c7 Clean combined UI parity history after main merge (#6225) 2026-11-12 19:31:52 +01:00
batistalucasv 139aa7a51b Merge branch 'main' into fix/6205-image-copy-paste 2026-11-12 15:30:43 -03:00
FredApps 76d5f13a03 Merge pull request #6232 from batistalucasv/fix/6180-linux-save-as-ext
Save As: on Linux the file type picked in the save dialog sets the extension (#6180)
2026-11-12 19:29:52 +01:00
FredApps 11318f9226 Merge main and preserve both UI parity histories (#6225) 2026-11-12 18:13:35 +01:00
FredApps 0196647dc6 Merge main and preserve both UI parity histories (#6232) 2026-11-12 18:13:35 +01:00
10e5bb8bce View: editable Markdown source view for .md files (#5826) (#6004)
* View: editable Markdown source view for .md files (#5826)

Co-authored-by: oldgranola <marken.john206@gmail.com>

* Address review feedback on Markdown source view

- Fix view.markdownSourceText purity and input validation
- Single-pass block line mapping in markdown parser
- Reuse parsed documents and block line ranges on apply and leave
- Bound memory usage with MAX_SOURCE_BYTES and clamp caret byte offsets
- Isolate temp directories in tests and test caret validation
- Preserve dirty state while markdown text edits are pending
- Flush pending markdown text edits in swap_doc

* Address CodeRabbit review feedback on Markdown source view

- Exempt view.markdownSourceText in command refusal
- Preserve original source line mapping when filtering link definitions
- Map between egui char indices and byte-offset caret in history and undo

* Remap block line numbers in a single linear pass

* Refuse over-limit Markdown Source edits to prevent document truncation

* Keep a Markdown file's own text when it is opened from handed-over bytes

`file.open` kept a Markdown file's exact text only for path-backed opens, so a
platform that hands the bytes over (data) lost front matter, comments and the
author's line endings on the first save, and the rewritten flag could not say
so. The kept text now comes from the same bytes `read_document` decodes, still
bounded by MAX_SOURCE_BYTES through FileText::from_bytes; the path read stays
for opens without data.

---------

Co-authored-by: oldgranola <marken.john206@gmail.com>
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-11-12 17:59:04 +01:00
batistalucasv c8e64d72cc UI: vertical scrollbar track advances one screen (#5853) (#5892) 2026-11-12 17:58:46 +01:00
batistalucasv 664b033071 Save As: don't open the rfd fallback after the portal dialog was given up (#6180)
Giving the dialog up cancels the request. If the portal then fails with an
error other than its cancellation code, the worker opened the rfd dialog even
though nothing was waiting for the answer, leaving an unwanted Save dialog on
screen. The fallback now checks this request's cancellation token first.
2026-11-12 12:52:55 -03:00
batistalucasv 1a8fd899d3 Clipboard: keep the ownership flag Paste Special captured (#6225)
system_clipboard::fill recomputed privateClipboard whenever the host could report ownership, overwriting the value the Paste Special dialog captured when it opened. Only compute it when the caller supplied none, so the dialog's format list and the paste agree while ribbon and agent calls keep reading ownership from the clipboard.
2026-11-12 12:38:27 -03:00
batistalucasv 2d286fe252 Save As: only the request that armed the portal cancel token disarms it (#6180)
An abandoned portal request finishing late cleared the token of a newer
request in the shared slot, which left the newer Save dialog uncancellable.
Each arming now carries an identity and only its owner clears the slot; a
regression test arms two requests and disarms the older one.
2026-11-12 12:36:00 -03:00
batistalucasv ab90b7142e Clipboard: honor another program's clipboard ownership on paste (#6225)
Paste selected WordCraft's own fragment whenever the system clipboard text matched, even when the host reported that another program owned the clipboard, so a plain-text copy made elsewhere could paste the earlier bold fragment back. Read privateClipboard in paste_content and in the Formatted Text source, using the session fragment only when ownership is true or the caller omits the flag (agents and the CLI keep working). Carry the flag through the Paste Special request and dialog so its format list matches.
2026-11-12 12:22:28 -03:00
batistalucasv 02f8022e57 Merge remote-tracking branch 'upstream/main' into fix/6205-image-copy-paste
# Conflicts:
#	docs/gaps.md
#	docs/ui-parity.md
2026-11-12 10:41:04 -03:00
batistalucasv afcb2ce46e Docs: record clipboard picture copying in gaps and UI parity (#6205) 2026-11-12 10:37:38 -03:00
batistalucasvandCommandCodeBot 4671e47b50 Save As: cancel a stalled portal request and recognize the Flatpak document portal (#6180)
- Recognize the XDG document portal mount inside a Flatpak sandbox
  (`/run/flatpak/doc/`), so a changed extension reopens the dialog for the
  overwrite confirmation like the host mount does.
- Give the portal SaveFile request a real cancellation path: the CancelToken is
  armed before the waiting thread starts and cancelled when the system dialog is
  given up (File > Open > Browse in WordCraft...). The request closes and the
  answer reads as a cancel, so a portal that stops answering cannot keep Save As
  stuck; a late cancel after the answer is ignored.
- Map the cancellation error to a cancelled dialog instead of the rfd fallback.
- Regression tests for the Flatpak mount, the CancelHandle arm/cancel/disarm,
  and the UI calling the host's cancel hook when the dialog is given up.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-11-12 10:15:36 -03:00
batistalucasv cd4e63849e Clipboard: qualify the CF_HTML helper in the Windows host (#6205) 2026-11-12 10:06:07 -03:00
batistalucasv a1d4495cfb Clipboard: account for fragment media and test CF_HTML offsets (#6205) 2026-11-12 09:53:17 -03:00
FredApps 07f977372d CI: nightly release dry run (#6367)
release.yml also runs nightly on main with craft-common-actions' dry-run: every release build
except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits
(riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release
day, as it did on 11 Nov 2026.
2026-11-11 23:04:54 +01:00
batistalucasv cc27f29a01 Save As on Linux: the file type picked in the save dialog sets the extension (#6180) 2026-11-11 18:08:29 -03:00
batistalucasv 28af39b610 Clipboard: preserve system clipboard and ownership when copy has no selection (#6205) 2026-11-11 09:34:43 -03:00
batistalucasv 22f01131d8 Clipboard: use process-unique token for Windows clipboard ownership (#6205) 2026-11-10 21:47:52 -03:00
batistalucasv 860426adda Clipboard: remap conflicting media keys, format Windows CF_HTML, and complete docstrings (#6205) 2026-11-10 21:33:22 -03:00
batistalucasv 7458a18468 Clipboard: collapse nested checks in Windows clipboard host (#6205) 2026-11-10 21:12:03 -03:00
batistalucasv 49e777cce5 Clipboard: transfer copied images to system clipboard and preserve media across internal paste (#6205) 2026-11-10 20:59:58 -03:00
51 changed files with 4100 additions and 158 deletions
+5
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "0 3 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -33,6 +37,7 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: wordcraft
app-title: WordCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+4 -1
View File
File diff suppressed because one or more lines are too long
+292 -1
View File
@@ -60,6 +60,7 @@ pub fn set_rich(text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
set
}
/// Write HTML and plain text to the system clipboard via the cached writer slot, recording ownership.
fn set_html_text(html: &str, text: &str) -> bool {
WRITER.with(|w| {
let Ok(mut slot) = w.try_borrow_mut() else { return false };
@@ -68,7 +69,10 @@ fn set_html_text(html: &str, text: &str) -> bool {
}
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
match set {
Some(Ok(())) => true,
Some(Ok(())) => {
platform::record_own();
true
}
Some(Err(e)) => {
log::warn!("clipboard: {e}");
*slot = None;
@@ -79,6 +83,74 @@ fn set_html_text(html: &str, text: &str) -> bool {
})
}
/// Put a copied picture on the clipboard: its pixels, with HTML and text beside it where the
/// platform takes them. `false` when it couldn't be set.
pub fn set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, text: &str) -> bool {
if platform::set_image(image, html, text) {
return true;
}
if !platform::can_set() {
return false;
}
let set = set_image_arboard(image);
if set {
platform::after_set_image(image, html, text);
}
set
}
/// Write image pixels to the clipboard through arboard.
fn set_image_arboard(image: &wordcraft_ui_egui::system_clipboard::CopyImage) -> bool {
WRITER.with(|w| {
let Ok(mut slot) = w.try_borrow_mut() else { return false };
if slot.is_none() {
*slot = open();
}
let img = arboard::ImageData { width: image.width, height: image.height, bytes: std::borrow::Cow::Borrowed(&image.rgba) };
let set = slot.as_mut().map(|c| c.set().image(img));
match set {
Some(Ok(())) => {
platform::record_own();
true
}
Some(Err(e)) => {
log::warn!("clipboard image: {e}");
*slot = None;
false
}
None => false,
}
})
}
/// Whether the system clipboard still holds WordCraft's last copy.
pub fn is_own() -> bool {
platform::is_own()
}
/// Wrap HTML in the Windows CF_HTML header format required by clipboard readers.
///
/// Offsets are byte offsets from the start of the payload; `EndHTML` excludes the trailing NUL.
#[cfg(any(target_os = "windows", test))]
fn wrap_cf_html(ctn: &str) -> Vec<u8> {
let h_version = "Version:0.9";
let h_start_html = "\r\nStartHTML:";
let h_end_html = "\r\nEndHTML:";
let h_start_frag = "\r\nStartFragment:";
let h_end_frag = "\r\nEndFragment:";
let c_start_frag = "\r\n<html>\r\n<body>\r\n<!--StartFragment-->\r\n";
let c_end_frag = "\r\n<!--EndFragment-->\r\n</body>\r\n</html>\0";
let h_len = h_version.len() + h_start_html.len() + 10 + h_end_html.len() + 10 + h_start_frag.len() + 10 + h_end_frag.len() + 10;
let n_start_html = h_len + 2;
let n_start_frag = h_len + c_start_frag.len();
let n_end_frag = n_start_frag + ctn.len();
let n_end_html = n_end_frag + c_end_frag.len() - 1;
let header = format!(
"{h_version}{h_start_html}{n_start_html:010}{h_end_html}{n_end_html:010}{h_start_frag}{n_start_frag:010}{h_end_frag}{n_end_frag:010}{c_start_frag}{ctn}{c_end_frag}"
);
header.into_bytes()
}
/// The clipboard's text.
pub fn text() -> Option<String> {
open()?.get_text().ok()
@@ -185,6 +257,39 @@ mod platform {
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default()
}
const OWNER_MIME: &str = "application/x-wordcraft-owner";
/// Process-unique ownership token placed into Wayland clipboard offers.
fn owner_token() -> [u8; 16] {
static TOKEN: std::sync::OnceLock<[u8; 16]> = std::sync::OnceLock::new();
*TOKEN.get_or_init(|| {
let mut b = [0u8; 16];
let _ = getrandom::fill(&mut b);
b
})
}
static X11_OWNER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// Record clipboard ownership for X11 selection tracking.
pub fn record_own() {
if !wayland() {
let win = x11::owner_window().unwrap_or(0);
X11_OWNER.store(win, std::sync::atomic::Ordering::Relaxed);
}
}
/// Whether WordCraft still owns the Wayland offer or X11 selection.
pub fn is_own() -> bool {
if wayland() {
if formats().iter().any(|m| m == OWNER_MIME) { read(OWNER_MIME, 16).is_some_and(|b| b == owner_token()) } else { false }
} else {
let current = x11::owner_window().unwrap_or(0);
let recorded = X11_OWNER.load(std::sync::atomic::Ordering::Relaxed);
recorded != 0 && current == recorded
}
}
/// Whether arboard sets the clipboard natively: X11, or Wayland with data-control.
pub fn can_set() -> bool {
!wayland()
@@ -211,6 +316,7 @@ mod platform {
sources.push(offer(mime, data));
}
}
sources.push(MimeSource { source: Source::Bytes(owner_token().to_vec().into()), mime_type: MimeType::Specific(OWNER_MIME.into()) });
match Options::new().copy_multi(sources) {
Ok(()) => true,
Err(e) => {
@@ -220,6 +326,37 @@ mod platform {
}
}
/// Put a copied picture on the Wayland clipboard alongside HTML, text, and ownership token.
pub fn set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, text: &str) -> bool {
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
if !wayland() {
return false;
}
let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone()) else {
return false;
};
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
let mut sources = vec![MimeSource { source: Source::Bytes(png.into()), mime_type: MimeType::Specific("image/png".into()) }];
if !text.is_empty() {
sources.push(offer("text/plain;charset=utf-8", text));
}
if !html.is_empty() {
sources.push(offer("text/html", html));
}
sources.push(MimeSource { source: Source::Bytes(owner_token().to_vec().into()), mime_type: MimeType::Specific(OWNER_MIME.into()) });
match Options::new().copy_multi(sources) {
Ok(()) => true,
Err(e) => {
log::warn!("clipboard image: {e}");
false
}
}
}
/// Additional formats to register after setting image via arboard on Unix.
pub fn after_set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) {}
/// Wayland copies carry their RTF already; X11 ones can't (see the module docs).
pub fn add_rtf(_rtf: &str) {}
@@ -346,6 +483,14 @@ mod platform {
let reply = r.convert(target, cap, true)?;
(reply.type_ != incr).then_some(reply.value)
}
/// Query X11 for the current window id owning the CLIPBOARD selection.
pub fn owner_window() -> Option<Window> {
let (conn, _) = x11rb::connect(None).ok()?;
let clipboard = atom(&conn, "CLIPBOARD")?;
let reply = conn.get_selection_owner(clipboard).ok()?.reply().ok()?;
(reply.owner != x11rb::NONE).then_some(reply.owner)
}
}
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
@@ -436,6 +581,40 @@ mod platform {
Ok(out)
}
const OWNER_FORMAT: &str = "WordCraft Copy Owner";
/// Process-unique ownership token placed into Windows clipboard marker.
fn owner_token() -> [u8; 16] {
static TOKEN: std::sync::OnceLock<[u8; 16]> = std::sync::OnceLock::new();
*TOKEN.get_or_init(|| {
let mut b = [0u8; 16];
let _ = getrandom::fill(&mut b);
b
})
}
/// Record clipboard ownership for Windows clipboard.
pub fn record_own() {
let Some(format) = raw::register_format(OWNER_FORMAT) else { return };
let Ok(_open) = Clipboard::new_attempts(10) else { return };
let _ = raw::set_without_clear(format.get(), &owner_token());
}
/// Whether WordCraft still owns the Windows clipboard contents.
pub fn is_own() -> bool {
let Some(format) = raw::register_format(OWNER_FORMAT) else { return false };
let Ok(_open) = Clipboard::new_attempts(10) else { return false };
if !raw::is_format_avail(format.get()) {
return false;
}
let mut out = Vec::new();
if raw::get_vec(format.get(), &mut out).is_err() {
return false;
}
out == owner_token()
}
/// Windows supports clipboard setting via arboard and clipboard_win.
pub fn can_set() -> bool {
true
}
@@ -445,6 +624,29 @@ mod platform {
false
}
/// Windows sets the primary image through arboard and supplements via [`after_set_image`].
pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool {
false
}
/// Add PNG and CF_HTML formats alongside arboard's image, then mark ownership.
pub fn after_set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, _text: &str) {
if let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone())
&& let Some(format) = raw::register_format("PNG")
&& let Ok(_open) = Clipboard::new_attempts(10)
{
let _ = raw::set_without_clear(format.get(), &png);
}
if !html.is_empty()
&& let Some(format) = raw::register_format("HTML Format")
&& let Ok(_open) = Clipboard::new_attempts(10)
{
let cf = super::wrap_cf_html(html);
let _ = raw::set_without_clear(format.get(), &cf);
}
record_own();
}
/// 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 };
@@ -487,19 +689,38 @@ mod platform {
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
const RTF: &str = "public.rtf";
/// Formats offered on macOS pasteboard.
pub fn formats() -> Vec<String> {
Vec::new()
}
/// Read RTF data from macOS pasteboard.
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())
}
/// Read encoded picture data from macOS pasteboard.
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
static LAST_CHANGE_COUNT: std::sync::atomic::AtomicIsize = std::sync::atomic::AtomicIsize::new(-1);
/// Record pasteboard changeCount on macOS to verify ownership.
pub fn record_own() {
let count = NSPasteboard::generalPasteboard().changeCount();
LAST_CHANGE_COUNT.store(count, std::sync::atomic::Ordering::Relaxed);
}
/// Whether WordCraft still owns the macOS pasteboard.
pub fn is_own() -> bool {
let count = NSPasteboard::generalPasteboard().changeCount();
let recorded = LAST_CHANGE_COUNT.load(std::sync::atomic::Ordering::Relaxed);
recorded >= 0 && count == recorded
}
/// Whether macOS supports setting the pasteboard.
pub fn can_set() -> bool {
true
}
@@ -509,6 +730,23 @@ mod platform {
false
}
/// macOS sets the primary image through arboard and supplements via [`after_set_image`].
pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool {
false
}
/// Set supplemental PNG and HTML representations on macOS pasteboard.
pub fn after_set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, _text: &str) {
let board = NSPasteboard::generalPasteboard();
if let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone()) {
let _ = board.setData_forType(Some(&NSData::with_bytes(&png)), &NSString::from_str("public.png"));
}
if !html.is_empty() {
let _ = board.setString_forType(&NSString::from_str(html), &NSString::from_str("public.html"));
}
record_own();
}
/// 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)) {
@@ -535,27 +773,80 @@ mod platform {
all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten")))
)))]
mod platform {
/// Formats on the clipboard.
pub fn formats() -> Vec<String> {
Vec::new()
}
/// The RTF bytes on the clipboard.
pub fn rtf() -> Option<Vec<u8>> {
None
}
/// Encoded picture bytes on the clipboard.
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
/// Whether the fallback platform can set the clipboard.
pub fn can_set() -> bool {
true
}
/// Record clipboard ownership on the fallback platform.
pub fn record_own() {}
/// Whether WordCraft owns the clipboard on the fallback platform.
pub fn is_own() -> bool {
false
}
/// Set rich text on the fallback platform.
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
/// Set image on the fallback platform.
pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool {
false
}
/// Post-image set hook on the fallback platform.
pub fn after_set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) {}
/// Add RTF to the clipboard on the fallback platform.
pub fn add_rtf(_rtf: &str) {}
/// Add MathML to the clipboard on the fallback platform.
pub fn add_mathml(_mathml: &str) {}
}
#[cfg(test)]
mod cf_html_tests {
use super::wrap_cf_html;
fn field(data: &[u8], name: &str) -> usize {
let at = data.windows(name.len()).position(|w| w == name.as_bytes()).expect("field") + name.len();
std::str::from_utf8(&data[at..at + 10]).expect("offset digits").parse().expect("offset")
}
#[test]
fn cf_html_offsets_delimit_the_fragment_bytes() {
let fragment = "<p>Olá, 世界 — café</p>";
let data = wrap_cf_html(fragment);
let start_html = field(&data, "StartHTML:");
let end_html = field(&data, "EndHTML:");
let start_fragment = field(&data, "StartFragment:");
let end_fragment = field(&data, "EndFragment:");
assert!(start_html < start_fragment, "HTML starts before its fragment");
assert!(start_fragment < end_fragment, "fragment has a positive length");
assert!(end_fragment < end_html, "fragment ends inside the document");
assert_eq!(&data[start_html..start_html + "<html>".len()], b"<html>");
assert_eq!(&data[start_fragment..end_fragment], fragment.as_bytes());
assert_eq!(end_fragment - start_fragment, fragment.len(), "offsets count UTF-8 bytes");
assert_eq!(&data[end_html..], b"\0", "EndHTML excludes the trailing NUL");
assert_eq!(end_html, data.len() - 1);
}
}
+168 -7
View File
@@ -23,6 +23,8 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::mpsc::{Receiver, Sender, channel};
#[cfg(all(unix, not(target_os = "macos")))]
use std::sync::{Arc, Mutex};
use wordcraft_ui_egui::{FileDialogAnswer, FileDialogRequest};
@@ -32,6 +34,59 @@ type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
/// The `Services::file_dialog` hook.
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
/// Cancels the portal Save dialog the launcher is waiting on, if any.
///
/// The portal's dialog lives outside the app's window, so a portal that stops answering cannot be
/// dismissed from WordCraft. Giving the dialog up (File › Open › Browse in WordCraft…) cancels the
/// request: the waiting thread closes it and reports a cancellation, and the built-in browser
/// opens instead. A no-op where Save As doesn't ask the portal (macOS and Windows).
#[derive(Clone, Default)]
pub struct CancelHandle {
#[cfg(all(unix, not(target_os = "macos")))]
armed: Arc<Mutex<Option<Armed>>>,
#[cfg(all(unix, not(target_os = "macos")))]
next_id: Arc<std::sync::atomic::AtomicU64>,
}
/// The token a request installed in [`CancelHandle`], with the identity that owns it.
#[cfg(all(unix, not(target_os = "macos")))]
struct Armed {
id: u64,
token: crate::portal_save::CancelToken,
}
impl CancelHandle {
/// Cancels the pending portal request, if there is one.
pub fn cancel(&self) {
#[cfg(all(unix, not(target_os = "macos")))]
{
let mut slot = self.armed.lock().unwrap_or_else(|e| e.into_inner());
if let Some(armed) = slot.take() {
armed.token.cancel();
}
}
}
/// Arms the handle for the portal request about to wait, returning its token and identity.
#[cfg(all(unix, not(target_os = "macos")))]
fn arm(&self) -> Armed {
let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1);
let token = crate::portal_save::CancelToken::new();
*self.armed.lock().unwrap_or_else(|e| e.into_inner()) = Some(Armed { id, token: token.clone() });
Armed { id, token }
}
/// Disarms the handle once the request with `id` has answered, leaving a newer request's
/// token alone: an abandoned request finishing late must not make the next one uncancellable.
#[cfg(all(unix, not(target_os = "macos")))]
fn disarm(&self, id: u64) {
let mut slot = self.armed.lock().unwrap_or_else(|e| e.into_inner());
if slot.as_ref().is_some_and(|armed| armed.id == id) {
*slot = None;
}
}
}
/// A picker's answer, waited for off the UI thread.
type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
@@ -39,6 +94,7 @@ type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
pub struct Launcher {
queue: Receiver<Request>,
ctx: egui::Context,
cancel: CancelHandle,
}
/// The hook for [`Services::file_dialog`](wordcraft_ui_egui::Services::file_dialog) and the
@@ -51,10 +107,15 @@ pub fn hook(ctx: &egui::Context) -> (Hook, Launcher) {
let _ = queue_tx.send((req, answer));
rx
});
(hook, Launcher { queue, ctx: ctx.clone() })
(hook, Launcher { queue, ctx: ctx.clone(), cancel: CancelHandle::default() })
}
impl Launcher {
/// A handle that cancels the portal Save dialog the launcher is waiting on, if any.
pub fn cancel_handle(&self) -> CancelHandle {
self.cancel.clone()
}
/// Show the queued dialogs with the window as their parent, so the portal (or the system)
/// keeps them on top of it. Call after every pass that may ask for one.
pub fn show(&self, parent: &eframe::Frame) {
@@ -63,26 +124,90 @@ impl Launcher {
let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req);
#[cfg(all(unix, not(target_os = "macos")))]
let unparented = picker(rfd::AsyncFileDialog::new(), &req);
#[cfg(all(unix, not(target_os = "macos")))]
let parent_id = {
use eframe::wgpu::rwh::HasWindowHandle;
crate::portal_save::parent_id(parent.window_handle().ok().map(|h| h.as_raw()))
};
// Armed before the thread starts, so giving the dialog up cannot race its start.
#[cfg(all(unix, not(target_os = "macos")))]
let portal_arm = matches!(&req, FileDialogRequest::Save { .. }).then(|| self.cancel.arm());
#[cfg(all(unix, not(target_os = "macos")))]
let portal_id = portal_arm.as_ref().map(|armed| armed.id);
#[cfg(all(unix, not(target_os = "macos")))]
let cancel_handle = self.cancel.clone();
let ctx = self.ctx.clone();
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
// The pickers only start when first polled: without a system dialog they never do.
let picked = match no_system_dialog() {
Some(reason) => Err(reason),
None => {
// Only the portal folds a failure into "cancelled" (#2999).
#[cfg(all(unix, not(target_os = "macos")))]
let picked = pick(picking, unparented, TOO_QUICK);
{
if let FileDialogRequest::Save { name, dir } = &req {
let cancel = portal_arm.as_ref().map(|armed| armed.token.clone());
let portal_req = crate::portal_save::SaveRequest {
parent: parent_id,
name: Some(name.clone()),
suggested: Some(name.clone()),
folder: dir.clone().map(std::path::PathBuf::from),
filters: save_filters(name),
current: Some(0),
cancel,
timeout: None,
};
let portal_res = zbus::blocking::Connection::session()
.map_err(|e| e.to_string())
.and_then(|conn| crate::portal_save::save_as(&conn, portal_req));
match portal_res {
Ok(Some(path)) => Ok(vec![path.to_string_lossy().into_owned()]),
Ok(None) => Ok(Vec::new()),
Err(e) if crate::portal_save::is_cancelled_error(&e) => Ok(Vec::new()),
Err(e) if crate::portal_save::is_ambiguous_filter_error(&e) => {
log::warn!("portal save format ambiguous ({e}); cancelling save without explicit format choice");
Ok(Vec::new())
}
Err(e) => {
// Giving the dialog up cancels the request. A portal that then
// fails with another error must not open the rfd dialog, which
// nothing would be waiting for.
if portal_arm.as_ref().is_some_and(|armed| armed.token.is_cancelled()) {
log::info!("portal save dialog given up ({e})");
Ok(Vec::new())
} else {
log::info!("portal save dialog unavailable ({e}), using rfd");
let picked = pick(picking, unparented, TOO_QUICK);
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
}
}
}
} else {
// Only the portal folds a failure into "cancelled" (#2999).
let picked = pick(picking, unparented, TOO_QUICK);
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
let picked: Result<_, String> = Ok(pollster::block_on(picking));
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
{
let picked: Result<_, String> = Ok(pollster::block_on(picking));
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
}
}
};
#[cfg(all(unix, not(target_os = "macos")))]
if let Some(armed) = &portal_arm {
cancel_handle.disarm(armed.id);
}
let _ = answer.send(picked);
ctx.request_repaint();
});
// The answer's sender went with the closure, so the app sees the dialog as cancelled.
if let Err(e) = waiting {
log::error!("couldn't wait for the file dialog: {e}");
#[cfg(all(unix, not(target_os = "macos")))]
if let Some(id) = portal_id {
self.cancel.disarm(id);
}
}
}
}
@@ -209,6 +334,8 @@ fn save_exts(name: &str) -> Vec<String> {
.extension()
.and_then(std::ffi::OsStr::to_str)
.map(str::to_ascii_lowercase)
// A .markdown file is under Markdown's one entry in the list.
.map(|e| if e == "markdown" { "md".to_string() } else { e })
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
.unwrap_or_else(|| "docx".to_string());
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
@@ -220,8 +347,9 @@ fn save_exts(name: &str) -> Vec<String> {
/// The "Save as type" list (#1399): one named entry per format, the suggested one first (see
/// [`save_exts`] for why the order matters).
///
/// rfd doesn't report which entry the user picked, so if they choose "PDF" but leave "name.docx"
/// in the name box, the extension in the name wins, as the format is chosen by extension.
/// On Linux and the BSDs, [`crate::portal_save`] asks the portal directly over D-Bus and gives a
/// name the dialog left alone the chosen type's extension (#6180). When falling back to `rfd`, the
/// extension in the name wins, as `rfd` doesn't report which entry the user picked.
fn save_filters(name: &str) -> Vec<(String, Vec<String>)> {
save_exts(name).into_iter().map(|e| (format!("{} (*.{e})", format_name(&e)), vec![e])).collect()
}
@@ -272,6 +400,37 @@ mod tests {
assert_eq!(pick(async { vec![] }, slow_cancel(), quick), Ok(vec![]), "the second dialog cancelled for real");
}
/// Giving the portal Save dialog up cancels the request the launcher armed, and a request
/// that has already answered is not affected by a later cancel.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn cancelling_a_pending_portal_request_marks_its_token() {
let handle = super::CancelHandle::default();
let armed = handle.arm();
assert!(!armed.token.is_cancelled());
handle.cancel();
assert!(armed.token.is_cancelled());
let answered = handle.arm();
handle.disarm(answered.id);
handle.cancel();
assert!(!answered.token.is_cancelled(), "an answered request is no longer cancelled");
}
/// A request that finishes late must not disarm the token of a request started after it:
/// otherwise the newer Save dialog would become uncancellable.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn a_late_answer_keeps_the_newer_requests_token() {
let handle = super::CancelHandle::default();
let abandoned = handle.arm();
let newer = handle.arm();
handle.disarm(abandoned.id);
handle.cancel();
assert!(newer.token.is_cancelled(), "the newer request is still armed");
assert!(!abandoned.token.is_cancelled(), "only the armed request is cancelled");
}
/// #1399: the Save dialog's type list has one named entry per format, the suggested one first,
/// every savable extension exactly once.
#[test]
@@ -301,6 +460,8 @@ mod tests {
assert_eq!(save_exts("novel.docx")[0], "docx");
assert_eq!(save_exts("report.odt")[0], "odt");
assert_eq!(save_exts("annual report.pdf")[0], "pdf");
assert_eq!(save_exts("notes.md")[0], "md");
assert_eq!(save_exts("notes.markdown")[0], "md");
// A suggested name always has an extension; anything unreadable falls back to Word's.
assert_eq!(save_exts("no extension")[0], "docx");
assert_eq!(save_exts("mystery.rtf2")[0], "docx");
+8 -2
View File
@@ -21,6 +21,8 @@ mod file_dialogs;
#[cfg(any(target_os = "windows", target_os = "macos", test))]
mod graphics;
mod logging;
#[cfg(all(unix, not(target_os = "macos")))]
mod portal_save;
mod print;
mod region;
#[cfg(target_os = "macos")]
@@ -367,9 +369,10 @@ fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
/// through the system spooler (`print`), the system clipboard's text, formatted copies,
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
fn services(file_dialog: file_dialogs::Hook, cancel_file_dialog: file_dialogs::CancelHandle, ctx: &egui::Context) -> Services {
Services {
file_dialog: Some(file_dialog),
cancel_file_dialog: Some(Box::new(move || cancel_file_dialog.cancel())),
printers: Some(print::discover()),
print_native: Some(print::submit()),
cancel_print: Some(print::cancel()),
@@ -381,6 +384,8 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
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_image: Some(Box::new(clipboard::set_image)),
clipboard_is_own: Some(Box::new(clipboard::is_own)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
save_prefs: Some(Box::new(try_save_prefs)),
@@ -611,9 +616,10 @@ fn main() -> eframe::Result {
let region = region::system();
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
let cancel_file_dialog = dialogs.cancel_handle();
let mut session = Session::new(doc);
session.system_region = region;
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
let mut app = WordApp::new(session, services(file_dialog, cancel_file_dialog, &cc.egui_ctx));
app.set_startup_update_check(true);
load_prefs(&mut app);
// After the preferences, so the reason it gives is in the interface language.
+906
View File
@@ -0,0 +1,906 @@
//! File › Save As through the XDG desktop portal on Linux and BSDs (#6180).
//!
//! The GTK file chooser behind the portal keeps the typed name when the user picks another file
//! type, and `rfd` 0.17 reads only the file name from the portal's answer, so `Document1.docx`
//! with PDF picked in the dropdown came back as `Document1.docx` and was saved as a Word document.
//! The portal's answer does say which type was picked (`current_filter`), so Save As asks the portal
//! itself over D-Bus via `zbus` and gives a name the dialog left alone that type's extension
//! ([`with_chosen_type`]). `rfd` stays the fallback (zenity, or no portal), and this can go once an
//! `rfd` release reads the picked type (upstream PolyMeilex/rfd#341).
//!
//! The dialog is attached to the window on X11 (`x11:<xid>`). On Wayland it has no parent: naming the
//! window to the portal needs an xdg-foreign handle, which takes `unsafe` code the workspace forbids.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use zbus::blocking::{MessageIterator, Proxy};
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
/// One file type in the dialog: its label and extensions (`("PDF (*.pdf)", ["pdf"])`).
pub type Filter = (String, Vec<String>);
/// Quicker than anyone can see a dialog and dismiss it (#2999).
const TOO_QUICK: Duration = Duration::from_millis(300);
/// A thread-safe cancellation token to signal cancellation of an ongoing save request.
#[derive(Clone, Debug, Default)]
pub struct CancelToken(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl CancelToken {
/// Creates a new cancellation token in the active (not cancelled) state.
pub fn new() -> Self {
Self(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)))
}
/// Signals that the active save request should be cancelled.
pub fn cancel(&self) {
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// Returns `true` if cancellation has been requested.
pub fn is_cancelled(&self) -> bool {
self.0.load(std::sync::atomic::Ordering::Relaxed)
}
}
impl PartialEq for CancelToken {
/// Compares two cancellation tokens by their current cancellation status.
fn eq(&self, other: &Self) -> bool {
self.is_cancelled() == other.is_cancelled()
}
}
impl Eq for CancelToken {}
/// A Save dialog request to pass to the desktop portal.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SaveRequest {
/// The portal's parent window identifier (`x11:<xid>`), or empty.
pub parent: String,
/// The name the dialog shows: the suggested file name (`Document1.docx`).
pub name: Option<String>,
/// The suggested file's name (`Document1.docx`): a name that comes back with its extension still
/// takes the extension of the type picked ([`with_chosen_type`]).
pub suggested: Option<String>,
/// The initial folder to open in, if any.
pub folder: Option<PathBuf>,
/// The list of filters offered in the dropdown.
pub filters: Vec<Filter>,
/// The index of the filter the dialog starts on.
pub current: Option<usize>,
/// Optional cancellation token allowing the caller to cancel a pending save request.
pub cancel: Option<CancelToken>,
/// Optional timeout after which a request waiting for a response is considered stalled.
pub timeout: Option<Duration>,
}
/// What the portal dialog returned: the path, as typed, and the filter chosen in the dropdown.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Saved {
/// The filesystem path chosen in the dialog.
pub path: PathBuf,
/// The filter chosen in the dropdown, if reported by the portal: `(label, extensions)`.
pub filter: Option<Filter>,
}
/// The portal's parent window identifier for `window`: `x11:<xid>` on X11, empty elsewhere.
///
/// On Wayland, exporting an xdg-foreign handle requires `unsafe` code forbidden by workspace rules
/// (`#![forbid(unsafe_code)]`, Rust Rule 1), so Wayland dialogs remain unparented, matching
/// PhotoCraft and VectorCraft.
pub fn parent_id(window: Option<eframe::wgpu::rwh::RawWindowHandle>) -> String {
use eframe::wgpu::rwh::RawWindowHandle;
match window {
Some(RawWindowHandle::Xlib(h)) => format!("x11:{:x}", h.window),
Some(RawWindowHandle::Xcb(h)) => format!("x11:{:x}", h.window.get()),
_ => String::new(),
}
}
/// Converts a filter tuple into the portal's wire format: label and a list of `(0, "*.ext")` glob pairs.
fn wire(filter: &Filter) -> (String, Vec<(u32, String)>) {
let globs = filter.1.iter().map(|e| (0u32, if e.is_empty() || e == "*" { "*".to_string() } else { format!("*.{e}") })).collect();
(filter.0.clone(), globs)
}
/// The `SaveFile` options dictionary for `req`, answering on `token`.
fn options<'a>(req: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
let mut o: HashMap<&'static str, Value<'a>> = HashMap::from([("handle_token", Value::from(token)), ("modal", Value::from(true))]);
if let Some(name) = &req.name {
o.insert("current_name", Value::from(name.as_str()));
}
if let Some(folder) = &req.folder {
// A byte string with its terminating NUL, as the portal specifies.
use std::os::unix::ffi::OsStrExt;
let mut bytes = folder.as_os_str().as_bytes().to_vec();
bytes.push(0);
o.insert("current_folder", Value::from(bytes));
}
if !req.filters.is_empty() {
let wired = req.filters.iter().map(wire).collect::<Vec<_>>();
o.insert("filters", Value::from(wired));
}
if let Some(f) = req.current.and_then(|i| req.filters.get(i)).or_else(|| req.filters.first()) {
o.insert("current_filter", Value::from(wire(f)));
}
o
}
/// Reads the portal's `Response`: code 0 with the first `uris` entry. 1 is Cancel, and 2 a dialog
/// closed some other way (e.g. Esc in GNOME): no file either. When code 2 arrives quicker than
/// [`TOO_QUICK`], the portal failed to show its dialog (#2999) and an error is returned so the
/// caller can fall back.
fn answer(code: u32, results: &HashMap<String, OwnedValue>, elapsed: Duration) -> Result<Option<Saved>, String> {
match code {
0 => {}
1 => return Ok(None),
2 if elapsed < TOO_QUICK => return Err("the save dialog closed at once".to_string()),
2 => return Ok(None),
_ => return Err(format!("the save dialog failed (portal response {code})")),
}
let uris: Vec<String> = results
.get("uris")
.ok_or_else(|| "no file in the portal's answer".to_string())?
.try_clone()
.map_err(|e| e.to_string())?
.try_into()
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
let uri = uris.first().ok_or_else(|| "no file in the portal's answer".to_string())?;
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
let filter = results.get("current_filter").and_then(|v| {
let val = v.try_clone().ok()?;
if let Ok((label, globs)) = <(String, Vec<(u32, String)>)>::try_from(val.clone()) {
let mut exts = Vec::new();
for (_, g) in globs {
let s = g.trim_start_matches('*').trim_start_matches('.');
if !s.is_empty() && s != "*" {
exts.push(s.to_ascii_lowercase());
}
}
if exts.is_empty() {
exts.extend(extract_exts_from_label(&label));
}
Some((label, exts))
} else if let Ok((label, exts)) = <(String, Vec<String>)>::try_from(val.clone()) {
Some((label, exts.into_iter().map(|s| s.to_ascii_lowercase()).collect()))
} else if let Ok(label) = String::try_from(val) {
let exts = extract_exts_from_label(&label);
Some((label, exts))
} else {
None
}
});
Ok(Some(Saved { path, filter }))
}
/// Extracts file extensions from a filter label such as `"PDF (*.pdf)"` or `"*.pdf"`.
fn extract_exts_from_label(label: &str) -> Vec<String> {
let text = if let Some(inside) = label.split('(').nth(1).and_then(|s| s.split(')').next()) { inside } else { label };
text.split(|c: char| c == ';' || c == ',' || c.is_whitespace())
.filter_map(|part| {
let s = part.trim().trim_start_matches('*').trim_start_matches('.');
if s.is_empty() || s == "*" || s.contains('/') || s.contains('\\') { None } else { Some(s.to_ascii_lowercase()) }
})
.collect()
}
/// Converts a `file://` URI from the portal into a local `PathBuf`.
/// Returns `None` for non-local URIs or invalid percent-encoding.
pub fn file_uri_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://")?;
// Only local files: `file:///…` or `file://localhost/…`.
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
if !rest.starts_with('/') {
return None;
}
let bytes = rest.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes.get(i) == Some(&b'%') {
let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())?;
out.push(hex);
i = i.saturating_add(3);
} else if let Some(&b) = bytes.get(i) {
out.push(b);
i = i.saturating_add(1);
} else {
break;
}
}
use std::os::unix::ffi::OsStringExt;
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
}
/// Returns the index of the chosen filter in `filters` by matching the picked index, or the first filter
/// if only one was offered.
fn chosen_type(filters: &[Filter], picked: Option<usize>) -> Option<usize> {
match picked {
Some(i) if i < filters.len() => Some(i),
None if filters.len() == 1 => Some(0),
_ => None,
}
}
/// Returns `path` with the extension of the file type picked in the save dialog (`chosen`),
/// where the dialog left the suggested name's extension alone (#6180).
///
/// The GTK file chooser keeps the typed name when another type is picked. Only a name that
/// still has the extension the dialog opened with (`opened_with`, e.g. `Document1.docx` with
/// PDF picked) or none gets the type's extension, as PolyMeilex/rfd#341 does. An extension the
/// user explicitly typed that WordCraft saves (e.g. `art.txt` with PDF untouched) is kept,
/// and so does one of the type's own extensions. Any extension that WordCraft does not save
/// (e.g. `scan.2024`) is treated as part of the stem, and the type's extension is added.
pub fn with_chosen_type(path: &Path, opened_with: Option<&str>, chosen: &[String]) -> PathBuf {
let Some(first) = chosen.first() else { return path.to_path_buf() };
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
let opened_ext = opened_with
.and_then(|n| Path::new(n).extension())
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()));
match ext {
Some(ext) if chosen.contains(&ext) => path.to_path_buf(),
Some(ext) if opened_ext.as_ref() == Some(&ext) => path.with_extension(first),
Some(ext) if wordcraft_engine::io::SAVE_EXTS.contains(&ext.as_str()) => path.to_path_buf(),
_ => {
let mut name = path.as_os_str().to_owned();
name.push(".");
name.push(first);
PathBuf::from(name)
}
}
}
/// Returns true if `path` exists as a file, directory, or symlink on disk.
fn entry_exists(path: &Path) -> bool {
match path.symlink_metadata() {
Ok(_) => true,
Err(e) => e.kind() != std::io::ErrorKind::NotFound,
}
}
/// Closes the active portal request handle by calling `org.freedesktop.portal.Request.Close`.
pub fn close_request(conn: &zbus::blocking::Connection, portal: Option<&str>, handle: &OwnedObjectPath) {
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
const REQUEST: &str = "org.freedesktop.portal.Request";
let dest = portal.unwrap_or(DESKTOP);
if let Ok(proxy) = Proxy::new(conn, dest, handle.as_str(), REQUEST) {
let _ = proxy.call_method("Close", &());
}
}
/// RAII guard that closes an open portal request handle if dropped before being disarmed.
struct RequestGuard<'a> {
conn: &'a zbus::blocking::Connection,
portal: Option<String>,
handle: Option<OwnedObjectPath>,
}
impl<'a> RequestGuard<'a> {
/// Creates a new guard for the given request `handle`.
fn new(conn: &'a zbus::blocking::Connection, portal: Option<String>, handle: OwnedObjectPath) -> Self {
Self { conn, portal, handle: Some(handle) }
}
/// Disarms the guard when a response has been successfully received, preventing `Close` from being called on drop.
fn disarm(&mut self) {
self.handle = None;
}
/// Returns a reference to the request handle, if still active.
fn handle(&self) -> Option<&OwnedObjectPath> {
self.handle.as_ref()
}
}
impl<'a> Drop for RequestGuard<'a> {
/// Closes the active portal request handle when dropped unless disarmed.
fn drop(&mut self) {
if let Some(handle) = self.handle.take() {
close_request(self.conn, self.portal.as_deref(), &handle);
}
}
}
/// Asks the XDG desktop portal on `conn` for the `SaveFile` dialog of `req` and waits for its answer.
pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result<Option<Saved>, String> {
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
const REQUEST: &str = "org.freedesktop.portal.Request";
static NEXT: AtomicU32 = AtomicU32::new(0);
let token = format!("wordcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
// Listen before asking, on every request path: a portal older than version 0.9 ignores
// `handle_token` and answers on the request path it returns, and its answer can come before
// the call's reply. The stream keeps every `Response` from the time it is registered.
let dbus = zbus::blocking::fdo::DBusProxy::new(conn).map_err(|e| e.to_string())?;
let rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender(DESKTOP)
.and_then(|b| b.interface(REQUEST))
.and_then(|b| b.member("Response"))
.map_err(|e| e.to_string())?
.build();
dbus.add_match_rule(rule).map_err(|e| e.to_string())?;
let owner_rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender("org.freedesktop.DBus")
.and_then(|b| b.interface("org.freedesktop.DBus"))
.and_then(|b| b.member("NameOwnerChanged"))
.map_err(|e| e.to_string())?
.build();
dbus.add_match_rule(owner_rule).map_err(|e| e.to_string())?;
let responses = MessageIterator::from(conn);
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
let start = std::time::Instant::now();
let call = chooser.call_method("SaveFile", &(req.parent.as_str(), "Save As", options(req, &token))).map_err(|e| e.to_string())?;
let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
let portal = call.header().sender().map(|s| s.as_str().to_string());
let mut guard = RequestGuard::new(conn, portal.clone(), handle);
let (tx, rx) = std::sync::mpsc::channel();
let _listener = std::thread::Builder::new().name("portal save listener".into()).spawn(move || {
for msg in responses.filter_map(Result::ok) {
if tx.send(msg).is_err() {
break;
}
}
});
let reply = loop {
if req.cancel.as_ref().is_some_and(CancelToken::is_cancelled) {
return Err(ERR_CANCELLED.to_string());
}
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(m) => {
let header = m.header();
if header.path().map(|p| p.as_str()) == guard.handle().map(|h| h.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref()
{
break Ok(m);
}
#[allow(clippy::collapsible_if)]
if header.interface().map(|i| i.as_str()) == Some("org.freedesktop.DBus")
&& header.member().map(|m| m.as_str()) == Some("NameOwnerChanged")
{
if let Ok((name, old_owner, "")) = m.body().deserialize::<(&str, &str, &str)>() {
if (name == DESKTOP || portal.as_deref() == Some(name)) && (portal.is_none() || portal.as_deref() == Some(old_owner)) {
break Err("the desktop portal terminated while the save dialog was open".to_string());
}
if name.starts_with("org.freedesktop.impl.portal.desktop") {
break Err("the desktop portal backend terminated while the save dialog was open".to_string());
}
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if req.timeout.is_some_and(|t| start.elapsed() >= t) {
break Err("the save dialog request timed out".to_string());
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
break Err("the save dialog closed without an answer".to_string());
}
}
}?;
guard.disarm();
let elapsed = start.elapsed();
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
answer(code, &results, elapsed)
}
/// Returns true if `path` is located in the XDG document portal (`/run/user/<uid>/doc/…`, or
/// `/run/flatpak/doc/…` inside a Flatpak sandbox).
fn is_document_portal(path: &Path) -> bool {
let s = path.to_string_lossy();
((s.starts_with("/run/user/") || s.starts_with("/var/run/user/")) && s.contains("/doc/")) || s.starts_with("/run/flatpak/doc/")
}
/// Error message returned when a pending save request was cancelled by the caller.
pub const ERR_CANCELLED: &str = "the save dialog request was cancelled";
/// Returns true if `err` represents a save request cancelled by the caller (not a cancel in the
/// dialog itself, which is `Ok(None)`).
pub fn is_cancelled_error(err: &str) -> bool {
err == ERR_CANCELLED
}
/// Error message returned when the portal dialog does not report the selected filter and
/// the saved filename does not explicitly specify a distinct format.
pub const ERR_AMBIGUOUS_FILTER: &str = "the save dialog did not report which file type was picked";
/// Returns true if `err` represents an ambiguous filter error from the desktop portal.
pub fn is_ambiguous_filter_error(err: &str) -> bool {
err == ERR_AMBIGUOUS_FILTER
}
/// Finds the index of the filter in `filters` matching `saved_filter`, first by exact label,
/// then by unambiguous match against the filter's extensions.
fn match_filter(filters: &[Filter], saved_filter: Option<&Filter>) -> Option<usize> {
let (label, exts) = saved_filter?;
if let Some(pos) = filters.iter().position(|(l, _)| l == label) {
return Some(pos);
}
if !exts.is_empty() {
let matching: Vec<usize> = filters
.iter()
.enumerate()
.filter(|(_, (_, f_exts))| exts.iter().any(|e| f_exts.iter().any(|fe| fe.eq_ignore_ascii_case(e))))
.map(|(i, _)| i)
.collect();
if matching.len() == 1 {
return matching.first().copied();
}
}
None
}
/// Returns true if the portal did not provide a unique matching filter when multiple filters were offered
/// and the user did not explicitly type a filename with a distinct supported format extension.
pub fn is_ambiguous_filter(saved: &Saved, req: &SaveRequest) -> bool {
if req.filters.len() <= 1 {
return false;
}
if match_filter(&req.filters, saved.filter.as_ref()).is_some() {
return false;
}
let ext = saved.path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
let suggested_ext = req
.suggested
.as_deref()
.and_then(|n| Path::new(n).extension())
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()));
match ext {
Some(ext) if wordcraft_engine::io::SAVE_EXTS.contains(&ext.as_str()) => suggested_ext.as_ref() == Some(&ext),
_ => true,
}
}
/// Save As on Linux and BSDs: the portal's dialog, with the file name given the extension of the
/// file type picked in the dropdown. When that changes the name to one that already exists on disk,
/// the dialog opens again under the new name, so the portal asks before replacing it (it asked only
/// about the typed name).
pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Result<Option<PathBuf>, String> {
loop {
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
if is_ambiguous_filter(&saved, &req) {
return Err(ERR_AMBIGUOUS_FILTER.to_string());
}
let picked = match_filter(&req.filters, saved.filter.as_ref());
let exts: &[String] = match chosen_type(&req.filters, picked) {
Some(i) => req.filters.get(i).map(|(_, e)| e.as_slice()).unwrap_or(&[]),
None => &[],
};
let path = with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
let needs_reopen = entry_exists(&path) || (path != saved.path && is_document_portal(&saved.path));
if path == saved.path || !needs_reopen {
return Ok(Some(path));
}
req.name = path.file_name().map(|n| n.to_string_lossy().into_owned());
req.suggested = req.name.clone();
req.folder = path.parent().map(Path::to_path_buf);
req.current = picked;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// #6180: the file type picked in the save dialog gives the name its extension where the
/// dialog left the one it opened with, and only there.
#[test]
fn the_picked_file_type_gives_the_name_its_extension() {
let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let (pdf, docx) = (exts(&["pdf"]), exts(&["docx", "docm"]));
let opened = Some("Document1.docx");
let cases = [
// PDF picked, the name as the dialog opened (or renamed, its extension kept).
("/docs/Document1.docx", opened, &pdf, "/docs/Document1.pdf"),
("/docs/letter.docx", opened, &pdf, "/docs/letter.pdf"),
("/docs/LETTER.DOCX", opened, &pdf, "/docs/LETTER.pdf"),
// No extension: takes the picked type's extension.
("/docs/report", opened, &pdf, "/docs/report.pdf"),
// An extension WordCraft doesn't save is part of the name.
("/docs/scan.2024", opened, &pdf, "/docs/scan.2024.pdf"),
// An opened extension WordCraft doesn't save (e.g. .5 in Report 1.5) is kept as part of the stem.
("/docs/Report 1.5", Some("Report 1.5"), &docx, "/docs/Report 1.5.docx"),
// Typed, an extension WordCraft saves is the user's: kept, whatever the type says.
("/docs/notes.txt", opened, &pdf, "/docs/notes.txt"),
("/docs/page.html", opened, &pdf, "/docs/page.html"),
("/docs/file.rtf", opened, &pdf, "/docs/file.rtf"),
// Already the type's: kept.
("/docs/letter.pdf", opened, &pdf, "/docs/letter.pdf"),
("/docs/letter.PDF", opened, &pdf, "/docs/letter.PDF"),
// The dialog opened on a .dotx template: that is the extension the type replaces.
("/docs/memo.dotx", Some("memo.dotx"), &pdf, "/docs/memo.pdf"),
("/docs/doc.docx", Some("memo.dotx"), &pdf, "/docs/doc.docx"),
// Word document picked for a file named without extension.
("/docs/contract", opened, &docx, "/docs/contract.docx"),
];
for (typed, opened_with, chosen, want) in cases {
assert_eq!(
with_chosen_type(Path::new(typed), opened_with, chosen),
PathBuf::from(want),
"failed for typed={typed}, opened_with={opened_with:?}"
);
}
// No type picked: the name as typed is kept.
assert_eq!(with_chosen_type(Path::new("/docs/report.docx"), opened, &[]), PathBuf::from("/docs/report.docx"));
}
#[test]
fn file_uris_become_local_paths() {
assert_eq!(file_uri_path("file:///home/me/Document1.docx"), Some(PathBuf::from("/home/me/Document1.docx")));
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.pdf"), Some(PathBuf::from("/home/me/A bé.pdf")));
assert_eq!(file_uri_path("file://localhost/tmp/x.pdf"), Some(PathBuf::from("/tmp/x.pdf")));
assert_eq!(file_uri_path("file:///tmp/a%ZZ.pdf"), None);
assert_eq!(file_uri_path("file:///tmp/a%"), None);
assert_eq!(file_uri_path("file:///tmp/a%2"), None);
assert_eq!(file_uri_path("sftp://host/x.pdf"), None);
assert_eq!(file_uri_path("file://host/x.pdf"), None);
assert_eq!(file_uri_path("http://example.com/doc.pdf"), None);
}
#[test]
fn x11_windows_are_named_to_the_portal() {
use eframe::wgpu::rwh::{RawWindowHandle, XlibWindowHandle};
assert_eq!(parent_id(Some(RawWindowHandle::Xlib(XlibWindowHandle::new(0x3a0000f)))), "x11:3a0000f");
assert_eq!(parent_id(None), "");
}
#[test]
fn answer_parses_portal_response_or_errors_on_quick_failure() {
let mut results = HashMap::new();
let uris: Vec<String> = vec!["file:///tmp/doc.docx".to_string()];
results.insert("uris".to_string(), OwnedValue::try_from(Value::from(uris)).unwrap());
let globs: Vec<(u32, String)> = vec![(0u32, "*.docx".to_string())];
let filter: (String, Vec<(u32, String)>) = ("Word Document (*.docx)".to_string(), globs);
results.insert("current_filter".to_string(), OwnedValue::try_from(Value::from(filter)).unwrap());
// Code 0 with filter
let ans = answer(0, &results, Duration::from_millis(1000)).unwrap();
assert_eq!(
ans,
Some(Saved { path: PathBuf::from("/tmp/doc.docx"), filter: Some(("Word Document (*.docx)".to_string(), vec!["docx".to_string()])) })
);
// Code 0 without filter
let mut results_no_filter = HashMap::new();
results_no_filter.insert("uris".to_string(), results.get("uris").unwrap().clone());
let ans_no_filter = answer(0, &results_no_filter, Duration::from_millis(1000)).unwrap();
assert_eq!(ans_no_filter, Some(Saved { path: PathBuf::from("/tmp/doc.docx"), filter: None }));
// Code 1 (Cancel)
assert_eq!(answer(1, &results, Duration::from_millis(50)).unwrap(), None);
// Code 2 quick (< TOO_QUICK)
assert!(answer(2, &results, Duration::from_millis(100)).is_err());
// Code 2 slow (>= TOO_QUICK)
assert_eq!(answer(2, &results, Duration::from_millis(500)).unwrap(), None);
// Code 3 (failure)
assert!(answer(3, &results, Duration::from_millis(1000)).is_err());
}
struct KillOnDrop(std::process::Child);
impl Drop for KillOnDrop {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
/// Configuration for spawning a mock portal daemon.
struct MockPortalConfig {
code: u32,
delay_reply: Option<Duration>,
exit_after_reply: bool,
send_response: bool,
close_called: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
emit_backend_owner_loss: bool,
}
impl Default for MockPortalConfig {
fn default() -> Self {
Self { code: 0, delay_reply: None, exit_after_reply: false, send_response: true, close_called: None, emit_backend_owner_loss: false }
}
}
/// Spawns a mock portal daemon that answers `SaveFile` and handles `Close` according to `config`.
fn mock_portal_with_config(config: MockPortalConfig) -> Option<(zbus::blocking::Connection, KillOnDrop)> {
use std::io::BufRead as _;
let daemon = std::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(std::process::Stdio::piped())
.spawn()
.ok()?;
let mut guard = KillOnDrop(daemon);
let mut address = String::new();
std::io::BufReader::new(guard.0.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
let server =
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
let incoming = zbus::blocking::MessageIterator::from(&server);
let thread_address = address.clone();
std::thread::spawn(move || {
for msg in incoming {
let Ok(msg) = msg else { break };
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall {
continue;
}
if header.member().map(|m| m.as_str()) == Some("Close") {
if let Some(flag) = &config.close_called {
flag.store(true, Ordering::Relaxed);
}
let _ = server.reply(&header, &());
continue;
}
if header.member().map(|m| m.as_str()) != Some("SaveFile") {
continue;
}
let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap<String, OwnedValue>)>() else {
break;
};
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
let token = text("handle_token").unwrap_or_default();
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
if config.exit_after_reply {
let _ = server.reply(&header, &handle);
drop(server);
break;
}
if config.emit_backend_owner_loss {
let _ = server.reply(&header, &handle);
let addr = thread_address.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
if let Ok(backend) = zbus::blocking::connection::Builder::address(addr.as_str())
.and_then(|b| b.name("org.freedesktop.impl.portal.desktop.gtk"))
.and_then(|b| b.build())
{
std::thread::sleep(Duration::from_millis(50));
drop(backend);
}
});
continue;
}
if config.send_response {
let to = header.sender().cloned().map(zbus::names::BusName::from);
let results: HashMap<&str, Value<'_>> = HashMap::new();
let _ = server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(config.code, &results));
}
if let Some(delay) = config.delay_reply {
std::thread::sleep(delay);
}
if server.reply(&header, &handle).is_err() {
break;
}
}
});
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
Some((client, guard))
}
/// Spawns a mock portal daemon that answers `SaveFile`.
/// If `delay_reply` is set, it emits the Response signal immediately and delays the method
/// reply by that duration. If `exit_after_reply` is set, it replies to `SaveFile` and exits
/// without sending a `Response`.
fn mock_portal(code: u32, delay_reply: Option<Duration>, exit_after_reply: bool) -> Option<(zbus::blocking::Connection, KillOnDrop)> {
mock_portal_with_config(MockPortalConfig {
code,
delay_reply,
exit_after_reply,
send_response: true,
close_called: None,
emit_backend_owner_loss: false,
})
}
/// A delayed SaveFile method reply with an already-queued response code 2 measures elapsed time
/// from before calling SaveFile, so it is treated as a user cancellation rather than a quick
/// portal failure that re-opens another dialog.
#[test]
fn delayed_method_reply_with_queued_cancel_is_not_treated_as_quick_failure() {
let Some((client, _daemon)) = mock_portal(2, Some(Duration::from_millis(350)), false) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let req = SaveRequest {
name: Some("test.docx".to_string()),
suggested: Some("test.docx".to_string()),
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
..Default::default()
};
let res = save_file(&client, &req);
assert_eq!(res, Ok(None));
}
/// If the desktop portal terminates while a save dialog is open (e.g. crashing after replying
/// to SaveFile), save_file detects NameOwnerChanged and returns an error immediately.
#[test]
fn portal_terminating_while_open_returns_error() {
let Some((client, _daemon)) = mock_portal(0, None, true) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let req = SaveRequest {
name: Some("test.docx".to_string()),
suggested: Some("test.docx".to_string()),
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
..Default::default()
};
let res = save_file(&client, &req);
assert!(res.is_err(), "portal termination returns an error so rfd fallback runs: got {res:?}");
}
/// If the desktop portal backend implementation (e.g. GTK, GNOME, KDE) terminates while the
/// dialog is open, save_file detects NameOwnerChanged on the implementation service and returns
/// an error immediately so the rfd fallback can run.
#[test]
fn portal_backend_terminating_while_open_returns_error() {
let Some((client, _daemon)) =
mock_portal_with_config(MockPortalConfig { emit_backend_owner_loss: true, send_response: false, ..Default::default() })
else {
eprintln!("no dbus-daemon: skipped");
return;
};
let req = SaveRequest {
name: Some("test.docx".to_string()),
suggested: Some("test.docx".to_string()),
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
..Default::default()
};
let res = save_file(&client, &req);
assert!(res.is_err(), "backend termination returns an error so rfd fallback runs: got {res:?}");
assert!(res.as_ref().unwrap_err().contains("backend terminated"), "expected backend termination error: got {res:?}");
}
/// When a save request is cancelled via `CancelToken`, save_file aborts waiting for the response,
/// calls Close on the portal request object path, and returns a cancellation error immediately.
#[test]
fn save_file_cancellation_closes_request_and_returns_error() {
let close_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let Some((client, _daemon)) =
mock_portal_with_config(MockPortalConfig { send_response: false, close_called: Some(close_called.clone()), ..Default::default() })
else {
eprintln!("no dbus-daemon: skipped");
return;
};
let cancel = CancelToken::new();
let cancel_thread = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
cancel_thread.cancel();
});
let req = SaveRequest {
name: Some("test.docx".to_string()),
suggested: Some("test.docx".to_string()),
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
cancel: Some(cancel),
..Default::default()
};
let res = save_file(&client, &req);
assert!(res.is_err(), "cancelled request returns error: got {res:?}");
assert!(res.as_ref().unwrap_err().contains("cancelled"), "expected cancellation error: got {res:?}");
std::thread::sleep(Duration::from_millis(50));
assert!(close_called.load(Ordering::Relaxed), "Close must be called on portal request handle");
}
/// When a save request times out waiting for a stalled portal backend response, save_file aborts,
/// calls Close on the portal request object path, and returns a timeout error.
#[test]
fn save_file_timeout_closes_request_and_returns_error() {
let close_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let Some((client, _daemon)) =
mock_portal_with_config(MockPortalConfig { send_response: false, close_called: Some(close_called.clone()), ..Default::default() })
else {
eprintln!("no dbus-daemon: skipped");
return;
};
let req = SaveRequest {
name: Some("test.docx".to_string()),
suggested: Some("test.docx".to_string()),
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
timeout: Some(Duration::from_millis(50)),
..Default::default()
};
let res = save_file(&client, &req);
assert!(res.is_err(), "timed out request returns error: got {res:?}");
assert!(res.as_ref().unwrap_err().contains("timed out"), "expected timeout error: got {res:?}");
std::thread::sleep(Duration::from_millis(50));
assert!(close_called.load(Ordering::Relaxed), "Close must be called on portal request handle");
}
/// `entry_exists` detects existing files and dangling symlinks, distinguishing them from
/// absent entries.
#[test]
fn entry_exists_detects_dangling_symlinks() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("nonexistent_target.txt");
let link = dir.path().join("dangling_link.txt");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(!link.exists(), "target does not exist so exists() is false");
assert!(entry_exists(&link), "symlink entry exists so entry_exists() is true");
}
assert!(!entry_exists(&target), "nonexistent file does not exist");
}
/// `is_document_portal` identifies paths within the XDG document portal mount, including the
/// `/run/flatpak/doc` mount a Flatpak sandbox sees.
#[test]
fn is_document_portal_detects_portal_fuse_paths() {
assert!(is_document_portal(Path::new("/run/user/1000/doc/1234abcd/test.docx")));
assert!(is_document_portal(Path::new("/var/run/user/1000/doc/by-app/app/test.docx")));
assert!(is_document_portal(Path::new("/run/flatpak/doc/1234abcd/test.docx")));
assert!(!is_document_portal(Path::new("/home/user/Documents/test.docx")));
assert!(!is_document_portal(Path::new("/tmp/doc/test.docx")));
assert!(!is_document_portal(Path::new("/run/flatpak/other/test.docx")));
}
/// When the portal omits `current_filter`, ambiguous saves (unchanged suggested extension or bare name)
/// are rejected, while an explicitly typed different format is accepted.
#[test]
fn ambiguous_filters_require_explicit_format() {
let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let filters = vec![("Word Document (*.docx)".into(), exts(&["docx"])), ("PDF (*.pdf)".into(), exts(&["pdf"]))];
let req = SaveRequest { filters: filters.clone(), suggested: Some("Document1.docx".into()), ..Default::default() };
// Reported filter with matching label is not ambiguous.
let with_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("Word Document (*.docx)".into(), exts(&["docx"]))) };
assert_eq!(match_filter(&filters, with_filter.filter.as_ref()), Some(0));
assert!(!is_ambiguous_filter(&with_filter, &req));
// Reported filter with unmatched label but unambiguous extension (e.g. KDE *.pdf) is not ambiguous.
let kde_pdf_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("*.pdf".into(), exts(&["pdf"]))) };
assert_eq!(match_filter(&filters, kde_pdf_filter.filter.as_ref()), Some(1));
assert!(!is_ambiguous_filter(&kde_pdf_filter, &req));
// Reported filter with unrecognized extensions and unchanged suggested extension is ambiguous.
let unknown_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("Custom (*.xyz)".into(), exts(&["xyz"]))) };
assert_eq!(match_filter(&filters, unknown_filter.filter.as_ref()), None);
assert!(is_ambiguous_filter(&unknown_filter, &req));
// Multiple filters matching same extension is ambiguous when label doesn't match.
let duplicate_filters = vec![("Plain Text (*.txt)".into(), exts(&["txt"])), ("Text Document (*.txt)".into(), exts(&["txt"]))];
let dup_filter = ("*.txt".to_string(), exts(&["txt"]));
assert_eq!(match_filter(&duplicate_filters, Some(&dup_filter)), None);
// Missing filter with unchanged suggested extension is ambiguous.
let unchanged = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: None };
assert!(is_ambiguous_filter(&unchanged, &req));
// Missing filter with bare filename is ambiguous.
let bare = Saved { path: PathBuf::from("/docs/Document1"), filter: None };
assert!(is_ambiguous_filter(&bare, &req));
// Missing filter but user explicitly typed a different format is NOT ambiguous.
let explicit = Saved { path: PathBuf::from("/docs/Document1.pdf"), filter: None };
assert!(!is_ambiguous_filter(&explicit, &req));
// Single filter offered is never ambiguous even if filter is omitted.
let single_req =
SaveRequest { filters: vec![("PDF (*.pdf)".into(), exts(&["pdf"]))], suggested: Some("Document1.docx".into()), ..Default::default() };
assert!(!is_ambiguous_filter(&unchanged, &single_req));
}
}
+121 -6
View File
@@ -20,6 +20,9 @@ pub struct Fragment {
/// gives each box a new story made from these.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub parts: BTreeMap<u32, Part>,
/// Media items referenced by objects in this fragment.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "crate::byte_store")]
pub media: BTreeMap<String, Arc<Vec<u8>>>,
}
impl Fragment {
@@ -30,6 +33,8 @@ impl Fragment {
pub fn to_document(&self) -> Document {
let mut d = Document::new();
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
d.parts = self.parts.clone();
d.media = self.media.clone();
d
}
/// Whether the fragment holds an equation (in tables too), which a copy also puts on the
@@ -560,6 +565,44 @@ fn box_ids(p: &Paragraph) -> impl Iterator<Item = u32> + '_ {
p.objects.iter().flat_map(InlineObject::text_boxes)
}
/// Remap media references in an inline object.
fn remap_media_object(o: &mut InlineObject, map: &BTreeMap<String, String>, depth: usize) {
match o {
InlineObject::Image { media, .. } => {
if let Some(k) = map.get(media.as_str()) {
*media = k.clone();
}
}
InlineObject::Graphic { graphic, .. } => {
let mapped = |m: &str| map.get(m).filter(|k| k.as_str() != m);
if graphic.items.iter().any(|i| matches!(i, crate::graphic::GraphicItem::Image { media, .. } if mapped(media).is_some())) {
for i in &mut Arc::make_mut(graphic).items {
if let crate::graphic::GraphicItem::Image { media, .. } = i
&& let Some(k) = map.get(media.as_str())
{
*media = k.clone();
}
}
}
}
InlineObject::Group { children, .. } if depth < MAX_BOX_DEPTH => {
for child in children {
remap_media_object(&mut child.obj, map, depth + 1);
}
}
_ => {}
}
}
/// Remap media references in a block.
fn remap_media_block(b: &mut Block, map: &BTreeMap<String, String>) {
each_para_mut(b, 0, &mut |p| {
for o in &mut p.objects {
remap_media_object(o, map, 0);
}
});
}
impl Document {
/// Convert notes between footnotes and endnotes: footnotes (`foot`) become endnotes and
/// endnotes (`end`) footnotes, both at once to swap them. Each note's kind changes, as do its
@@ -974,7 +1017,8 @@ impl Document {
// Half a content control (one marker without the other) isn't copied.
crate::control::balance_blocks(&mut blocks);
let parts = self.text_box_parts(&blocks);
Fragment { blocks, parts }
let media = self.media_of_blocks(&blocks);
Fragment { blocks, parts, media }
}
/// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes.
@@ -996,7 +1040,8 @@ impl Document {
fn fragment_of(&self, mut blocks: Vec<Block>) -> Fragment {
crate::control::balance_blocks(&mut blocks);
let parts = self.text_box_parts(&blocks);
Fragment { blocks, parts }
let media = self.media_of_blocks(&blocks);
Fragment { blocks, parts, media }
}
/// The table whose whole text `a..b` covers when the range runs across its cells: from the
@@ -1038,7 +1083,8 @@ impl Document {
pub fn body_fragment(&self) -> Fragment {
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
let parts = self.text_box_parts(&blocks);
Fragment { blocks, parts }
let media = self.media_of_blocks(&blocks);
Fragment { blocks, parts, media }
}
/// Another document's body as a fragment of this one, to insert: the media, lists, styles,
@@ -1207,7 +1253,7 @@ impl Document {
}
let mut blocks: Vec<Block> = src.body.iter().map(|b| (**b).clone()).collect();
blocks.iter_mut().for_each(|b| link.block(b));
Fragment { blocks, parts }
Fragment { blocks, parts, media: self.media.clone() }
}
/// [`Self::adopt_with`] as blocks to place in a story directly (a reader building one): its
@@ -1215,7 +1261,7 @@ impl Document {
/// id the reader hasn't given out (ranges it holds aren't in this document yet); it moves past
/// the adopted ones.
pub fn adopt_blocks(&mut self, src: Document, keep_styles: bool, perms: &mut u32) -> Vec<Block> {
let Fragment { mut blocks, parts } = self.adopt_in(src, keep_styles, perms);
let Fragment { mut blocks, parts, .. } = self.adopt_in(src, keep_styles, perms);
let mut budget = BoxBudget::new();
self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
blocks
@@ -1241,6 +1287,52 @@ impl Document {
out
}
/// Media items referenced by objects in `blocks`, nested text box stories included.
pub fn media_of_blocks(&self, blocks: &[Block]) -> BTreeMap<String, Arc<Vec<u8>>> {
let mut keys = std::collections::BTreeSet::new();
/// Collect media keys from an inline object recursively.
fn collect_object(o: &InlineObject, keys: &mut std::collections::BTreeSet<String>, depth: usize) {
match o {
InlineObject::Image { media, .. } => {
keys.insert(media.clone());
}
InlineObject::Graphic { graphic, .. } => {
for item in &graphic.items {
if let crate::graphic::GraphicItem::Image { media, .. } = item {
keys.insert(media.clone());
}
}
}
InlineObject::Group { children, .. } if depth < MAX_BOX_DEPTH => {
for child in children {
collect_object(&child.obj, keys, depth + 1);
}
}
_ => {}
}
}
let mut collect_para = |p: &Paragraph| {
for o in &p.objects {
collect_object(o, &mut keys, 0);
}
};
for b in blocks {
each_para(b, 0, &mut collect_para);
}
for part in self.text_box_parts(blocks).values() {
for b in &part.blocks {
each_para(b, 0, &mut collect_para);
}
}
let mut out = BTreeMap::new();
for k in keys {
if let Some(data) = self.media.get(&k) {
out.insert(k, data.clone());
}
}
out
}
fn copy_blocks(&self, a: &Pos, b: &Pos) -> Vec<Block> {
let (a, b) = order(a, b);
let mut out = Vec::new();
@@ -1389,6 +1481,18 @@ impl Document {
/// Insert a fragment at `pos`; returns the position after the pasted content.
pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result<Pos> {
let mut media_remap = BTreeMap::new();
for (k, v) in &frag.media {
if let Some(existing) = self.media.get(k) {
if existing.as_slice() != v.as_slice() {
let ext = k.rsplit_once('.').map_or("png", |(_, ext)| ext);
let new_key = self.add_media((**v).clone(), ext);
media_remap.insert(k.clone(), new_key);
}
} else {
self.media.insert(k.clone(), v.clone());
}
}
// Tables that would nest deeper than the limit here become their paragraphs (#1596).
let room = MAX_TABLE_DEPTH.saturating_sub(pos.path.depth());
let mut blocks = Vec::with_capacity(frag.blocks.len());
@@ -1401,9 +1505,20 @@ impl Document {
if self.para_at(pos).is_none() {
return Err(DocError::BadPath(pos.path.to_string()));
}
let mut parts = frag.parts.clone();
if !media_remap.is_empty() {
for b in &mut blocks {
remap_media_block(b, &media_remap);
}
for part in parts.values_mut() {
for b in &mut part.blocks {
remap_media_block(Arc::make_mut(b), &media_remap);
}
}
}
// Pasted text boxes get stories of their own (never shared with the copy's source).
let mut budget = BoxBudget::new();
self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget);
self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
if budget.skipped > 0 {
// Once per paste, not once per box (review of #3874).
log::warn!(
+78 -1
View File
@@ -326,7 +326,7 @@ fn fan_out(story: Vec<Arc<Block>>, refs: usize) -> Fragment {
}
let mut parts = BTreeMap::new();
parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: story, body: Default::default() });
Fragment { blocks: vec![Block::Para(p)], parts }
Fragment { blocks: vec![Block::Para(p)], parts, ..Default::default() }
}
#[test]
@@ -911,3 +911,80 @@ fn an_adopted_notes_text_box_gets_a_story_of_its_own() {
assert_eq!(part.kind, PartKind::TextBox);
assert!(matches!(part.blocks.first().map(|b| &**b), Some(Block::Para(p)) if p.text == "their box"));
}
#[test]
fn media_of_blocks_collects_graphic_and_group_images() {
let mut d = doc(&["test"]);
d.media.insert("graphic_img.png".into(), Arc::new(vec![1, 2, 3]));
d.media.insert("group_img.png".into(), Arc::new(vec![4, 5, 6]));
let graphic = crate::graphic::Graphic {
kind: crate::graphic::GraphicKind::Diagram,
items: vec![crate::graphic::GraphicItem::Image { rect: [0.0, 0.0, 10.0, 10.0], media: "graphic_img.png".into() }],
..Default::default()
};
let group = InlineObject::Group {
w: 50.0,
h: 50.0,
float: Default::default(),
ch_w: 50.0,
ch_h: 50.0,
canvas: None,
children: vec![crate::para::GroupChild {
x: 0.0,
y: 0.0,
obj: InlineObject::Image {
media: "group_img.png".into(),
w: 10.0,
h: 10.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
},
}],
};
let mut p = Paragraph::with_text(&crate::para::OBJ.to_string(), CharProps::default());
p.objects.push(InlineObject::Graphic { w: 100.0, h: 100.0, float: Default::default(), alt: String::new(), graphic: Arc::new(graphic) });
p.objects.push(group);
let blocks = vec![Block::Para(p)];
let collected = d.media_of_blocks(&blocks);
assert!(collected.contains_key("graphic_img.png"));
assert!(collected.contains_key("group_img.png"));
}
#[test]
fn insert_fragment_remaps_conflicting_media_keys() {
let mut d = doc(&["destination"]);
d.media.insert("image1.png".into(), Arc::new(vec![1, 1, 1]));
let mut frag_media = std::collections::BTreeMap::new();
frag_media.insert("image1.png".into(), Arc::new(vec![2, 2, 2]));
let mut p = Paragraph::with_text(&crate::para::OBJ.to_string(), CharProps::default());
p.objects.push(InlineObject::Image {
media: "image1.png".into(),
w: 10.0,
h: 10.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
});
let frag = Fragment { blocks: vec![Block::Para(p)], parts: Default::default(), media: frag_media };
let pos = Pos::body(0, 0);
d.insert_fragment(&pos, &frag).unwrap();
// The original destination media is preserved.
assert_eq!(d.media["image1.png"].as_slice(), &[1, 1, 1]);
// A second media key was created for the conflicting fragment media.
assert_eq!(d.media.len(), 2);
let other_key = d.media.keys().find(|k| k.as_str() != "image1.png").unwrap();
assert_eq!(d.media[other_key].as_slice(), &[2, 2, 2]);
// The inserted paragraph references the remapped key.
let para = d.para(StoryRef::Body, &Path::top(0)).unwrap();
let remapped = para.objects.iter().find_map(|o| if let InlineObject::Image { media, .. } = o { Some(media.as_str()) } else { None }).unwrap();
assert_eq!(remapped, other_key);
}
+2
View File
@@ -24,6 +24,7 @@ pub mod insert;
pub mod inspector;
pub mod lists;
pub mod mailings;
pub mod md_source;
pub mod moves;
pub mod objects;
pub mod options;
@@ -73,6 +74,7 @@ pub fn registry() -> Registry {
v.extend(lists::specs());
v.extend(inspector::specs());
v.extend(view::specs());
v.extend(md_source::specs());
v.extend(outline::specs());
v.extend(insert::specs());
v.extend(equation::specs());
+2 -1
View File
@@ -232,7 +232,8 @@ fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult {
blocks.push(Block::Para(para));
}
s.clipboard_text = rows.join("\n");
s.clipboard = Some(Fragment { blocks, parts });
let media = s.doc.media_of_blocks(&blocks);
s.clipboard = Some(Fragment { blocks, parts, media });
Ok(super::edit::copy_result(s))
}
+11 -5
View File
@@ -79,12 +79,14 @@ pub fn specs() -> Vec<CommandSpec> {
Some("the Spike is empty")
}
}),
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V / Shift+Insert").params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#),
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste)
.key("Mod+V / Shift+Insert")
.params(r#"{"text"?: string, "html"?: string, "rtf"?: string, "privateClipboard"?: bool}"#),
CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text)
.params(r#"{"text"?: string}"#)
.key("Mod+Shift+Alt+V"),
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_merge)
.params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#),
.params(r#"{"text"?: string, "html"?: string, "rtf"?: string, "privateClipboard"?: bool}"#),
CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane);
s.view.clipboard_pane = on;
@@ -204,7 +206,10 @@ fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
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();
let mut d = f.to_document();
if d.media.is_empty() {
d.media = s.doc.media.clone();
}
(wordcraft_formats::html::export_fragment(&d), wordcraft_formats::rtf::export(&d))
}
None => (String::new(), String::new()),
@@ -362,9 +367,10 @@ impl ClipHistory {
}
pub(crate) fn push(&mut self, f: &Fragment, text: &str) {
// Text plus per-paragraph overhead; pictures are references into the document.
// Text plus per-paragraph overhead, plus the picture bytes the fragment keeps alive.
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
let media = f.media.values().map(|m| m.len()).fold(0, usize::saturating_add);
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256)).saturating_add(media);
if bytes > CLIP_ITEM_BYTES || f.blocks.is_empty() {
return;
}
+43 -3
View File
@@ -269,6 +269,10 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
super::view::restore_zoom(s, z);
}
s.path = Some(path.into());
// A Markdown file's own text, for the view to show and Save to write back as it is.
if super::md_source::is_markdown_file(std::path::Path::new(path)) {
s.md_file = markdown_file_text(path, v);
}
// Like Word, a document opened with a password is saved with it again.
if encrypted {
s.password = password.map(crate::io::Password::new);
@@ -278,6 +282,28 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
)
}
/// A Markdown file's own text as `file.open` finds it: the bytes the caller hands over (base64 in
/// `data`, the same ones `read_document` decodes), else the head of the file at `path`.
/// `FileText::from_bytes` keeps it only when it can be written back as it is.
fn markdown_file_text(path: &str, v: &Value) -> super::md_source::FileText {
if let Some(data) = p::str(v, "data") {
// `read_document` has already refused a text it cannot decode.
return super::insert::base64_decode(data).map(|bytes| super::md_source::FileText::from_bytes(&bytes)).unwrap_or_default();
}
// Without `data`, the file's own bytes; not reachable on the web, where a file only arrives
// as `data`.
#[cfg(not(target_arch = "wasm32"))]
{
let max = u64::try_from(super::md_source::MAX_SOURCE_BYTES).unwrap_or(u64::MAX).saturating_add(1);
crate::io::read_file_head(path, max).map(|bytes| super::md_source::FileText::from_bytes(&bytes)).unwrap_or_default()
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
super::md_source::FileText::default()
}
}
/// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only
/// naming it), read with `password`, and whether it was encrypted: what `file.open` and
/// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes
@@ -364,6 +390,8 @@ fn encrypt(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"encrypted": s.password.is_some()}))
}
/// Executes `file.save`: writes the document or text to disk, preserving exact file text for
/// unedited Markdown files, and updating session path and dirty state.
fn save(s: &mut Session, v: &Value) -> CmdResult {
let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) {
Some(p) => p,
@@ -407,20 +435,32 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
let password = s.password.as_ref().map(crate::io::Password::as_str);
// A header/footer just made and left empty isn't saved (#960).
let copy = super::insert::saved_copy(s);
if let Err(e) = crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts) {
// A Markdown file's own text goes back as it is while it still says what the document holds;
// once it doesn't (an edit in the formatted view), the document is written out again.
let exact = super::md_source::text_to_save(s, &path, opts.markup);
let rewritten = exact.is_none() && !s.md_file.is_empty() && super::md_source::is_markdown_file(&path);
let written = match &exact {
Some(text) => crate::io::save_text_path(&path, text),
None => crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts),
};
if let Err(e) = written {
(s.doc.settings.zoom, s.doc.core.last_modified_by, s.doc.core.created, s.doc.core.modified, s.doc.core.revision) = before_stamp;
return Err(CmdError::Failed(e));
}
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
// A Markdown file saved as another one loses nothing its own save wouldn't: it moves there.
let own_file = s.path.as_deref() == Some(path.as_path());
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
let markdown_to_markdown = super::md_source::is_markdown_file(&path) && s.path.as_deref().is_some_and(super::md_source::is_markdown_file);
if own_file || markdown_to_markdown || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
s.relayout();
// What the file holds now.
s.md_file = super::md_source::FileText::saved(exact.filter(|_| super::md_source::is_markdown_file(&path)));
}
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted, "rewritten": rewritten}))
}
/// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)?
+897
View File
@@ -0,0 +1,897 @@
//! View › Markdown Source: a Markdown file's document as editable text.
//!
//! The document holds no Markdown while it is edited, so entering writes it out as text, and the
//! text waits beside the document ([`MdSource`]) while it is edited. Leaving reads a changed text
//! back into a new document; one Undo takes back the whole visit. A visit that changes nothing
//! leaves the document as it was, whatever Markdown can't represent.
//!
//! While the text is open it is the document: commands that edit the document are refused, those
//! that read it (Save, Export) first get the text applied, and those that change the view leave.
//!
//! The file's own text is kept beside the document too ([`FileText`]): while it still stands for
//! the document, the view shows it and Save writes it, unchanged. An edit in the formatted view
//! makes the document something only writing it out again can say.
use std::cell::RefCell;
use std::sync::Arc;
use serde_json::{Value, json};
use wordcraft_doc::{Document, Path, Pos, StoryRef};
use crate::doc_state::Selection;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// The largest text the view takes, in bytes: its text box lays all of it out after each edit.
pub const MAX_SOURCE_BYTES: usize = 2 * 1024 * 1024;
const LABEL: &str = "Markdown Source";
/// The text of an open Markdown Source view.
#[derive(Clone)]
pub struct MdSource {
/// What the view shows and edits.
pub text: String,
/// The caret in `text`, as a byte offset. The front end keeps it current.
pub caret: usize,
/// The front end still has to put its caret at `caret` and show it.
pub place_caret: bool,
/// The front end still has to reset its typing history from earlier visits.
pub reset_undo: bool,
/// Markdown can't represent all of the document: applying a changed text loses something.
pub loses_content: bool,
/// When the front end last applied the text while it was being typed, on its own clock.
pub applied_at_ms: f64,
/// The text on entry.
entry_text: Arc<str>,
/// The document on entry, which that text stands for: back to it, nothing is lost.
entry_doc: Arc<Document>,
/// The line each top-level block of `entry_doc` starts on.
entry_lines: Vec<usize>,
/// The text the document now holds.
applied: Arc<str>,
/// The visit has its undo step.
has_step: bool,
/// The undo mark recorded when the visit took its undo step, if one was taken.
step: Option<u64>,
/// The line each top-level block starts on, from the last parse.
block_lines: Vec<usize>,
}
impl MdSource {
/// The text holds edits the document doesn't have yet.
pub fn is_pending(&self) -> bool {
self.text != *self.applied
}
}
/// A Markdown file's own text, which keeps what the document can't (front matter, comments, a
/// code block's language, the author's own spacing): the text the file was opened or last saved
/// with, and the last text the view applied. Either stands for the document while reading it
/// gives the document as it is, so an Undo back to either finds it again.
#[derive(Clone, Default)]
pub struct FileText {
saved: Option<String>,
latest: Option<String>,
cached_exact: RefCell<Option<(u64, Option<String>)>>,
}
impl FileText {
/// A file's bytes as its text, if they can be written back as they are: valid UTF-8,
/// not empty, and no longer than the view takes.
pub fn from_bytes(bytes: &[u8]) -> Self {
let keep = !bytes.is_empty() && bytes.len() <= MAX_SOURCE_BYTES;
let saved = keep.then(|| std::str::from_utf8(bytes).ok()).flatten().map(str::to_string);
Self { saved, latest: None, cached_exact: RefCell::new(None) }
}
/// The text just written to the file, which is the file's own text now.
pub fn saved(text: Option<String>) -> Self {
Self { saved: text, latest: None, cached_exact: RefCell::new(None) }
}
/// No text is kept.
pub fn is_empty(&self) -> bool {
self.saved.is_none() && self.latest.is_none()
}
}
/// The kept text that stands for the document as it is, if one does: the latest first.
fn exact_text(s: &Session) -> Option<String> {
if let Some((cached_rev, ref res)) = *s.md_file.cached_exact.borrow()
&& cached_rev == s.rev()
{
return res.clone();
}
let kept = [s.md_file.latest.as_deref(), s.md_file.saved.as_deref()];
let res = kept.into_iter().flatten().find(|text| parse(&s.doc, text).same_content(&s.doc)).map(str::to_string);
*s.md_file.cached_exact.borrow_mut() = Some((s.rev(), res.clone()));
res
}
/// What Save writes to the Markdown file `path`, in place of the document written out again: the
/// file's own text, while it stands for the document. Not for a Print Markup copy, which adds
/// the comments.
pub(crate) fn text_to_save(s: &Session, path: &std::path::Path, markup: bool) -> Option<String> {
if markup || !is_markdown_file(path) {
return None;
}
exact_text(s)
}
/// Command specifications for Markdown Source view toggling and text inspection.
pub fn specs() -> Vec<CommandSpec> {
vec![
// Not `pure`: leaving edits the document. Entering changes nothing, so it adds no undo step.
CommandSpec::new(TOGGLE, LABEL, "View › Views", toggle).params(r#"{"value"?: bool}"#).when(can_toggle),
CommandSpec::new(TEXT, "Markdown Source Text", "View › Views", text)
.params(r#"{"text"?: string (up to 2 MB), "caret"?: integer >= 0}"#)
.when(|s| s.md_source.is_none().then_some("only in Markdown Source")),
]
}
const TOGGLE: &str = "view.markdownSource";
const TEXT: &str = "view.markdownSourceText";
/// The views that replace this one: they show the document, so the text goes into it first.
const LEAVES: [&str; 5] = ["view.printLayout", "view.webLayout", "view.draft", "view.outline", "view.readMode"];
/// Whether a file's name says it is Markdown.
pub fn is_markdown_file(path: &std::path::Path) -> bool {
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
}
/// Whether the current session's path points to a Markdown file.
fn is_markdown_path(s: &Session) -> bool {
s.path.as_deref().is_some_and(is_markdown_file)
}
/// Checks whether the Markdown Source view can currently be toggled in `s`.
fn can_toggle(s: &Session) -> Option<&'static str> {
if s.md_source.is_some() {
None
} else if !is_markdown_path(s) {
Some("only for Markdown (.md) files")
} else if s.view.read_mode {
Some("not in Read Mode")
} else if s.doc.settings.track_changes || !s.doc.revisions.is_empty() {
Some("not with tracked changes")
} else if s.doc.settings.protection.is_some() {
Some("not in a protected document")
} else {
None
}
}
/// Toggles or sets the Markdown Source view state.
fn toggle(s: &mut Session, v: &Value) -> CmdResult {
let on = p::bool(v, "value").unwrap_or(s.md_source.is_none());
match (on, s.md_source.is_some()) {
(true, false) => enter(s)?,
(false, true) => leave(s),
_ => {}
}
Ok(json!({"value": s.md_source.is_some()}))
}
/// Gets or sets the Markdown Source text and caret position.
fn text(s: &mut Session, v: &Value) -> CmdResult {
let new_text = p::str(v, "text");
if new_text.is_some_and(|t| t.len() > MAX_SOURCE_BYTES) {
return Err(CmdError::Params(too_long()));
}
if let Some(cv) = v.get("caret").filter(|c| !c.is_null())
&& cv.as_u64().is_none()
{
return Err(CmdError::Params("`caret` must be a non-negative integer".into()));
}
let Some(src) = s.md_source.as_mut() else { return Err(CmdError::Disabled("only in Markdown Source".into())) };
let mut edited = false;
if let Some(t) = new_text {
edited = src.text != t;
src.text = t.to_string();
src.caret = src.text.floor_char_boundary(src.caret.min(src.text.len()));
}
if let Some(caret) = v.get("caret").and_then(Value::as_u64) {
let caret = usize::try_from(caret).unwrap_or(usize::MAX).min(src.text.len());
src.caret = src.text.floor_char_boundary(caret);
src.place_caret = true;
}
if edited {
src.place_caret = true;
s.dirty = true;
}
let mut result = json!({"caret": src.caret, "losesContent": src.loses_content});
if new_text.is_none() {
result["text"] = json!(&src.text);
}
Ok(result)
}
/// Error message returned when Markdown Source text exceeds [`MAX_SOURCE_BYTES`].
fn too_long() -> String {
format!("Markdown Source takes up to {} MB of text", MAX_SOURCE_BYTES / (1024 * 1024))
}
/// Enters Markdown Source view, preparing source text and mapping caret position.
fn enter(s: &mut Session) -> Result<(), CmdError> {
// The file's own text when it still says what the document holds, so nothing is lost.
let (text, loses_content, block_lines) = match exact_text(s) {
Some(text) => {
let (_, lines) = parse_with_lines(&s.doc, &text);
(text, false, lines)
}
None => {
let text = wordcraft_formats::markdown::export(&s.doc);
if text.len() > MAX_SOURCE_BYTES {
return Err(CmdError::Failed(too_long()));
}
let (doc, lines) = parse_with_lines(&s.doc, &text);
let loses_content = !doc.same_content(&s.doc);
(text, loses_content, lines)
}
};
let block = if s.sel.focus.story == StoryRef::Body { s.sel.focus.path.0.first().copied().unwrap_or(0) as usize } else { 0 };
let line = block_lines.get(block).copied().unwrap_or(0);
let caret = line_start_byte(&text, line);
let text_arc: Arc<str> = Arc::from(text.as_str());
let entry_lines = block_lines.clone();
s.md_source = Some(MdSource {
caret,
place_caret: true,
reset_undo: true,
loses_content,
applied_at_ms: 0.0,
entry_doc: Arc::new(s.doc.clone()),
entry_lines,
entry_text: text_arc.clone(),
applied: text_arc,
text,
has_step: false,
step: None,
block_lines,
});
Ok(())
}
/// Leaves Markdown Source view, applying changes to the document model.
fn leave(s: &mut Session) {
// `Session::run` took a step for this command: the visit's first, or one too many.
// If the toggle was joined to an earlier edit (`join_next_undo`), it created no checkpoint.
let had_step = s.md_source.as_ref().is_some_and(|src| src.has_step);
let step = s.md_source.as_ref().and_then(|src| src.step);
let mark = s.undo_mark();
let toggle_created_step = step.is_some_and(|step| mark > step);
if toggle_created_step {
s.drop_last_undo_step();
}
let mark_after = s.undo_mark();
if let Some(src) = s.md_source.as_mut() {
src.has_step = true;
if src.step.is_none() {
src.step = Some(mark_after);
}
}
apply(s);
if let Some(src) = s.md_source.take()
&& (had_step || src.text != *src.entry_text)
{
let block = block_of_caret(&src.text, src.caret, &s.doc, &src.block_lines);
s.sel = Selection::caret(block);
}
}
/// The text into the document, if it has edits the document lacks. Its first change in a visit
/// takes the visit's undo step. Save, AutoSave and crash recovery see the text this way.
pub fn apply(s: &mut Session) {
let (text, entry_text, entry_doc, has_step, entry_lines) = {
let Some(src) = s.md_source.as_ref().filter(|src| src.is_pending()) else { return };
if src.text.len() > MAX_SOURCE_BYTES {
return;
}
(src.text.clone(), src.entry_text.clone(), src.entry_doc.clone(), src.has_step, src.entry_lines.clone())
};
let (doc, lines) = if text == *entry_text { ((*entry_doc).clone(), entry_lines) } else { parse_with_lines(&s.doc, &text) };
let step = if !has_step {
s.checkpoint(LABEL);
Some(s.undo_mark())
} else {
s.md_source.as_ref().and_then(|src| src.step)
};
s.doc = doc;
s.note_body_replaced();
s.math = None;
s.touch();
s.clamp_selection();
s.md_file.latest = Some(text.clone());
*s.md_file.cached_exact.borrow_mut() = Some((s.rev(), Some(text.clone())));
if let Some(src) = s.md_source.as_mut() {
src.applied = Arc::from(text.as_str());
src.block_lines = lines;
src.has_step = true;
src.step = step;
}
}
/// `text` as a document and block lines, with what isn't in Markdown from `current`: its page, settings,
/// properties and language, as opening the file gave it.
fn parse_with_lines(current: &Document, text: &str) -> (Document, Vec<usize>) {
let (mut doc, lines) = wordcraft_formats::markdown::import_on_with_lines(text, &current.last_section);
doc.ensure_nonempty();
doc.settings = current.settings.clone();
doc.core = current.core.clone();
Arc::make_mut(&mut doc.styles).default_chr.lang = current.styles.default_chr.lang.clone();
(doc, lines)
}
/// `text` as a document, with what isn't in Markdown from `current`: its page, settings,
/// properties and language, as opening the file gave it.
fn parse(current: &Document, text: &str) -> Document {
parse_with_lines(current, text).0
}
/// The byte offset where `line` starts, or the end of a text with fewer lines.
fn line_start_byte(text: &str, line: usize) -> usize {
if line == 0 {
return 0;
}
let mut lines = 0;
for (i, &b) in text.as_bytes().iter().enumerate() {
if b == b'\n' {
lines += 1;
if lines == line {
return i.saturating_add(1);
}
}
}
text.len()
}
/// The 0-based line that byte offset `byte` belongs to.
fn line_of_byte(text: &str, byte: usize) -> usize {
let slice = text.as_bytes().get(..byte).unwrap_or(text.as_bytes());
slice.iter().filter(|&&b| b == b'\n').count()
}
/// Where the caret at byte offset `caret` of `text` lands in `doc`: at the
/// start of the top-level block its line belongs to.
fn block_of_caret(text: &str, caret: usize, doc: &Document, block_lines: &[usize]) -> Pos {
let caret_line = line_of_byte(text, caret);
let top = if block_lines.is_empty() { 0 } else { block_lines.partition_point(|&l| l <= caret_line).saturating_sub(1) };
let top = top.min(doc.body.len().saturating_sub(1));
let top = u32::try_from(top).unwrap_or(0);
let path = doc.para_paths(StoryRef::Body).into_iter().find(|path| path.0.first().is_some_and(|b| *b >= top));
match path {
Some(path) => Pos { story: StoryRef::Body, path, off: 0 },
None => Pos { story: StoryRef::Body, path: Path::top(0), off: 0 },
}
}
/// Why the open text refuses command `id`, if it does.
pub(crate) fn refusal(s: &Session, id: &str, mutates: bool) -> Option<&'static str> {
(s.md_source.is_some()
&& id != TOGGLE
&& id != TEXT
&& !LEAVES.contains(&id)
&& (mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo" | "edit.repeat")))
.then_some("leave Markdown Source first")
}
/// What a command does while the text is open, before it runs (see the module's header).
pub(crate) fn before_command(s: &mut Session, id: &str, mutates: bool) -> Result<(), CmdError> {
if s.md_source.is_none() || id == TOGGLE || id == TEXT {
return Ok(());
}
if LEAVES.contains(&id) {
s.run(TOGGLE, &json!({"value": false}))?;
} else if let Some(why) = refusal(s, id, mutates) {
return Err(CmdError::Disabled(format!("{id}: {why}")));
} else if !id.starts_with("caret.") && !id.starts_with("select.") && !id.starts_with("view.") {
apply(s);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use wordcraft_layout::ViewMode;
const SAMPLE: &str = "# Title\n\nFirst paragraph.\n\n## Second\n\n- one\n- two\n\nLast **bold** paragraph.\n";
/// Creates a test session with a Markdown document and a dummy path.
fn session(md: &str) -> Session {
let mut s = Session::new(crate::io::open_bytes("a.md", md.as_bytes()).unwrap());
s.path = Some(PathBuf::from("/tmp/wordcraft-md-source-test.md"));
s.dirty = false;
s
}
/// Runs the Markdown Source text command with `text`.
fn set_text(s: &mut Session, text: &str) {
s.run(TEXT, &json!({"text": text})).unwrap();
}
/// Extracts the plain body text of `s`.
fn body_text(s: &Session) -> String {
s.doc.plain_text(StoryRef::Body)
}
/// Verifies entering and leaving without changes does not mutate document or add undo step.
#[test]
fn a_visit_that_changes_nothing_is_no_edit() {
let mut s = session(SAMPLE);
let before = s.doc.clone();
let depth = s.undo_depth();
s.run(TOGGLE, &json!({})).unwrap();
assert!(s.md_source.is_some());
assert!(!s.dirty, "entering is not an edit");
s.run(TOGGLE, &json!({})).unwrap();
assert!(s.md_source.is_none());
assert!(s.doc.same_content(&before));
assert!(!s.dirty, "a visit without edits leaves no unsaved changes");
assert_eq!(s.undo_depth(), depth, "and no undo step");
}
/// Verifies edited source text becomes document upon leaving and one undo reverts the visit.
#[test]
fn an_edited_text_becomes_the_document_and_one_undo_takes_it_back() {
let mut s = session(SAMPLE);
let before = s.doc.clone();
s.run(TOGGLE, &json!({})).unwrap();
let text = s.run(TEXT, &json!({})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.starts_with("# Title"), "{text}");
set_text(&mut s, &text.replace("First paragraph.", "Changed paragraph."));
assert!(s.dirty, "the edit is an unsaved change");
s.run(TOGGLE, &json!({})).unwrap();
assert!(body_text(&s).contains("Changed paragraph."));
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc.same_content(&before), "one undo restores the document");
assert!(s.can_redo());
}
static TEST_DIR_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Generates a unique temporary directory for test file isolation.
fn unique_temp_dir(tag: &str) -> PathBuf {
let n = TEST_DIR_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
std::env::temp_dir().join(format!("wordcraft-md-{tag}-{}-{n}-{nanos}", std::process::id()))
}
/// Verifies saving within source view writes text to disk while keeping view open and preserving undo.
#[test]
fn save_writes_the_text_and_the_visit_stays_one_undo_step() {
let dir = unique_temp_dir("source-save");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("saved.md");
let mut s = session(SAMPLE);
s.path = Some(path.clone());
let before = s.doc.clone();
let depth = s.undo_depth();
s.run(TOGGLE, &json!({})).unwrap();
set_text(&mut s, "# Saved one\n");
s.run("file.save", &json!({})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().contains("Saved one"));
assert!(!s.dirty);
assert!(s.md_source.is_some(), "saving keeps the view open");
set_text(&mut s, "# Saved two\n");
s.run("file.save", &json!({})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().contains("Saved two"));
set_text(&mut s, "# Left three\n");
s.run(TOGGLE, &json!({})).unwrap();
assert!(body_text(&s).contains("Left three"));
assert_eq!(s.undo_depth(), depth + 1, "one step for the whole visit");
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc.same_content(&before));
let _ = std::fs::remove_dir_all(&dir);
}
/// Verifies returning to the original entry text restores formatting that Markdown cannot represent and maps the caret using entry block lines.
#[test]
fn going_back_to_the_entry_text_restores_what_markdown_cannot_hold() {
let mut s = session(SAMPLE);
std::sync::Arc::make_mut(&mut s.doc.styles).get_mut("Normal").unwrap().chr.bold = Some(true);
let before = s.doc.clone();
s.run(TOGGLE, &json!({})).unwrap();
assert!(s.md_source.as_ref().unwrap().loses_content, "Markdown doesn't keep a changed style");
let text = s.md_source.as_ref().unwrap().text.clone();
set_text(&mut s, "# Other\n");
apply(&mut s);
assert!(!s.doc.same_content(&before));
set_text(&mut s, &text);
let last_off = text.find("Last ").unwrap_or(0);
s.run(TEXT, &json!({"caret": last_off})).unwrap();
s.run(TOGGLE, &json!({})).unwrap();
assert!(s.doc.same_content(&before));
assert_eq!(s.sel.focus.path.0.first().copied(), Some(5));
}
/// Verifies that leaving Markdown Source with `join_next_undo` after an applied edit keeps the visit's undo checkpoint.
#[test]
fn leaving_joined_after_an_applied_edit_keeps_the_visits_undo_step() {
let mut s = session(SAMPLE);
let before = body_text(&s);
s.run(TOGGLE, &json!({})).unwrap();
set_text(&mut s, "# Intermediate\n");
apply(&mut s);
assert_eq!(s.undo_depth(), 1);
set_text(&mut s, "# Final\n");
s.join_next_undo();
s.run(TOGGLE, &json!({"value": false})).unwrap();
assert!(s.md_source.is_none());
assert!(body_text(&s).contains("Final"));
assert_eq!(s.undo_depth(), 1);
assert!(s.can_undo());
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(body_text(&s), before);
}
/// Verifies that opening an unedited Markdown file reports no content loss on entry.
#[test]
fn markdown_the_document_came_from_is_not_reported_as_lost() {
let s = &mut session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
assert!(!s.md_source.as_ref().unwrap().loses_content);
}
/// Verifies standard document editing commands are disabled while Markdown Source view is open.
#[test]
fn edits_are_refused_while_the_text_is_open_and_nothing_is_lost() {
let mut s = session(SAMPLE);
let before = s.doc.clone();
s.run(TOGGLE, &json!({})).unwrap();
set_text(&mut s, "# Kept\n");
for id in ["format.bold", "edit.undo", "edit.redo"] {
assert!(matches!(s.run(id, &json!({})), Err(CmdError::Disabled(_))), "{id}");
}
assert!(matches!(s.run("text.insert", &json!({"text": "x"})), Err(CmdError::Disabled(_))));
assert!(s.doc.same_content(&before));
assert_eq!(s.md_source.as_ref().unwrap().text, "# Kept\n");
}
/// Verifies switching view mode leaves Markdown Source view and applies edits.
#[test]
fn changing_the_view_leaves_with_the_edits() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
set_text(&mut s, "# Applied\n");
s.run("view.webLayout", &json!({})).unwrap();
assert!(s.md_source.is_none());
assert!(body_text(&s).contains("Applied"));
assert_eq!(s.view.mode, ViewMode::Web);
}
/// Verifies only unprotected Markdown files without tracked changes can enter Markdown Source.
#[test]
fn only_markdown_files_without_tracking_or_protection_enter() {
let mut s = Session::new(Document::from_text("Text"));
assert!(s.run(TOGGLE, &json!({})).is_err(), "no file");
s.path = Some(PathBuf::from("/tmp/a.docx"));
assert!(s.run(TOGGLE, &json!({})).is_err(), "not Markdown");
s.path = Some(PathBuf::from("/tmp/A.MARKDOWN"));
s.doc.settings.track_changes = true;
assert!(s.run(TOGGLE, &json!({})).is_err(), "tracking");
s.doc.settings.track_changes = false;
s.doc.settings.protection = Some("readOnly".into());
assert!(s.run(TOGGLE, &json!({})).is_err(), "protected");
s.doc.settings.protection = None;
s.view.read_mode = true;
assert!(s.run(TOGGLE, &json!({})).is_err(), "Read Mode");
s.view.read_mode = false;
s.run(TOGGLE, &json!({})).unwrap();
}
/// Verifies source text exceeding limit is refused with CmdError::Failed.
#[test]
fn a_text_too_long_is_refused() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
let long = "x".repeat(MAX_SOURCE_BYTES + 1);
assert!(matches!(s.run(TEXT, &json!({"text": long})), Err(CmdError::Params(_))));
s.run(TOGGLE, &json!({})).unwrap();
s.set_document(Document::from_text(&"x".repeat(MAX_SOURCE_BYTES + 1)));
s.path = Some(PathBuf::from("/tmp/a.md"));
assert!(matches!(s.run(TOGGLE, &json!({})), Err(CmdError::Failed(_))));
assert!(s.md_source.is_none());
}
/// Verifies caret stays synchronized with its paragraph when entering and leaving source view.
#[test]
fn the_caret_stays_with_its_paragraph_both_ways() {
let mut s = session(SAMPLE);
let target = s
.doc
.para_paths(StoryRef::Body)
.into_iter()
.find(|path| s.doc.para(StoryRef::Body, path).is_some_and(|para| para.plain_text().contains("Last")))
.unwrap();
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: target.clone(), off: 2 });
s.run(TOGGLE, &json!({})).unwrap();
let src = s.md_source.as_ref().unwrap();
let line: String = src.text[src.caret..].chars().take_while(|c| *c != '\n').collect();
assert!(line.contains("Last"), "caret on {line:?} in {:?}", src.text);
let changed = src.text.replace("First paragraph.", "First changed paragraph.");
let caret = changed.find("Last").unwrap();
s.run(TEXT, &json!({"text": changed, "caret": caret})).unwrap();
s.run(TOGGLE, &json!({})).unwrap();
let para = s.doc.para(StoryRef::Body, &s.sel.focus.path).unwrap();
assert!(para.plain_text().contains("Last"), "{:?}", para.plain_text());
}
/// Verifies that the caret in the trailing paragraph appended after a final table maps to the end of the text.
#[test]
fn caret_in_appended_paragraph_after_final_table_stays_at_end() {
let md = "| Col1 | Col2 |\n|---|---|\n| Val1 | Val2 |\n";
let mut s = session(md);
let trailing_para = s
.doc
.para_paths(StoryRef::Body)
.into_iter()
.find(|path| path.0.first() == Some(&1))
.expect("document ending in a table must have an appended paragraph");
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: trailing_para, off: 0 });
s.run(TOGGLE, &json!({})).unwrap();
let src = s.md_source.as_ref().unwrap();
assert_eq!(src.caret, src.text.len(), "caret must land after the table at the end of the text");
s.run(TOGGLE, &json!({})).unwrap();
assert_eq!(s.sel.focus.path.0.first(), Some(&1), "caret must stay in the appended paragraph after table");
}
/// Verifies hostile carets and texts do not panic.
#[test]
fn hostile_carets_and_texts_do_not_panic() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
for (text, caret) in [("", u64::MAX), ("é\n\n\n", 2), ("```\nopen fence", 9), ("|\n|-", 1), ("> > > >\n- - - -", 3)] {
s.run(TEXT, &json!({"text": text, "caret": caret})).unwrap();
s.run(TOGGLE, &json!({})).unwrap();
s.clamp_selection();
assert!(s.doc.para(s.sel.focus.story, &s.sel.focus.path).is_some(), "{text:?}");
s.run(TOGGLE, &json!({})).unwrap();
}
}
/// Verifies a parked document preserves its Markdown Source text.
#[test]
fn a_parked_document_keeps_its_text() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
set_text(&mut s, "# Mine\n");
let first = s.document_id();
let _second = s.add_opened_document(Document::from_text("Other"));
assert!(s.md_source.is_none(), "the other document has no text open");
#[cfg(not(target_arch = "wasm32"))]
{
let first_rec = s.recovery_docs().find(|r| r.id == first).unwrap();
assert!(first_rec.doc.plain_text(StoryRef::Body).contains("Mine"), "pending text was applied before parking");
}
s.activate(first).unwrap();
assert_eq!(s.md_source.as_ref().unwrap().text, "# Mine\n");
}
/// What the document can't hold, all of which a save without formatted edits keeps.
const OWN: &str = "---\ntitle: Notes\n---\n\nTitle\n=====\n\n<!-- a comment -->\n\n* star bullet\n* another\n\n- [ ] a task\n- [x] done\n\n```bash\necho hi\n```\n\nSome *emphasis* and a [link](https://example.com \"its title\").\nA wrapped\nline.\n\n![a picture](pic.png)\n\nLast paragraph.\n";
/// A scratch folder holding `name` with `text`, opened in a new session.
fn open_file(dir: &str, name: &str, text: &[u8]) -> (Session, PathBuf) {
let dir = unique_temp_dir(&format!("own-{dir}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, text).unwrap();
let mut s = Session::new(Document::from_text(""));
s.run("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
(s, path)
}
/// Saves the session document via `file.save`.
fn save(s: &mut Session) -> Value {
s.run("file.save", &json!({})).unwrap()
}
/// A scratch folder holding `name`, opened from the bytes `file.open` is handed (`data`, as a
/// platform hands a file over). The path holds other bytes, so a save that left them alone
/// would show.
fn open_handed_over(dir: &str, name: &str, text: &[u8]) -> (Session, PathBuf) {
let dir = unique_temp_dir(&format!("handed-{dir}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, b"not the opened text").unwrap();
let mut s = Session::new(Document::from_text(""));
let data = crate::cmd::insert::base64_encode(text);
s.run("file.open", &json!({"path": path.to_string_lossy(), "data": data})).unwrap();
(s, path)
}
/// Verifies a Markdown file opened from handed-over bytes keeps its own text and saves it back
/// byte-for-byte, as one opened from its path does.
#[test]
fn a_file_opened_from_handed_over_bytes_keeps_its_own_text() {
for (dir, text) in [("plain", OWN.to_string()), ("crlf", OWN.replace('\n', "\r\n"))] {
let (mut s, path) = open_handed_over(dir, "own.md", text.as_bytes());
assert!(!s.md_file.is_empty(), "{dir}: the opened text must be kept");
assert_eq!(save(&mut s)["rewritten"], false, "{dir}");
assert_eq!(std::fs::read(&path).unwrap(), text.as_bytes(), "{dir}");
done(&path);
}
}
/// Removes the temporary test folder created for `path`.
fn done(path: &std::path::Path) {
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
/// Verifies unedited Markdown files are written back byte-for-byte upon save.
#[test]
fn a_file_saved_without_edits_is_written_back_byte_for_byte() {
for (dir, text) in [("plain", OWN.to_string()), ("crlf", OWN.replace('\n', "\r\n")), ("bom", format!("\u{FEFF}{OWN}"))] {
let (mut s, path) = open_file(dir, "own.md", text.as_bytes());
assert_eq!(save(&mut s)["rewritten"], false, "{dir}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), text, "{dir}");
done(&path);
}
}
/// Verifies BOM-prefixed Markdown files retain original bytes when saved without edits.
#[test]
fn a_bom_prefixed_file_saved_without_edits_retains_its_original_bytes() {
let bom = [b"\xEF\xBB\xBF".as_slice(), OWN.as_bytes()].concat();
let (mut s, path) = open_file("bom-exact", "own.md", &bom);
assert!(!s.md_file.is_empty(), "BOM file text must be kept");
assert_eq!(save(&mut s)["rewritten"], false);
let read = std::fs::read(&path).unwrap();
assert_eq!(read, bom, "Saved bytes must match original BOM bytes byte-for-byte");
done(&path);
}
/// Verifies Markdown Source view displays the file's own unedited text.
#[test]
fn the_view_shows_the_files_own_text() {
let (mut s, path) = open_file("view", "own.md", OWN.as_bytes());
s.run(TOGGLE, &json!({})).unwrap();
let src = s.md_source.as_ref().unwrap();
assert_eq!(src.text, OWN);
assert!(!src.loses_content);
done(&path);
}
/// Verifies edits made directly in Markdown Source view are saved as typed.
#[test]
fn edits_in_the_view_are_saved_as_typed() {
let (mut s, path) = open_file("typed", "own.md", OWN.as_bytes());
s.run(TOGGLE, &json!({})).unwrap();
let typed = OWN.replace("Last paragraph.", "<!-- another -->\n\n* Last *changed* paragraph.");
set_text(&mut s, &typed);
s.run(TOGGLE, &json!({})).unwrap();
assert_eq!(save(&mut s)["rewritten"], false);
assert_eq!(std::fs::read_to_string(&path).unwrap(), typed);
// Saved while the view is open: the same.
s.run(TOGGLE, &json!({})).unwrap();
let again = typed.replace("another", "a third");
set_text(&mut s, &again);
assert_eq!(save(&mut s)["rewritten"], false);
assert_eq!(std::fs::read_to_string(&path).unwrap(), again);
done(&path);
}
/// Verifies formatted edits force re-exporting the document on save with a warning.
#[test]
fn an_edit_in_the_formatted_view_writes_the_document_out_again_once_with_a_warning() {
let (mut s, path) = open_file("formatted", "own.md", OWN.as_bytes());
s.run("text.insert", &json!({"text": "New "})).unwrap();
assert_eq!(save(&mut s)["rewritten"], true);
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("New "), "{written}");
assert!(!written.contains("<!-- a comment -->"), "written out again: {written}");
s.run("text.insert", &json!({"text": "Again "})).unwrap();
assert_eq!(save(&mut s)["rewritten"], false, "warned once: the file holds the document's own text now");
done(&path);
}
/// Verifies undoing back to original kept text restores original formatting.
#[test]
fn undoing_back_to_a_kept_text_finds_it_again() {
// A formatted edit undone.
let (mut s, path) = open_file("undo", "own.md", OWN.as_bytes());
s.run("text.insert", &json!({"text": "New "})).unwrap();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(save(&mut s)["rewritten"], false);
assert_eq!(std::fs::read_to_string(&path).unwrap(), OWN);
// A visit to the view undone: back to the opened text, then redone: the typed one.
s.run(TOGGLE, &json!({})).unwrap();
let typed = OWN.replace("Last paragraph.", "Typed paragraph.");
set_text(&mut s, &typed);
s.run(TOGGLE, &json!({})).unwrap();
s.run("edit.undo", &json!({})).unwrap();
save(&mut s);
assert_eq!(std::fs::read_to_string(&path).unwrap(), OWN);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(save(&mut s)["rewritten"], true, "the typed text was given up for the saved one");
assert!(std::fs::read_to_string(&path).unwrap().contains("Typed paragraph."));
done(&path);
}
/// Verifies files that cannot go back as they were are written out without rewriting warnings.
#[test]
fn files_that_cannot_go_back_as_they_were_are_written_out_without_a_warning() {
let latin1 = b"Caf\xE9 au lait.\n".to_vec();
for (dir, bytes) in [("latin1", latin1), ("empty", Vec::new())] {
let (mut s, path) = open_file(dir, "own.md", &bytes);
assert!(s.md_file.is_empty(), "{dir}");
s.run("text.insert", &json!({"text": "New "})).unwrap();
assert_eq!(save(&mut s)["rewritten"], false, "{dir}");
done(&path);
}
// A Word document saved as Markdown is a copy written out as always.
let mut s = Session::new(Document::from_text("Hello"));
let dir = unique_temp_dir("own-docx");
std::fs::create_dir_all(&dir).unwrap();
s.path = Some(dir.join("a.docx"));
let r = s.run("file.saveAs", &json!({"path": dir.join("a.md").to_string_lossy()})).unwrap();
assert_eq!(r["rewritten"], false);
let _ = std::fs::remove_dir_all(&dir);
}
/// Verifies markdown source text command sets place_caret flag on explicit carets and edits.
#[test]
fn markdown_source_text_command_sets_place_caret() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
assert!(s.md_source.as_ref().unwrap().place_caret);
s.md_source.as_mut().unwrap().place_caret = false;
s.run(TEXT, &json!({"caret": 3})).unwrap();
assert!(s.md_source.as_ref().unwrap().place_caret);
s.md_source.as_mut().unwrap().place_caret = false;
s.run(TEXT, &json!({"text": "# Changed\n"})).unwrap();
assert!(s.md_source.as_ref().unwrap().place_caret);
}
/// Verifies caret finds its matching line in file's own text.
#[test]
fn the_caret_finds_its_line_in_the_files_own_text() {
let (mut s, path) = open_file("caret", "own.md", OWN.as_bytes());
let target = s
.doc
.para_paths(StoryRef::Body)
.into_iter()
.find(|path| s.doc.para(StoryRef::Body, path).is_some_and(|para| para.plain_text().contains("Last")))
.unwrap();
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: target, off: 0 });
s.run(TOGGLE, &json!({})).unwrap();
let src = s.md_source.as_ref().unwrap();
let line: String = src.text[src.caret..].chars().take_while(|c| *c != '\n').collect();
assert_eq!(line, "Last paragraph.");
done(&path);
}
/// Verifies opening another document over session closes Markdown Source view.
#[test]
fn opening_over_the_document_closes_the_view() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
s.set_document(Document::from_text("New"));
assert!(s.md_source.is_none());
}
/// Verifies invalid and negative carets are refused with CmdError::Params.
#[test]
fn invalid_and_negative_carets_are_refused() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
for bad in [json!({"caret": -1}), json!({"caret": -100}), json!({"caret": 2.5}), json!({"caret": "three"})] {
assert!(matches!(s.run(TEXT, &bad), Err(CmdError::Params(_))), "{bad:?}");
}
}
/// Verifies markdown source text command is marked as mutating.
#[test]
fn markdown_source_text_is_not_marked_pure() {
let s = session(SAMPLE);
let spec = s.registry.get(TEXT).unwrap();
assert!(spec.mutates, "view.markdownSourceText mutates session and dirty state");
}
}
+19 -4
View File
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special)
.key("Mod+Alt+V")
.params(r#"{"as"?: "formatted"|"rtf"|"html"|"picture"|"text", "text"?: string, "html"?: string, "rtf"?: string, "picture"?: base64}"#),
.params(r#"{"as"?: "formatted"|"rtf"|"html"|"picture"|"text", "text"?: string, "html"?: string, "rtf"?: string, "picture"?: base64, "privateClipboard"?: bool}"#),
]
}
@@ -58,11 +58,18 @@ fn looks_like_html(t: &str) -> bool {
t.starts_with('<') && (t.contains("</") || t.contains("/>"))
}
/// Whether the system clipboard still holds this window's copy. A front end that can check says
/// so in `privateClipboard`; callers that cannot (an agent, the CLI) omit it, and the session's
/// copy is used as before.
fn owns_clipboard(v: &Value) -> bool {
p::bool(v, "privateClipboard").unwrap_or(true)
}
/// The source for a format, if the clipboard has it.
fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
let non_empty = |t: String| if t.is_empty() { None } else { Some(t) };
match fmt {
"formatted" => {
"formatted" if owns_clipboard(v) => {
let f = s.clipboard.as_ref()?;
// System clipboard text that isn't our own copy means someone else copied since.
match p::str(v, "text") {
@@ -70,6 +77,7 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
_ => Some(String::new()),
}
}
"formatted" => None,
"rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty),
"html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty),
"picture" => p::str(v, "picture").map(str::to_string).and_then(non_empty),
@@ -105,6 +113,10 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
req[k] = json!(t);
}
}
// Keep the ownership result so the dialog's list is the same as this one.
if let Some(own) = p::bool(v, "privateClipboard") {
req["privateClipboard"] = json!(own);
}
// Only the newest dialog request matters; older ones would pin their payloads.
s.ui_requests.retain(|r| r["open"] != "pasteSpecial");
s.push_ui_request(req);
@@ -265,10 +277,13 @@ pub enum Pasted {
}
/// What Paste inserts: WordCraft's own copy while the system clipboard still holds it, else
/// another program's formatted copy (HTML, RTF), else the system clipboard's text.
/// another program's formatted copy (HTML, RTF), else the system clipboard's text. A front end
/// that knows another program owns the clipboard passes `privateClipboard: false`, which keeps
/// the fragment out even when the text matches.
pub fn paste_content(s: &mut Session, v: &Value) -> Result<Pasted, CmdError> {
let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n"));
if let Some(f) = &s.clipboard
if owns_clipboard(v)
&& let Some(f) = &s.clipboard
&& text.as_ref().is_none_or(|t| f.clipboard_text() == *t)
&& (text.is_some() || (p::str(v, "html").is_none() && p::str(v, "rtf").is_none()))
{
+3 -3
View File
@@ -765,9 +765,9 @@ mod tests {
s.run("table.eraser", &json!({"page": 0, "x": a.right() + 0.5, "y": a.y + a.h / 2.0})).unwrap();
let t = first_table(&s);
assert_eq!(shape(&t), shape(&start), "no merge");
for r in 0..2 {
let right = t.rows[r].cells[0].props.borders.and_then(|b| b.right);
let left = t.rows[r].cells[1].props.borders.and_then(|b| b.left);
for (r, row) in t.rows[..2].iter().enumerate() {
let right = row.cells[0].props.borders.and_then(|b| b.right);
let left = row.cells[1].props.borders.and_then(|b| b.left);
assert!(right.is_some_and(|b| !b.is_visible()) && left.is_some_and(|b| !b.is_visible()), "row {r}");
}
assert!(t.rows[2].cells[0].props.borders.is_none(), "rows outside the edge are untouched");
+3 -1
View File
@@ -131,7 +131,9 @@ pub(super) fn quick_parts(s: &mut Session, v: &Value, gallery: &str) -> CmdResul
None => {
let e = document_entry(&s.doc, name, gallery).and_then(|i| s.doc.glossary.get(i));
let e = e.ok_or_else(|| CmdError::Params(format!("no Quick Part `{name}`")))?;
wordcraft_doc::edit::Fragment { blocks: e.blocks.iter().map(|b| (**b).clone()).collect(), parts: Default::default() }
let blocks: Vec<_> = e.blocks.iter().map(|b| (**b).clone()).collect();
let media = s.doc.media_of_blocks(&blocks);
wordcraft_doc::edit::Fragment { blocks, parts: Default::default(), media }
}
};
let at = delete_selection(s)?;
+14
View File
@@ -851,11 +851,25 @@ pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&
safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
}
/// Saves a document to `path` with optional password and export options on web targets.
#[cfg(target_arch = "wasm32")]
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>, _opts: &ExportOptions) -> Result<(), String> {
Err(format!("{}: files are saved through the browser on the web", path.display()))
}
/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`].
#[cfg(not(target_arch = "wasm32"))]
pub fn save_text_path(path: &std::path::Path, text: &str) -> Result<(), String> {
crate::file_access::check_write(&path.to_string_lossy())?;
safe_save::write(path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))
}
/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`].
#[cfg(target_arch = "wasm32")]
pub fn save_text_path(path: &std::path::Path, _text: &str) -> Result<(), String> {
Err(format!("{}: files are saved through the browser on the web", path.display()))
}
/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else the
/// legacy encoding they look like — GBK, Big5, Shift_JIS, Windows-1252… (see
/// [`wordcraft_formats::txt::decode`]).
+4
View File
@@ -164,6 +164,10 @@ pub struct Session {
typing: Option<TypedWord>,
/// Picture edits: edited media key → original media key (Reset Picture).
pub originals: std::collections::HashMap<String, String>,
/// View › Markdown Source: its text, while it is open.
pub md_source: Option<crate::cmd::md_source::MdSource>,
/// A Markdown file's own text, so Save can write it unchanged.
pub md_file: crate::cmd::md_source::FileText,
/// Picture effects, styles and borders: drawn media key → what was drawn on which bitmap,
/// so the next one replaces them instead of drawing over them (#2052).
pub picture_looks: std::collections::HashMap<String, crate::cmd::objects::PictureLook>,
+15
View File
@@ -70,6 +70,8 @@ pub struct DocState {
pub(super) layout: LayoutSlots,
pub(super) typing: Option<TypedWord>,
pub(super) originals: std::collections::HashMap<String, String>,
pub(super) md_source: Option<crate::cmd::md_source::MdSource>,
pub(super) md_file: crate::cmd::md_source::FileText,
pub(super) picture_looks: std::collections::HashMap<String, crate::cmd::objects::PictureLook>,
pub(super) versions: Vec<(String, String, Document)>,
pub(super) autosave: bool,
@@ -124,6 +126,8 @@ impl DocState {
layout: Default::default(),
typing: None,
originals: Default::default(),
md_source: None,
md_file: Default::default(),
picture_looks: Default::default(),
versions: Vec::new(),
autosave: true,
@@ -187,6 +191,7 @@ fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitle
impl Session {
/// Swap the active document's state with `d`'s: `d` then holds the document that was active.
fn swap_doc(&mut self, d: &mut DocState) {
crate::cmd::md_source::apply(self);
use std::mem::swap;
// Every field is listed, so a new one doesn't build until someone decides whether each
// open document has its own (then it goes in `DocState` and is swapped below) or the
@@ -224,6 +229,8 @@ impl Session {
layout: _,
typing: _,
originals: _,
md_source: _,
md_file: _,
picture_looks: _,
versions: _,
autosave: _,
@@ -318,6 +325,8 @@ impl Session {
layout,
typing,
originals,
md_source,
md_file,
picture_looks,
versions,
autosave,
@@ -365,6 +374,8 @@ impl Session {
swap(&mut self.layout, layout);
swap(&mut self.typing, typing);
swap(&mut self.originals, originals);
swap(&mut self.md_source, md_source);
swap(&mut self.md_file, md_file);
swap(&mut self.picture_looks, picture_looks);
swap(&mut self.versions, versions);
swap(&mut self.autosave, autosave);
@@ -389,6 +400,7 @@ impl Session {
if id == self.document_id {
return Ok(());
}
crate::cmd::md_source::apply(self);
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
let mut d = self.others.swap_remove(at);
self.swap_doc(&mut d);
@@ -469,7 +481,10 @@ impl Session {
self.add(doc, 0)
}
/// Adds `doc` to the session and makes it active, parking the previously active document
/// and applying any pending Markdown Source text first.
fn add(&mut self, mut doc: Document, untitled: u32) -> u64 {
crate::cmd::md_source::apply(self);
doc.ensure_nonempty();
let mut view = self.view.clone();
view.outline.collapsed.clear();
+5
View File
@@ -26,6 +26,11 @@ impl Session {
true
}
/// Take back the newest undo step: a command that joins an earlier step of its own.
pub(crate) fn drop_last_undo_step(&mut self) {
self.history.pop();
}
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) {
+4
View File
@@ -68,6 +68,8 @@ impl Session {
layout,
typing,
originals,
md_source,
md_file,
picture_looks,
versions,
autosave,
@@ -136,6 +138,8 @@ impl Session {
layout,
typing,
originals,
md_source,
md_file,
picture_looks,
last_command: None,
recording: None,
+17 -2
View File
@@ -46,13 +46,28 @@ impl Session {
/// A command that changes nothing is not an edit: it adds no undo step, marks no
/// unsaved changes, forces no relayout and leaves the redo stack alone (#23).
fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option<ColumnBlock>, pending: &Option<CharProps>) -> bool {
doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending
doc.same_content(&self.doc)
&& *sel == self.sel
&& *column == self.column
&& *pending == self.pending
&& !self.md_source.as_ref().is_some_and(|src| src.is_pending())
}
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
/// control channel). Mutating commands are undoable; a failed command leaves the document as
/// it was; a panic inside a command becomes an error.
pub fn run(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
if self.md_source.is_some()
&& let Some(mutates) = self.registry.get(id).map(|spec| spec.mutates)
{
crate::cmd::md_source::before_command(self, id, mutates)?;
}
self.run_command(id, params)
}
/// Dispatches `id` through the command registry with transactional rollback on failure,
/// tracking history, undo checkpoints, and IME outcomes.
fn run_command(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
// The document the command starts in, and the first id a document it opens can have.
let home = self.document_id;
let mark = document_id_mark();
@@ -160,7 +175,7 @@ impl Session {
// Whether this command pushed an undo step (a no-op command's step is dropped below,
// leaving its undo group without one).
let mut pushed = false;
if spec.mutates {
if spec.mutates && spec.id != "view.markdownSourceText" {
let label = if typing { "Typing" } else { spec.label };
// A joined command merges into its group's step; a group whose first command
// changed nothing has none, so the next command in it starts one (#4001).
+7 -1
View File
@@ -7,6 +7,8 @@ impl Session {
pub fn set_document(&mut self, doc: Document) {
self.autocap = None;
let replaced = self.document_id;
self.md_source = None;
self.md_file = Default::default();
self.document_id = next_document_id();
if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) {
*at = self.document_id;
@@ -110,6 +112,8 @@ impl Session {
// The word being typed, like `column`: valid only while `sel` is where typing left it.
typing: _,
originals: _,
md_source: _,
md_file: _,
picture_looks: _,
last_command: _,
recording: _,
@@ -253,7 +257,9 @@ impl Session {
/// Restrict Editing refuses outright. `None` when it can run (or doesn't exist).
pub fn why_disabled(&self, id: &str) -> Option<&'static str> {
let spec = self.registry.get(id)?;
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
(spec.enabled)(self)
.or_else(|| crate::cmd::md_source::refusal(self, id, spec.mutates))
.or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
}
/// Put the revision back to `rev` after a command that changed nothing or failed. What the
+98 -2
View File
@@ -1193,6 +1193,52 @@ fn clipboard_round_trip() {
assert_eq!(text(&s), "plain\ntext");
}
/// Whether any run in the body is bold.
fn any_bold(s: &Session) -> bool {
s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.runs.iter()).any(|r| r.props.bold == Some(true))
}
#[test]
fn paste_from_another_program_does_not_bring_back_our_fragment() {
// Review on #6225: when another program owns the clipboard, matching text must not make
// Paste put WordCraft's own formatted fragment back.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Bold"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard_text, "Bold");
// The same text, copied by another program: paste it as plain text, not our bold fragment.
run(&mut s, "document.setText", json!({"text": "Plain "}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "edit.paste", json!({"text": "Bold", "privateClipboard": false}));
assert_eq!(text(&s), "Plain Bold");
assert!(!any_bold(&s), "{:?}", s.doc.body);
// Callers that omit the flag (an agent, the CLI) still get the session's own copy.
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "edit.paste", json!({"text": "Bold"}));
assert!(any_bold(&s), "{:?}", s.doc.body);
}
#[test]
fn paste_special_does_not_offer_our_fragment_for_another_programs_copy() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Bold"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "edit.copy", json!({}));
let r = run(&mut s, "edit.pasteSpecial", json!({"text": "Bold", "privateClipboard": false}));
let ids: Vec<&str> = r["formats"].as_array().map(|a| a.iter().filter_map(|f| f["id"].as_str()).collect()).unwrap_or_default();
assert!(!ids.contains(&"formatted"), "{ids:?}");
assert_eq!(s.ui_requests.last().and_then(|r| r.get("privateClipboard")), Some(&json!(false)), "{:?}", s.ui_requests);
let e = s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "Bold", "privateClipboard": false}));
assert!(e.is_err(), "another program's copy cannot be pasted as our fragment");
}
/// Equations in the paragraphs of a session's body, as linear text.
fn equations(s: &Session) -> Vec<String> {
let mut out = Vec::new();
@@ -5623,16 +5669,66 @@ fn saving_a_text_file_over_itself_saves_the_document() {
assert!(!s.dirty, "{name}: saved by its own path");
// Plain text somewhere else is still a copy: the document keeps its file and its changes.
// (Markdown saved as Markdown moves there: see the test below.)
run(&mut s, "text.insert", json!({"text": "!"}));
let copy = dir.join(format!("copy-{name}"));
run(&mut s, "file.saveAs", json!({"path": copy.to_string_lossy()}));
assert!(copy.exists());
assert!(s.dirty, "{name}: a copy elsewhere leaves it unsaved");
assert_eq!(s.path.as_deref(), Some(path.as_path()));
if name == "note.txt" {
assert!(s.dirty, "{name}: a copy elsewhere leaves it unsaved");
assert_eq!(s.path.as_deref(), Some(path.as_path()));
}
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A Markdown file saved as another Markdown file is that file from now on: the next save goes
/// there and the first file stays as it was. Other formats keep the #2376 rule.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn markdown_saved_as_markdown_moves_to_the_new_file() {
let dir = std::env::temp_dir().join(format!("wordcraft-engine-md-save-as-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let first = dir.join("a.md");
std::fs::write(&first, b"First\n").unwrap();
for name in ["b.md", "c.markdown", "D.MD"] {
let mut s = s();
std::fs::write(&first, b"First\n").unwrap();
let second = dir.join(name);
run(&mut s, "file.open", json!({"path": first.to_string_lossy()}));
run(&mut s, "text.insert", json!({"text": "Hello "}));
run(&mut s, "file.saveAs", json!({"path": second.to_string_lossy()}));
assert!(!s.dirty, "{name}: saved");
assert_eq!(s.path.as_deref(), Some(second.as_path()), "{name}");
run(&mut s, "text.insert", json!({"text": "Later "}));
run(&mut s, "file.save", json!({}));
assert!(!s.dirty, "{name}: saved again");
assert!(std::fs::read_to_string(&second).unwrap().contains("Later"), "{name}: the next save goes to the new file");
assert_eq!(std::fs::read_to_string(&first).unwrap(), "First\n", "{name}: the first file is left alone");
}
// Markdown to plain text is still a copy.
let mut s = s();
run(&mut s, "file.open", json!({"path": first.to_string_lossy()}));
run(&mut s, "text.insert", json!({"text": "Hello "}));
run(&mut s, "file.saveAs", json!({"path": dir.join("copy.txt").to_string_lossy()}));
assert!(s.dirty, "a text copy leaves it unsaved");
assert_eq!(s.path.as_deref(), Some(first.as_path()));
// So is plain text to Markdown.
let note = dir.join("note.txt");
std::fs::write(&note, b"Note\n").unwrap();
let mut text = self::s();
run(&mut text, "file.open", json!({"path": note.to_string_lossy()}));
run(&mut text, "text.insert", json!({"text": "Hello "}));
run(&mut text, "file.saveAs", json!({"path": dir.join("copy.md").to_string_lossy()}));
assert!(text.dirty, "a Markdown copy of a text file leaves it unsaved");
assert_eq!(text.path.as_deref(), Some(note.as_path()));
let _ = std::fs::remove_dir_all(&dir);
}
/// A save that fails to write (no permission, disk full, a bad path) leaves the document's own
/// save stamps untouched: `stamp_save` runs before the fallible write, so a prior version of this
/// left `modified`/`created`/`last_modified_by`/`revision`/`zoom` changed for a save that never
+6 -6
View File
@@ -391,9 +391,9 @@ fn table_borders_and_borderless_round_trip() {
let mut doc = Document::new();
let mut t_borderless = Table::new(2, 2, 400.0);
t_borderless.props.style = None;
for r in 0..2 {
for c in 0..2 {
t_borderless.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))];
for row in &mut t_borderless.rows {
for cell in &mut row.cells {
cell.blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))];
}
}
doc.body = vec![Arc::new(Block::Table(t_borderless))];
@@ -407,9 +407,9 @@ fn table_borders_and_borderless_round_trip() {
let mut doc_bordered = Document::new();
let mut t_bordered = Table::new(2, 2, 400.0);
t_bordered.props.style = Some("TableGrid".to_string());
for r in 0..2 {
for c in 0..2 {
t_bordered.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))];
for row in &mut t_bordered.rows {
for cell in &mut row.cells {
cell.blocks = vec![para_block(Paragraph::with_text("cell", CharProps::default()))];
}
}
doc_bordered.body = vec![Arc::new(Block::Table(t_bordered))];
+2 -2
View File
@@ -27,7 +27,7 @@ mod inlines;
/// header made 2 million cells and took 3 to 5 GB to convert; past this, rows are left out.
pub(crate) const MAX_TABLE_CELLS: usize = 1 << 18;
pub use blocks::{Refs, import, import_on, parse};
pub use blocks::{Refs, import, import_on, import_on_with_lines, parse, parse_with_lines};
pub(crate) use export::escape;
pub use export::export;
#[cfg(test)]
@@ -35,6 +35,6 @@ use export::{escape_line_start, inlines_md, table_md, write_blocks};
pub use inlines::parse_inlines;
#[cfg(test)]
use blocks::split_row;
use blocks::{remap_original_lines, split_row};
#[cfg(test)]
mod tests;
+195 -81
View File
@@ -101,12 +101,18 @@ fn link_definition(line: &str) -> Option<(String, String)> {
/// definition. A definition anywhere else stays in the document and reads as text, which is what
/// it did before definitions were understood at all; deleting a line that was part of a
/// paragraph would lose it silently.
fn take_definitions(lines: &mut Vec<String>) -> Refs {
///
/// Returns the parsed references and the 0-based indexes of the removed lines.
fn take_definitions(lines: &mut Vec<String>) -> (Refs, Vec<usize>) {
let mut refs = Refs::new();
let mut open: Option<(char, usize)> = None;
// The start of the document begins a block.
let mut starts_block = true;
let mut removed = Vec::new();
let mut at = 0usize;
lines.retain(|line| {
let this = at;
at += 1;
let t = line.trim_start_matches([' ', '\t']);
// Four spaces of indentation is code, not a fence (`parse_blocks`), on both sides.
let marker = (indent(line) < 4).then(|| fence(t)).flatten();
@@ -130,26 +136,97 @@ fn take_definitions(lines: &mut Vec<String>) -> Refs {
// The first definition of a label wins, as CommonMark says, and definitions may
// follow one another.
refs.entry(label).or_insert(dest.into());
removed.push(this);
return false;
}
starts_block = is_blank(line) || atx_heading(t).is_some() || thematic_break(t);
true
});
refs
(refs, removed)
}
/// Maps monotonically non-decreasing line indexes in lines filtered of link definitions back to
/// their original source lines in a single linear pass.
pub(super) fn remap_original_lines(block_lines: &mut [usize], removed: &[usize]) {
let mut rem_iter = removed.iter().copied().peekable();
let mut shift = 0;
for l in block_lines.iter_mut() {
while let Some(&r) = rem_iter.peek() {
if r <= *l + shift {
shift += 1;
rem_iter.next();
} else {
break;
}
}
*l += shift;
}
}
/// Context for block parsing: depth, nesting, reference definitions, cell budget, and line recording.
struct BlockCtx<'a> {
depth: usize,
level: u8,
refs: &'a Refs,
budget: &'a std::cell::Cell<usize>,
lines_out: Option<&'a mut Vec<usize>>,
}
impl BlockCtx<'_> {
/// Records the starting line of a top-level block if line tracking is active.
fn record_line(&mut self, line: usize) {
if self.depth == 0
&& let Some(lo) = self.lines_out.as_mut()
{
lo.push(line);
}
}
/// Records `count` blocks originating at `line` if line tracking is active.
fn record_lines(&mut self, line: usize, count: usize) {
if self.depth == 0
&& let Some(lo) = self.lines_out.as_mut()
{
for _ in 0..count {
lo.push(line);
}
}
}
}
/// Parse Markdown text into the flow model.
pub fn parse(text: &str) -> model::Flow {
parse_with_lines(text).0
}
/// [`parse`] returning the flow model and the 0-based starting line of each top-level block.
pub fn parse_with_lines(text: &str) -> (model::Flow, Vec<usize>) {
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
let mut lines: Vec<String> = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect();
let refs = take_definitions(&mut lines);
let (refs, removed) = take_definitions(&mut lines);
let budget = std::cell::Cell::new(super::MAX_TABLE_CELLS);
let mut blocks = parse_blocks(&lines, 0, 0, &refs, &budget);
let mut block_lines = Vec::new();
let mut ctx = BlockCtx { depth: 0, level: 0, refs: &refs, budget: &budget, lines_out: Some(&mut block_lines) };
let mut blocks = parse_blocks(&lines, &mut ctx);
// A leading "Title" from front matter isn't supported; a first-level heading stays a heading.
if blocks.is_empty() {
blocks.push(FBlock::Para(Para::default()));
block_lines.push(0);
}
model::Flow { blocks, meta: Default::default() }
remap_original_lines(&mut block_lines, &removed);
(model::Flow { blocks, meta: Default::default() }, block_lines)
}
/// [`import_on`] returning the document and the 0-based starting line of each top-level block.
pub fn import_on_with_lines(text: &str, page: &wordcraft_doc::SectionProps) -> (Document, Vec<usize>) {
model::begin_import();
let (flow, mut lines) = parse_with_lines(text);
let doc = model::to_doc_in(&flow, page.clone());
if doc.body.len() > lines.len() {
let end_line = text.as_bytes().iter().filter(|&&b| b == b'\n').count();
lines.resize(doc.body.len(), end_line);
}
(doc, lines)
}
/// Leading tabs → spaces (tab stops of 4); the rest of the line is kept.
@@ -369,18 +446,22 @@ fn interrupts(l: &str) -> bool {
|| list_marker(t).is_some_and(|m| (!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or("")))
}
fn flush_para(lines: &mut Vec<String>, out: &mut Vec<FBlock>, depth: usize, refs: &Refs) {
/// Flushes accumulated paragraph text into `out`, recording its starting line if tracked.
fn flush_para(lines: &mut Vec<String>, start_line: &mut Option<usize>, out: &mut Vec<FBlock>, ctx: &mut BlockCtx<'_>) {
if lines.is_empty() {
*start_line = None;
return;
}
let line_no = start_line.take().unwrap_or(0);
let text = join_lines(lines);
lines.clear();
let inlines = parse_inlines(&text, &Fmt::default(), depth, refs);
let inlines = parse_inlines(&text, &Fmt::default(), ctx.depth, ctx.refs);
if inlines.is_empty() {
return;
}
let p = Para { inlines, ..Default::default() };
out.push(FBlock::Para(p));
ctx.record_line(line_no);
}
/// Paragraph lines → text with `\n` for hard breaks and spaces for soft ones.
@@ -405,32 +486,83 @@ fn join_lines(lines: &[String]) -> String {
s
}
/// Creates a single code block paragraph for an indented code line.
fn code_para(line: &str) -> FBlock {
let mut p = Para::new(Kind::Code);
p.push_text(line, &Fmt::default());
FBlock::Para(p)
}
/// Parse block structure. `level` is the list nesting level for lists found here.
/// `budget`: the table cells the document may still make ([`super::MAX_TABLE_CELLS`]).
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>) -> Vec<FBlock> {
/// Attempts to parse a table starting at `lines[*i]`. Returns `true` if a table was parsed.
fn parse_table(lines: &[String], i: &mut usize, t: &str, ctx: &mut BlockCtx<'_>, out: &mut Vec<FBlock>) -> bool {
let Some((aligns, width)) = lines.get(*i + 1).and_then(|l| delimiter_row(l, MAX_COLS)) else { return false };
let (head, head_width) = split_row(t, MAX_COLS);
if head_width != width {
return false;
}
let table_start = *i;
let mut table = FTable::default();
let cell = |s: &str, header: bool, a: Option<Align>| {
let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), ctx.depth, ctx.refs), ..Default::default() };
Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() }
};
let cols = aligns.len();
let fits = |rows: usize| rows < MAX_ROWS && ctx.budget.get() >= cols;
let mut cut = head_width > cols;
if fits(0) {
ctx.budget.set(ctx.budget.get() - cols);
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
} else {
cut = true;
}
*i += 2;
while let Some(l) = lines.get(*i) {
if is_blank(l) || !l.contains('|') || interrupts(l) {
break;
}
if !table.rows.is_empty() && fits(table.rows.len()) {
ctx.budget.set(ctx.budget.get() - cols);
let (mut cells, _) = split_row(l, cols);
cells.resize(cols, String::new());
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
} else {
cut = true;
}
*i += 1;
}
if cut {
model::dropped(model::Dropped::TableCells);
}
if !table.rows.is_empty() {
out.push(FBlock::Table(table));
ctx.record_line(table_start);
}
true
}
/// Parse block structure.
fn parse_blocks(lines: &[String], ctx: &mut BlockCtx<'_>) -> Vec<FBlock> {
let mut out = Vec::new();
let mut para: Vec<String> = Vec::new();
let mut para_start = None;
let mut i = 0;
while let Some(line) = lines.get(i) {
if is_blank(line) {
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
i += 1;
continue;
}
let ind = indent(line);
if ind >= 4 {
if !para.is_empty() {
if para_start.is_none() {
para_start = Some(i);
}
para.push(line.clone());
i += 1;
continue;
}
parse_indented_code(lines, &mut i, &mut out);
parse_indented_code(lines, &mut i, &mut out, ctx);
continue;
}
let t = line.trim_start_matches([' ', '\t']);
@@ -439,44 +571,50 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget:
let u = t.trim_end();
if !u.is_empty() && (u.chars().all(|c| c == '=') || (u.chars().all(|c| c == '-'))) {
let lvl = if u.starts_with('=') { 1 } else { 2 };
let line_no = para_start.take().unwrap_or(i);
let text = join_lines(&para);
para.clear();
let mut p = Para::new(Kind::Heading(lvl));
p.inlines = parse_inlines(&text, &Fmt::default(), depth, refs);
p.inlines = parse_inlines(&text, &Fmt::default(), ctx.depth, ctx.refs);
out.push(FBlock::Para(p));
ctx.record_line(line_no);
i += 1;
continue;
}
}
if let Some((n, body)) = atx_heading(t) {
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
let mut p = Para::new(Kind::Heading(n));
p.inlines = parse_inlines(body, &Fmt::default(), depth, refs);
p.inlines = parse_inlines(body, &Fmt::default(), ctx.depth, ctx.refs);
out.push(FBlock::Para(p));
ctx.record_line(i);
i += 1;
continue;
}
if thematic_break(t) {
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
out.push(FBlock::Para(Para::new(Kind::Rule)));
ctx.record_line(i);
i += 1;
continue;
}
if let Some((fc, fl)) = fence(t) {
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
i += 1;
while let Some(l) = lines.get(i) {
let cur = i;
i += 1;
let lt = l.trim_start_matches([' ', '\t']);
if indent(l) < 4 && fence(lt).is_some_and(|(c, n)| c == fc && n >= fl) && lt.trim_start_matches(fc).trim().is_empty() {
break;
}
out.push(code_para(strip_indent(l, ind)));
ctx.record_line(cur);
}
continue;
}
if t.starts_with("<!--") {
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
while let Some(l) = lines.get(i) {
i += 1;
if l.contains("-->") {
@@ -486,89 +624,58 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget:
continue;
}
if t.starts_with('>') {
flush_para(&mut para, &mut out, depth, refs);
parse_quote(lines, &mut i, depth, level, refs, budget, &mut out);
flush_para(&mut para, &mut para_start, &mut out, ctx);
let quote_start = i;
let before = out.len();
parse_quote(lines, &mut i, ctx, &mut out);
ctx.record_lines(quote_start, out.len() - before);
continue;
}
if para.is_empty()
&& t.contains('|')
&& let Some((aligns, width)) = lines.get(i + 1).and_then(|l| delimiter_row(l, MAX_COLS))
{
let (head, head_width) = split_row(t, MAX_COLS);
if head_width == width {
let mut table = FTable::default();
let cell = |s: &str, header: bool, a: Option<Align>| {
let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() };
Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() }
};
// The document keeps no more columns or rows than this, nor cells past its budget,
// so those aren't built; their lines are still the table's.
let cols = aligns.len();
let fits = |rows: usize| rows < MAX_ROWS && budget.get() >= cols;
let mut cut = head_width > cols;
if fits(0) {
budget.set(budget.get() - cols);
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
} else {
cut = true;
}
i += 2;
while let Some(l) = lines.get(i) {
if is_blank(l) || !l.contains('|') || interrupts(l) {
break;
}
if !table.rows.is_empty() && fits(table.rows.len()) {
budget.set(budget.get() - cols);
let (mut cells, _) = split_row(l, cols);
cells.resize(cols, String::new());
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
} else {
cut = true;
}
i += 1;
}
if cut {
model::dropped(model::Dropped::TableCells);
}
if !table.rows.is_empty() {
out.push(FBlock::Table(table));
}
continue;
}
if para.is_empty() && t.contains('|') && parse_table(lines, &mut i, t, ctx, &mut out) {
continue;
}
if let Some(m) = list_marker(t)
&& (para.is_empty() || ((!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or(""))))
{
flush_para(&mut para, &mut out, depth, refs);
i = parse_list(lines, i, depth, level, &mut out, refs, budget);
flush_para(&mut para, &mut para_start, &mut out, ctx);
i = parse_list(lines, i, ctx, &mut out);
continue;
}
if para_start.is_none() {
para_start = Some(i);
}
para.push(line.clone());
i += 1;
}
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
out
}
fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec<FBlock>) {
let mut code: Vec<&str> = Vec::new();
/// Parses indented code block starting at `lines[*i]`.
fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec<FBlock>, ctx: &mut BlockCtx<'_>) {
let mut code: Vec<(usize, &str)> = Vec::new();
while let Some(line) = lines.get(*i) {
let cur = *i;
if is_blank(line) {
code.push("");
code.push((cur, ""));
} else if indent(line) >= 4 {
code.push(strip_indent(line, 4));
code.push((cur, strip_indent(line, 4)));
} else {
break;
}
*i += 1;
}
while code.last().is_some_and(|line| line.is_empty()) {
while code.last().is_some_and(|(_, line)| line.is_empty()) {
code.pop();
}
out.extend(code.into_iter().map(code_para));
for (line_idx, text) in code {
out.push(code_para(text));
ctx.record_line(line_idx);
}
}
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>, out: &mut Vec<FBlock>) {
/// Parses block quote starting at `lines[*i]`.
fn parse_quote(lines: &[String], i: &mut usize, ctx: &BlockCtx<'_>, out: &mut Vec<FBlock>) {
let mut inner = Vec::new();
while let Some(line) = lines.get(*i) {
let trimmed = line.trim_start_matches([' ', '\t']);
@@ -582,13 +689,14 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &
}
*i += 1;
}
if depth >= MAX_NEST {
if ctx.depth >= MAX_NEST {
let mut p = Para::new(Kind::Quote);
p.push_text(&inner.join(" "), &Fmt::default());
out.push(FBlock::Para(p));
return;
}
for mut block in parse_blocks(&inner, depth + 1, level, refs, budget) {
let mut child = BlockCtx { depth: ctx.depth + 1, level: ctx.level, refs: ctx.refs, budget: ctx.budget, lines_out: None };
for mut block in parse_blocks(&inner, &mut child) {
if let FBlock::Para(p) = &mut block
&& p.kind == Kind::Normal
{
@@ -598,6 +706,7 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &
}
}
/// Computes columns and byte count for spaces following a list item marker.
fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) {
let mut cols = 0;
let mut bytes = 0;
@@ -626,7 +735,7 @@ fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) {
}
/// Parse a list starting at `lines[i]`; returns the index after it.
fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec<FBlock>, refs: &Refs, budget: &std::cell::Cell<usize>) -> usize {
fn parse_list(lines: &[String], mut i: usize, ctx: &mut BlockCtx<'_>, out: &mut Vec<FBlock>) -> usize {
let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 };
while let Some(line) = lines.get(i) {
let ind = indent(line);
@@ -638,6 +747,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
if m.ordered != first.ordered || m.ch != first.ch {
break;
}
let item_start = i;
let after = t.get(m.len..).unwrap_or("");
let (spaces, skip) = if indent(after) > 4 {
(1, 1)
@@ -667,14 +777,17 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
while item.len() > 1 && item.last().is_some_and(String::is_empty) {
item.pop();
}
let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start, ..Default::default() };
let inner = if depth >= MAX_NEST {
let li = ListInfo { ordered: first.ordered, level: ctx.level.min(8), start: first.start, ..Default::default() };
let inner = if ctx.depth >= MAX_NEST {
let mut p = Para::default();
p.push_text(&item.join(" "), &Fmt::default());
vec![FBlock::Para(p)]
} else {
parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs, budget)
let mut child =
BlockCtx { depth: ctx.depth + 1, level: ctx.level.saturating_add(1).min(8), refs: ctx.refs, budget: ctx.budget, lines_out: None };
parse_blocks(&item, &mut child)
};
let before_count = out.len();
let mut placed = false;
if inner.is_empty() {
out.push(FBlock::Para(Para { list: Some(li), ..Default::default() }));
@@ -712,6 +825,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
}
}
}
ctx.record_lines(item_start, out.len() - before_count);
// Blank lines between items.
while lines.get(i).is_some_and(|l| is_blank(l)) {
let next_is_item = lines.get(i + 1).is_some_and(|l| {
+50
View File
@@ -776,6 +776,7 @@ fn exported_link_destinations_read_back_unchanged() {
assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)");
}
/// Verifies that trailing escaped hashes on headings survive round-trip export.
#[test]
fn heading_trailing_hash_survives_round_trip() {
let heading = |md: &str| {
@@ -797,3 +798,52 @@ fn heading_trailing_hash_survives_round_trip() {
assert_eq!(export(&import(&md)), want, "second export of {src:?}");
}
}
/// Verifies that block line mapping tracks source-line indexes accurately across definitions.
#[test]
fn block_line_mapping() {
let md = "# Title\n\nFirst paragraph.\n\n## Second\n\n- one\n- two\n\n```\ncode\n```\n\nLast paragraph.\n";
let (flow, lines) = parse_with_lines(md);
assert_eq!(flow.blocks.len(), lines.len());
let (doc, doc_lines) = import_on_with_lines(md, &wordcraft_doc::SectionProps::default());
assert_eq!(doc.body.len(), doc_lines.len());
assert_eq!(lines, doc_lines);
assert_eq!(lines[0], 0);
assert_eq!(lines[1], 2);
assert_eq!(lines[2], 4);
// Reference definitions are removed from blocks, but block line numbers must match original source lines.
let md_refs = "[link]: https://example.com\n# Title\n\n[other]: https://example.org\nParagraph with [link][].\n";
let (flow, lines) = parse_with_lines(md_refs);
assert_eq!(flow.blocks.len(), lines.len());
assert_eq!(lines[0], 1, "heading on line 1 after definition on line 0");
assert_eq!(lines[1], 4, "paragraph on line 4 after definition on line 3");
}
/// Verifies linear remapping of original lines after removing definitions.
#[test]
fn remap_original_lines_cases() {
let mut lines = vec![0, 1, 2, 3, 4];
remap_original_lines(&mut lines, &[2, 5]);
assert_eq!(lines, vec![0, 1, 3, 4, 6]);
let mut lines = vec![0, 1];
remap_original_lines(&mut lines, &[1, 2, 3]);
assert_eq!(lines, vec![0, 4]);
let mut lines = vec![0];
remap_original_lines(&mut lines, &[0]);
assert_eq!(lines, vec![1]);
let mut lines = vec![1, 1, 2];
remap_original_lines(&mut lines, &[1]);
assert_eq!(lines, vec![2, 2, 3]);
let mut lines = vec![0, 2];
remap_original_lines(&mut lines, &[]);
assert_eq!(lines, vec![0, 2]);
let mut empty: Vec<usize> = Vec::new();
remap_original_lines(&mut empty, &[1, 2]);
assert!(empty.is_empty());
}
+4 -4
View File
@@ -8640,8 +8640,8 @@ fn picture_row(room: f32, min: f32, pic_h: f32, lines: usize) -> (DocLayout, Vec
t.rows[1].props.height = Some(min);
t.rows[1].props.height_rule = HeightRule::AtLeast;
}
for c in 1..3 {
t.rows[1].cells[c].blocks = lines_in_cell(lines, "N/A");
for cell in &mut t.rows[1].cells[1..3] {
cell.blocks = lines_in_cell(lines, "N/A");
}
set_at_least_row_spacing(&mut t);
let mut p = wordcraft_doc::Paragraph::with_text("", Default::default());
@@ -10327,8 +10327,8 @@ fn a_hidden_cell_mark_takes_no_room() {
let mut d = Document::from_text("Above");
let mut t = Table::new(3, 2, 300.0);
for (r, text) in [(0, "R1"), (2, "R3")] {
for c in 0..2 {
t.rows[r].cells[c].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
for cell in &mut t.rows[r].cells {
cell.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
}
}
for (cell, hide) in t.rows[1].cells.iter_mut().zip(hide) {
+2
View File
@@ -22,6 +22,7 @@ mod pictures;
mod prefs;
pub(crate) use commands::works_without_document;
pub(crate) use frame::STATUS_MS;
pub(crate) use input::control_click_as_secondary;
impl WordApp {
@@ -100,6 +101,7 @@ impl WordApp {
..UiState::default()
},
services,
clipboard_published: false,
printing: Default::default(),
print_feedback_seen: None,
integrated_titlebar: false,
+11
View File
@@ -28,6 +28,10 @@ impl WordApp {
/// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask
/// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`).
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
// While Markdown Source is open, Undo and Redo work on its typing, as Ctrl+Z does there.
if matches!(id, "edit.undo" | "edit.redo") && self.session.md_source.is_some() {
return crate::md_source::undo(self, id == "edit.redo");
}
// Comment text still being typed is written first, so a save or export has it and a
// close, New or Open counts it as a change (#1348). A draft the document refuses is not
// part of it: saving and exporting go on without it (the refusal stays on the status
@@ -256,6 +260,13 @@ impl WordApp {
{
self.autosave_path = self.session.path.clone();
}
// A Markdown file edited in the formatted view was written out again, not kept as it was.
if let Ok(v) = &r
&& matches!(id, "file.save" | "file.saveAs")
&& v.get("rewritten").and_then(Value::as_bool) == Some(true)
{
self.status_long(tl!("Saved. Edits in the formatted view meant writing the Markdown out again, so its own layout wasn't kept."));
}
// A renamed file is the same file: AutoSave keeps covering it, and Recent drops the old name.
if r.is_ok()
&& id == "file.rename"
+7 -4
View File
@@ -297,16 +297,19 @@ impl WordApp {
self.default_save_name()
}
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
/// otherwise .docx. A document never saved and without a title of its own is named after its
/// first words, as Word suggests them (#5793), instead of `Document1`.
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros) or,
/// in a dialog, its own Markdown format (a browser's download keeps the whole document as
/// .docx), otherwise .docx. A document never saved and without a title of its own is named after
/// its first words, as Word suggests them (#5793), instead of `Document1`.
pub(crate) fn default_save_name(&self) -> String {
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string());
let unnamed = self.session.path.is_none() && self.session.file_name.is_none() && self.session.doc.core.title.trim().is_empty();
let first = || self.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).find_map(|t| suggested_stem(&t));
let stem = unnamed.then(first).flatten().unwrap_or_else(|| self.title_stem());
let markdown =
self.services.download.is_none() && self.session.path.as_deref().is_some_and(wordcraft_engine::cmd::md_source::is_markdown_file);
match ext {
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"),
Some(e) if markdown || wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"),
_ => format!("{stem}.docx"),
}
}
+17 -1
View File
@@ -7,6 +7,11 @@ use super::{
use crate::i18n;
/// How long the status bar shows a message, in milliseconds.
pub(crate) const STATUS_MS: f64 = 6000.0;
/// How long [`WordApp::status_long`] shows one: a warning that is easy to miss in six seconds.
const STATUS_LONG_MS: f64 = 20000.0;
impl WordApp {
/// A file that wouldn't open (#1103): log it, and say on the status bar which file and why,
/// whatever handed it over (the command line, a macOS open event, a drop). Returns the message.
@@ -17,10 +22,17 @@ impl WordApp {
msg
}
/// Sets a temporary status message in the bottom status bar.
pub fn status(&mut self, s: impl Into<String>) {
self.status_msg = Some((s.into(), now_ms()));
}
/// A status message that stays [`STATUS_LONG_MS`] instead of the usual six seconds: the bar
/// shows a message until six seconds after its time, so its time is set that much later.
pub fn status_long(&mut self, s: impl Into<String>) {
self.status_msg = Some((s.into(), now_ms() + STATUS_LONG_MS - STATUS_MS));
}
/// Write the current preferences out through the host's hook, if it has one (#2716). The
/// desktop app saves them at exit; a change that should survive a crash, like taking a
/// document off the recent list, needs a save at the moment of the change. `None`: the host
@@ -99,7 +111,11 @@ impl WordApp {
}
let fill = if screen == chrome::Screen::Focus { chrome::FOCUS_CANVAS } else { t.canvas };
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| {
canvas::show(self, ui);
if self.session.md_source.is_some() {
crate::md_source::show(self, ui);
} else {
canvas::show(self, ui);
}
});
if self.focus_ribbon && !moved(self) {
chrome::focus_ribbon(self, ctx);
+106 -4
View File
@@ -824,6 +824,7 @@ struct CanvasFinish<'a> {
vruler: Option<Rect>,
scroll_base: f32,
rescaled: bool,
scroll_style: egui::style::ScrollStyle,
}
/// What a window's own scrolling is measured against for Synchronous Scrolling (#322).
@@ -833,8 +834,11 @@ struct ScrollSync {
base: f32,
/// The zoom changed: the offset moved, nobody scrolled.
rescaled: bool,
/// The scroll style applied to the canvas scroll area (#5853).
style: egui::style::ScrollStyle,
}
/// Update scroll offset, scrollbar tip, and current viewed page state for the canvas frame.
fn update_canvas_scroll_state(
app: &mut WordApp,
ui: &mut Ui,
@@ -858,12 +862,13 @@ fn update_canvas_scroll_state(
viewed_page(extent, &geo.rects, &geo.bands, app.canvas.scroll_offset.y, out.inner_rect.height()).min(layout.pages.len().saturating_sub(1))
});
crate::ink::replay_bar(app, ui, out.inner_rect);
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
scrollbar_track_paging(app, ui, out, sync.style);
// This window's own scrolling (wheel, scroll bar, keys, caret), for a partner window.
let own = app.canvas.scroll_offset.y - sync.base;
if app.session.windows.sync_scroll && !sync.rescaled && own.abs() > 0.01 && geo.scale > 0.0 {
app.canvas.sync_out += own / geo.scale;
}
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
if app.session.view.read_mode {
let step = app.session.view.read_width * geo.scale + GAP;
if step.is_finite() && step > 0.0 {
@@ -876,6 +881,82 @@ fn update_canvas_scroll_state(
app.canvas.viewport = Some(out.inner_rect);
}
/// Advance the vertical scroll by one screen when clicking in the track above or below the elevator (#5853).
///
/// In Word, clicking in the vertical scrollbar track above the elevator moves one screen up,
/// and clicking below it moves one screen down. Sensed here over the track regions outside the
/// elevator so egui does not jump the scroll offset to the clicked position.
///
/// Paging regions track egui's selected vertical scrollbar side (`ui.style().right_to_left`)
/// and allocated scrollbar width (`scroll_style.allocated_width()`), placing the paging bounds
/// on the left in RTL and on the right in LTR across solid, floating, and thin scroll styles.
fn scrollbar_track_paging(
app: &mut WordApp,
ui: &mut Ui,
out: &egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec<Rect>)>,
scroll_style: egui::style::ScrollStyle,
) {
let scroll_id = out.id.with(1usize);
let dragging_bar = ui.ctx().is_being_dragged(scroll_id);
if app.session.view.read_mode || !out.inner_rect.is_finite() || !out.content_size.is_finite() || app.canvas.scroll_max.y <= 0.0 || dragging_bar {
return;
}
let step = out.inner_rect.height();
if !step.is_finite() || step <= 0.0 {
return;
}
let inner_rect = out.inner_rect;
let full_width = scroll_style.bar_width;
let outer_margin = scroll_style.bar_outer_margin;
let allocated_width = scroll_style.allocated_width();
let vertical_bar_on_left = ui.style().right_to_left;
let (bar_left, bar_right) = if vertical_bar_on_left {
let outer_edge = (inner_rect.min.x - allocated_width).max(ui.clip_rect().min.x) + outer_margin;
(outer_edge, outer_edge + full_width)
} else {
let outer_edge = (inner_rect.max.x + allocated_width).min(ui.clip_rect().max.x) - outer_margin;
(outer_edge - full_width, outer_edge)
};
if !bar_left.is_finite() || !bar_right.is_finite() || bar_right <= bar_left {
return;
}
let handle_min_length = ui.spacing().scroll.handle_min_length;
let content_h = out.content_size.y;
let offset_y = app.canvas.scroll_offset.y;
let max_offset_y = app.canvas.scroll_max.y;
let from_content = |content: f32| egui::remap_clamp(content, 0.0..=content_h, inner_rect.min.y..=inner_rect.max.y);
let handle_size = (from_content(offset_y + step) - from_content(offset_y)).max(handle_min_length);
let handle_start = if max_offset_y > 0.0 {
egui::remap_clamp(offset_y, 0.0..=max_offset_y, inner_rect.min.y..=(inner_rect.max.y - handle_size))
} else {
inner_rect.min.y
};
let handle_end = handle_start + handle_size;
let track_above = Rect::from_min_max(pos2(bar_left, inner_rect.min.y), pos2(bar_right, handle_start));
if track_above.height() > 0.0 {
let resp = ui.interact(track_above, out.id.with("track_above"), Sense::click_and_drag());
if resp.clicked() && resp.interact_pointer_pos().is_some_and(|p| track_above.contains(p)) {
let target_y = (offset_y - step).max(0.0);
app.canvas.scroll_request = Some(vec2(app.canvas.scroll_offset.x, target_y));
app.canvas.scroll_offset.y = target_y;
ui.ctx().request_repaint();
}
}
let track_below = Rect::from_min_max(pos2(bar_left, handle_end), pos2(bar_right, inner_rect.max.y));
if track_below.height() > 0.0 {
let resp = ui.interact(track_below, out.id.with("track_below"), Sense::click_and_drag());
if resp.clicked() && resp.interact_pointer_pos().is_some_and(|p| track_below.contains(p)) {
let target_y = (offset_y + step).min(max_offset_y);
app.canvas.scroll_request = Some(vec2(app.canvas.scroll_offset.x, target_y));
app.canvas.scroll_offset.y = target_y;
ui.ctx().request_repaint();
}
}
}
fn update_canvas_focus(app: &mut WordApp, ui: &mut Ui, resp: &egui::Response, ime_geometry: Option<(Rect, Rect)>, ime_interrupt: bool) {
// Space and Enter also set `Response::clicked` on a focused click-sensing widget
// (egui's fake primary click). Re-requesting an already focused widget no longer
@@ -1328,9 +1409,25 @@ fn handle_secondary_selection(app: &mut WordApp, resp: &egui::Response, rects: &
}
}
/// Finish the canvas frame: update scroll state, handle focus, input, accessibility, and context menus.
fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec<Rect>)>) {
let CanvasFinish { app, ui, layout, geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler, scroll_base, rescaled } = finish;
update_canvas_scroll_state(app, ui, layout, geo, caret, &out, ScrollSync { base: scroll_base, rescaled });
let CanvasFinish {
app,
ui,
layout,
geo,
caret,
ime_geometry,
ime_interrupt,
area,
dim_body,
hruler,
vruler,
scroll_base,
rescaled,
scroll_style,
} = finish;
update_canvas_scroll_state(app, ui, layout, geo, caret, &out, ScrollSync { base: scroll_base, rescaled, style: scroll_style });
let (resp, rects) = out.inner;
// A click on the page leaves the selected comment balloon.
dismiss_balloon_on_canvas_click(app, ui, &resp);
@@ -1526,6 +1623,7 @@ fn live_selection_screen_rects(app: &mut WordApp, rects: &[Rect], scale: f32) ->
.collect()
}
/// Render the document canvas, pages, scroll bars, rulers, and interactive controls for the current frame.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
@@ -1539,7 +1637,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
// The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back.
let inherited = std::sync::Arc::clone(ui_area.style());
ui_area.spacing_mut().scroll = canvas_scroll_style();
if ui.spacing().scroll == egui::style::ScrollStyle::floating() {
ui_area.spacing_mut().scroll = canvas_scroll_style();
}
canvas_scroll_thumb(ui_area.visuals_mut(), &t);
let mut ime_geometry: Option<(Rect, Rect)> = None;
let mut ime_interrupt = false;
@@ -1828,6 +1928,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
crate::table_mouse::paint(app, &painter, &t, &layout, &rects, geo.scale);
(resp, rects)
});
let scroll_style = ui_area.spacing().scroll;
finish_canvas_frame(
CanvasFinish {
app,
@@ -1843,6 +1944,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
vruler,
scroll_base,
rescaled,
scroll_style,
},
out,
);
+264
View File
@@ -699,6 +699,7 @@ fn dragging_the_scrollbar_names_the_page_at_the_top() {
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("view.hideWhiteSpace", json!({"value": hide_white_space})).unwrap();
a.run("caret.docStart", json!({})).unwrap();
let (rev, sel) = (a.session.rev(), a.session.sel.clone());
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
@@ -835,6 +836,269 @@ fn the_document_scroll_bars_show_with_the_pointer_away() {
}
}
/// #5853: clicking in the vertical scrollbar track below the elevator moves one screen down,
/// and clicking above the elevator moves one screen up, rather than jumping to that position in the document.
#[test]
fn clicking_vertical_scrollbar_track_advances_one_screen() {
use egui::accesskit::Role;
use egui::{Event, PointerButton};
use egui_kittest::kittest::Queryable;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
for _ in 0..11 {
a.run("text.insert", json!({"text": "Page text"})).unwrap();
a.run("layout.break", json!({"kind": "page"})).unwrap();
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("caret.docStart", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
assert_eq!(h.state_mut().session.layout().pages.len(), 12);
let bar = h
.query_all_by_role(Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| r.height() > r.width())
.max_by(|a, b| a.height().total_cmp(&b.height()))
.expect("the canvas's vertical scrollbar");
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| {
h.event(Event::PointerMoved(at));
h.step();
h.event(button(at, true));
h.step();
h.event(button(at, false));
for _ in 0..3 {
h.step();
}
};
let start_offset = h.state().canvas.scroll_offset.y;
assert_eq!(start_offset, 0.0, "starts at the top");
let viewport_height = h.state().canvas.viewport.expect("viewport").height();
// Click near the bottom of the scrollbar (well below the elevator).
let near_bottom = pos2(bar.center().x, bar.bottom() - 20.0);
click(&mut h, near_bottom);
let after_click_down = h.state().canvas.scroll_offset.y;
assert!((after_click_down - viewport_height).abs() < 2.0, "advances one screen down: expected ~{viewport_height}, got {after_click_down}");
// Now click near the top of the scrollbar (above the elevator).
let near_top = pos2(bar.center().x, bar.top() + 10.0);
click(&mut h, near_top);
let after_click_up = h.state().canvas.scroll_offset.y;
assert!(after_click_up.abs() < 2.0, "advances one screen up back to top: expected ~0.0, got {after_click_up}");
}
/// #5853: in a right-to-left interface, egui places the vertical scrollbar on the left;
/// clicking in the scrollbar track advances one screen up/down along that left edge.
#[test]
fn clicking_vertical_scrollbar_track_advances_one_screen_in_rtl() {
use egui::accesskit::Role;
use egui::{Event, PointerButton};
use egui_kittest::kittest::Queryable;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
for _ in 0..11 {
a.run("text.insert", json!({"text": "Page text"})).unwrap();
a.run("layout.break", json!({"kind": "page"})).unwrap();
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("caret.docStart", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
ui.style_mut().set_right_to_left(true);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
assert_eq!(h.state_mut().session.layout().pages.len(), 12);
let bar = h
.query_all_by_role(Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| r.height() > r.width())
.max_by(|a, b| a.height().total_cmp(&b.height()))
.expect("the canvas's vertical scrollbar");
assert!(bar.center().x < 100.0, "scrollbar is on the left in RTL: {bar:?}");
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| {
h.event(Event::PointerMoved(at));
h.step();
h.event(button(at, true));
h.step();
h.event(button(at, false));
for _ in 0..3 {
h.step();
}
};
let start_offset = h.state().canvas.scroll_offset.y;
assert_eq!(start_offset, 0.0, "starts at the top");
let viewport_height = h.state().canvas.viewport.expect("viewport").height();
let near_bottom = pos2(bar.center().x, bar.bottom() - 20.0);
click(&mut h, near_bottom);
let after_click_down = h.state().canvas.scroll_offset.y;
assert!((after_click_down - viewport_height).abs() < 2.0, "advances one screen down in RTL: expected ~{viewport_height}, got {after_click_down}");
let near_top = pos2(bar.center().x, bar.top() + 10.0);
click(&mut h, near_top);
let after_click_up = h.state().canvas.scroll_offset.y;
assert!(after_click_up.abs() < 2.0, "advances one screen up back to top in RTL: expected ~0.0, got {after_click_up}");
}
/// #5853: clicking within the vertical scrollbar track under `ScrollStyle::thin()` advances one screen.
#[test]
fn clicking_thin_vertical_scrollbar_track_advances_one_screen() {
use egui::accesskit::Role;
use egui::{Event, PointerButton};
use egui_kittest::kittest::Queryable;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
for _ in 0..11 {
a.run("text.insert", json!({"text": "Page text"})).unwrap();
a.run("layout.break", json!({"kind": "page"})).unwrap();
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("caret.docStart", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
ui.spacing_mut().scroll = egui::style::ScrollStyle::thin();
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
assert_eq!(h.state_mut().session.layout().pages.len(), 12);
let bar = h
.query_all_by_role(Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| r.height() > r.width())
.max_by(|a, b| a.height().total_cmp(&b.height()))
.expect("the canvas's vertical scrollbar");
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| {
h.event(Event::PointerMoved(at));
h.step();
h.event(button(at, true));
h.step();
h.event(button(at, false));
for _ in 0..3 {
h.step();
}
};
let start_offset = h.state().canvas.scroll_offset.y;
assert_eq!(start_offset, 0.0, "starts at the top");
let viewport_height = h.state().canvas.viewport.expect("viewport").height();
let near_bottom = pos2(bar.center().x, bar.bottom() - 20.0);
click(&mut h, near_bottom);
let after_click_down = h.state().canvas.scroll_offset.y;
assert!(
(after_click_down - viewport_height).abs() < 2.0,
"advances one screen down with thin scrollbar: expected ~{viewport_height}, got {after_click_down}"
);
let near_top = pos2(bar.center().x, bar.top() + 10.0);
click(&mut h, near_top);
let after_click_up = h.state().canvas.scroll_offset.y;
assert!(after_click_up.abs() < 2.0, "advances one screen up back to top with thin scrollbar: expected ~0.0, got {after_click_up}");
}
/// #5853: clicks and drags on page content just outside the vertical scrollbar track do not trigger
/// paging and can place the caret or start a selection.
#[test]
fn clicking_and_dragging_outside_scrollbar_does_not_page() {
use egui::accesskit::Role;
use egui::{Event, PointerButton};
use egui_kittest::kittest::Queryable;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
for _ in 0..11 {
a.run("text.insert", json!({"text": "A long line of sample text that fills across the page for testing selections."})).unwrap();
a.run("layout.break", json!({"kind": "page"})).unwrap();
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("caret.docStart", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
let bar = h
.query_all_by_role(Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| r.height() > r.width())
.max_by(|a, b| a.height().total_cmp(&b.height()))
.expect("the canvas's vertical scrollbar");
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
// Point just to the left of the scrollbar track (outside egui's scrollbar hit rect).
let just_outside = pos2(bar.min.x - 2.0, bar.bottom() - 20.0);
h.event(Event::PointerMoved(just_outside));
h.step();
h.event(button(just_outside, true));
h.step();
h.event(button(just_outside, false));
for _ in 0..3 {
h.step();
}
assert_eq!(h.state().canvas.scroll_offset.y, 0.0, "clicking outside the scrollbar track does not page");
// Dragging just outside the scrollbar does not page.
let drag_from = pos2(bar.min.x - 3.0, 300.0);
let drag_to = pos2(bar.min.x - 3.0, 350.0);
h.event(Event::PointerMoved(drag_from));
h.step();
h.event(button(drag_from, true));
h.step();
for k in 1..=4 {
h.event(Event::PointerMoved(drag_from + (drag_to - drag_from) * (k as f32 / 4.0)));
h.step();
}
h.event(button(drag_to, false));
for _ in 0..3 {
h.step();
}
assert_eq!(h.state().canvas.scroll_offset.y, 0.0, "dragging outside the scrollbar track does not page");
}
/// #3880: the status bar names the page being looked at. With the caret scrolled out of view, that
/// is the page at the top of the window; while the caret is on screen, its page.
#[test]
+5 -1
View File
@@ -53,6 +53,8 @@ const QAT_MENU: [&str; 10] =
/// Match the toolbar's enabled state for keyboard activation too.
pub(crate) fn qat_enabled(app: &WordApp, id: &str) -> bool {
match id {
// The Markdown Source pane's arrows undo its typing.
"edit.undo" | "edit.redo" if app.session.md_source.is_some() => crate::md_source::can_undo(app, id == "edit.redo"),
"edit.undo" => app.session.can_undo(),
"edit.redo" => app.session.can_redo(),
"file.save" => app.has_document(),
@@ -82,6 +84,8 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) {
}
crate::widgets::command_menu(&resp, app, id);
if id == "edit.undo" {
// The list is the document's steps, not the Markdown Source pane's typing.
let enabled = enabled && app.session.md_source.is_none();
undo_list_button(ui, app, enabled);
}
}
@@ -962,7 +966,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
}
});
if let Some((msg, at)) = &app.status_msg
&& crate::now_ms() - at < 6000.0
&& crate::now_ms() - at < crate::app::STATUS_MS
{
let font = TypeRung::Caption.regular();
let track = TypeRung::Caption.tracking();
+3
View File
@@ -869,6 +869,9 @@ impl Dialog {
kept[k] = json!(t);
}
}
if let Some(own) = payload.get("privateClipboard").and_then(Value::as_bool) {
kept["privateClipboard"] = json!(own);
}
let note = if formats.is_empty() { crate::system_clipboard::nothing_to_paste(app) } else { String::new() };
Dialog::PasteSpecial { formats, choice: 0, payload: kept, note }
}
+13
View File
@@ -3,6 +3,19 @@ use wordcraft_doc::Pos;
use wordcraft_doc::props::Align;
use wordcraft_engine::Session;
/// Paste Special lists and pastes the same formats the engine chose: the clipboard ownership
/// result must survive into the dialog's payload, or another program's copy could be replaced
/// by WordCraft's own fragment.
#[test]
fn paste_special_dialog_keeps_the_clipboard_ownership_result() {
let a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default());
let d = Dialog::paste_special(&a, &json!({"text": "Bold", "privateClipboard": false}));
let Dialog::PasteSpecial { formats, payload, .. } = d else { panic!("not Paste Special") };
assert_eq!(formats, ["text"], "another program's copy isn't our fragment");
assert_eq!(payload.get("privateClipboard"), Some(&json!(false)));
assert_eq!(payload.get("text").and_then(Value::as_str), Some("Bold"));
}
/// #3931: hovering Insert › Table's grid asks the page for a preview of that table (rows by
/// columns) and changes nothing; leaving the grid drops it; a click inserts the table with no
/// preview left over for the page to show on top of it.
+3
View File
@@ -174,6 +174,9 @@ impl WordApp {
/// Treat the system dialog the host is showing as cancelled, e.g. one that never showed up.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn give_up_file_dialog(&mut self) {
if let Some(cancel) = &self.services.cancel_file_dialog {
cancel();
}
if let Some(d) = self.file_dialog.take() {
let _ = self.finish_pick(d.after, None);
}
+21
View File
@@ -252,6 +252,27 @@ fn answer_all(shown: &Shown, picked: &[&str]) {
tx.send(Ok(picked.iter().map(|p| p.to_string()).collect())).unwrap();
}
/// Giving the system dialog up (File › Open › Browse in WordCraft…) tells the host to cancel its
/// waiting request, so a portal that stopped answering cannot keep Save As stuck.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn giving_up_a_file_dialog_cancels_the_host_request() {
let cancelled = Rc::new(std::cell::Cell::new(false));
let flag = cancelled.clone();
let services = Services { cancel_file_dialog: Some(Box::new(move || flag.set(true))), ..Default::default() };
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
let (tx, rx) = channel();
a.file_dialog = Some(PendingDialog {
rx,
req: FileDialogRequest::Save { name: "Document1.docx".into(), dir: None },
after: AfterPick::Export { ext: "pdf".into() },
});
a.give_up_file_dialog();
assert!(cancelled.get(), "the host is told to stop waiting");
assert!(a.file_dialog.is_none(), "the pending dialog is taken as cancelled");
drop(tx);
}
fn typed(a: &mut WordApp) {
a.run("text.insert", json!({"text": UNSAVED})).unwrap();
assert!(a.session.dirty);
+9
View File
@@ -1034,6 +1034,15 @@ fn paint_view_and_tables(pen: &Pen, name: &str) -> bool {
pen.line(&[(12.5, 15.5), (17.0, 15.5)]);
}
"draft" => pen.lines(3.0, 17.0, &[4.0, 7.0, 10.0, 13.0, 16.0]),
// A heading mark, then lines of text.
"markdownSource" => {
pen.line_c(&[(4.0, 3.0), (4.0, 9.0)], a);
pen.line_c(&[(7.5, 3.0), (7.5, 9.0)], a);
pen.line_c(&[(2.5, 5.0), (9.0, 5.0)], a);
pen.line_c(&[(2.5, 7.0), (9.0, 7.0)], a);
pen.lines(3.0, 17.0, &[12.0, 15.0]);
pen.lines(11.0, 17.0, &[4.0, 7.0]);
}
"focus" => {
pen.line(&[(3.0, 7.0), (3.0, 3.0), (7.0, 3.0)]);
pen.line(&[(13.0, 3.0), (17.0, 3.0), (17.0, 7.0)]);
+1
View File
@@ -374,6 +374,7 @@ fn controls(tab: &str) -> Vec<Control> {
c("Read Mode", "view.readMode"),
c("Print Layout", "view.printLayout"),
c("Web Layout", "view.webLayout"),
c("Markdown Source", "view.markdownSource"),
c("Outline", "view.outline"),
c("Draft", "view.draft"),
c("Focus", "view.focus"),
+3
View File
@@ -54,6 +54,7 @@ pub mod keys;
pub mod keytips;
pub(crate) mod language_packs;
mod local_time;
pub mod md_source;
pub mod mini_toolbar;
pub mod objects;
pub mod options;
@@ -624,6 +625,8 @@ pub struct WordApp {
pub session: Session,
pub ui: UiState,
pub services: Services,
/// Only a successfully published copy may reuse this window's private fragment.
pub clipboard_published: bool,
pub printing: printing::State,
print_feedback_seen: Option<printing::Feedback>,
pub canvas: canvas::CanvasState,
+386
View File
@@ -0,0 +1,386 @@
//! View › Markdown Source: the text pane that takes the page's place. The text itself and what it
//! means for the document are the engine's (`wordcraft_engine::cmd::md_source`).
use egui::text::{CCursor, CCursorRange};
use egui::{FontId, Key, KeyboardShortcut, Modifiers, Ui};
use serde_json::json;
use crate::WordApp;
use crate::clock::now_ms;
use crate::theme::Tokens;
/// How often a text with edits reaches the document while it is being typed, in milliseconds, so
/// crash recovery has them.
const APPLY_EVERY_MS: f64 = 5000.0;
/// Draw the pane in the place of the page.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// The page isn't drawn, so it can't have the keyboard.
app.canvas.focused = false;
app.canvas.want_focus = false;
// A focused text box turns the global shortcuts off.
if ui.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::S))) {
let _ = app.run("file.save", json!({}));
}
let now = now_ms();
if app.session.md_source.as_ref().is_some_and(|src| src.is_pending() && now - src.applied_at_ms > APPLY_EVERY_MS) {
wordcraft_engine::cmd::md_source::apply(&mut app.session);
if let Some(src) = app.session.md_source.as_mut() {
src.applied_at_ms = now;
}
}
let doc_id = app.session.document_id();
let id = box_id(doc_id);
let mut changed = false;
let mut refused = false;
{
let Some(src) = app.session.md_source.as_mut() else { return };
if src.reset_undo {
src.reset_undo = false;
reset_undo_history(ui.ctx(), doc_id);
}
if src.loses_content {
egui::Frame::NONE.fill(t.hover).inner_margin(egui::Margin::symmetric(12, 6)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(tl!(
"This document has content Markdown can't hold. Changing the text here removes it; one Undo afterwards brings it back."
));
});
}
let has_input = ui.input(|i| i.events.iter().any(|e| !matches!(e, egui::Event::PointerMoved(_))));
let prev_text = if has_input { Some(src.text.clone()) } else { None };
let prev_caret = src.caret;
egui::ScrollArea::vertical().id_salt(id).auto_shrink(false).show(ui, |ui| {
let size = ui.available_size();
let mut out = egui::TextEdit::multiline(&mut src.text)
.id(id)
.hint_text(tl!("Markdown Source"))
.font(FontId::monospace(14.0))
.desired_width(f32::INFINITY)
.min_size(size)
.frame(egui::Frame::NONE.inner_margin(16))
.text_color(t.text)
.char_limit(wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES)
.lock_focus(true)
.show(ui);
changed = out.response.changed();
if changed && src.text.len() > wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES {
if let Some(prev) = prev_text {
src.text = prev;
src.caret = prev_caret;
} else {
let limit = src.text.floor_char_boundary(wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES);
src.text.truncate(limit);
src.caret = src.caret.min(limit);
}
changed = false;
refused = true;
src.place_caret = true;
}
let caret_changed = if src.place_caret {
src.place_caret = false;
let char_idx = src.text.get(..src.caret).map_or(0, |s| s.chars().count());
let caret = CCursor::new(char_idx);
out.state.cursor.set_char_range(Some(CCursorRange::one(caret)));
out.state.store(ui.ctx(), id);
out.response.request_focus();
let spot = out.galley.pos_from_cursor(caret).translate(out.galley_pos.to_vec2());
ui.scroll_to_rect(spot, Some(egui::Align::Center));
true
} else if let Some(range) = out.cursor_range {
let old_caret = src.caret;
let char_idx: usize = range.primary.index.into();
src.caret = src.text.char_indices().nth(char_idx).map_or(src.text.len(), |(b, _)| b);
old_caret != src.caret
} else {
false
};
if changed || caret_changed {
invalidate_undo_cache(ui.ctx(), doc_id);
}
});
}
if refused {
app.status(tl!("Markdown Source takes up to 2 MB of text"));
}
if changed {
app.session.dirty = true;
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(APPLY_EVERY_MS / 1000.0));
}
}
/// The text box of a document's pane, found the same way by Undo and Redo as by the pane.
fn box_id(document: u64) -> egui::Id {
egui::Id::unique("md_source").with(document)
}
/// The context storage key for caching the pane's undo/redo availability.
fn undo_cache_id(document: u64) -> egui::Id {
egui::Id::unique("md_source_undo_cache").with(document)
}
/// Cached undo and redo capabilities for a document's Markdown Source text box.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct CachedUndoState {
can_undo: bool,
can_redo: bool,
}
/// Invalidates the cached undo and redo capability for `document`.
pub(crate) fn invalidate_undo_cache(ctx: &egui::Context, document: u64) {
ctx.data_mut(|d| d.remove::<CachedUndoState>(undo_cache_id(document)));
}
/// Clears the stored typing history for `document`'s Markdown Source text box and invalidates the cache.
pub(crate) fn reset_undo_history(ctx: &egui::Context, document: u64) {
let id = box_id(document);
if let Some(mut state) = egui::text_edit::TextEditState::load(ctx, id) {
state.clear_undoer();
state.store(ctx, id);
}
invalidate_undo_cache(ctx, document);
}
/// A text box's state for its typing history: the selection and the text.
type Typed = (CCursorRange, String);
/// The text box's typing history, with the state Undo and Redo start from.
fn history(app: &WordApp) -> Option<(egui::Context, egui::text_edit::TextEditState, egui::util::undoer::Undoer<Typed>, Typed)> {
let ctx = app.ctx.clone()?;
let src = app.session.md_source.as_ref()?;
let mut state = egui::text_edit::TextEditState::load(&ctx, box_id(app.session.document_id()))?;
if src.reset_undo {
state.clear_undoer();
}
let undoer = state.undoer();
let caret_chars = src.text[..src.text.floor_char_boundary(src.caret)].chars().count();
let range = state.cursor.char_range().unwrap_or(CCursorRange::one(CCursor::new(caret_chars)));
Some((ctx, state, undoer, (range, src.text.clone())))
}
/// Whether the pane's Undo (or Redo, with `redo`) has a step to take.
pub fn can_undo(app: &WordApp, redo: bool) -> bool {
let Some(ctx) = app.ctx.as_ref() else { return false };
let doc_id = app.session.document_id();
if app.session.md_source.as_ref().is_some_and(|s| s.reset_undo) {
reset_undo_history(ctx, doc_id);
}
let cache_key = undo_cache_id(doc_id);
if let Some(cached) = ctx.data(|d| d.get_temp::<CachedUndoState>(cache_key)) {
return if redo { cached.can_redo } else { cached.can_undo };
}
let Some((ctx, _, undoer, now)) = history(app) else { return false };
let can_u = undoer.has_undo(&now);
let can_r = undoer.has_redo(&now);
ctx.data_mut(|d| d.insert_temp(cache_key, CachedUndoState { can_undo: can_u, can_redo: can_r }));
if redo { can_r } else { can_u }
}
/// Undo (or Redo, with `redo`) typing in the pane, as Ctrl+Z (Ctrl+Y) does in it: the toolbar's
/// arrows and the Undo and Redo commands work on the text while it is open, not on the document.
pub fn undo(app: &mut WordApp, redo: bool) -> Result<serde_json::Value, String> {
let Some((ctx, mut state, mut undoer, now)) = history(app) else { return Err(tl!("Nothing to undo").into()) };
let step = if redo { undoer.redo(&now) } else { undoer.undo(&now) }.cloned();
let Some((range, text)) = step else {
return Err(if redo { tl!("Nothing to redo") } else { tl!("Nothing to undo") }.into());
};
state.cursor.set_char_range(Some(range));
state.set_undoer(undoer);
state.store(&ctx, box_id(app.session.document_id()));
if let Some(src) = app.session.md_source.as_mut() {
src.text = text;
let char_idx: usize = range.primary.index.into();
src.caret = src.text.char_indices().nth(char_idx).map_or(src.text.len(), |(b, _)| b);
}
invalidate_undo_cache(&ctx, app.session.document_id());
app.session.dirty = true;
ctx.request_repaint();
Ok(json!({"text": true}))
}
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::kittest::Queryable;
/// Builds a test harness for Markdown Source UI integration tests.
fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> {
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
)
}
/// A saved Markdown file, with its folder for the test to remove.
fn opened(name: &str) -> (WordApp, std::path::PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"wordcraft-ui-md-source-{name}-{}-{}-{}",
std::process::id(),
id,
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("note.md");
std::fs::write(&path, "# Heading\n\nSome text.\n").unwrap();
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
(a, dir)
}
/// Verifies Markdown Source pane displays text and captures keyboard focus.
#[test]
fn the_pane_shows_the_text_and_takes_the_keyboard() {
let (mut a, dir) = opened("keys");
a.run("view.markdownSource", json!({})).unwrap();
let mut h = harness(a);
h.run();
assert!(!h.state().canvas.focused, "the page isn't drawn");
assert!(h.ctx.text_edit_focused(), "the text box has the keyboard");
assert!(h.get_all_by_value("# Heading\n\nSome text.\n").next().is_some(), "the Markdown is shown");
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies typing in source view marks document dirty and Ctrl+S saves it.
#[test]
fn typing_is_an_unsaved_change_and_ctrl_s_saves_it() {
let (mut a, dir) = opened("save");
a.run("view.markdownSource", json!({})).unwrap();
let mut h = harness(a);
h.run();
h.state_mut().session.md_source.as_mut().unwrap().text.insert_str(0, "Typed. ");
h.state_mut().session.dirty = true;
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
h.run();
let saved = std::fs::read_to_string(dir.join("note.md")).unwrap();
assert!(saved.contains("Typed."), "{saved}");
assert!(!h.state().session.dirty);
assert!(h.state().session.md_source.is_some(), "saving keeps the pane");
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies QAT undo and redo arrows work for typing in the source pane.
#[test]
fn the_arrows_undo_and_redo_typing_in_the_pane() {
let (mut a, dir) = opened("arrows");
a.run("view.markdownSource", json!({})).unwrap();
let mut h = harness(a);
h.run();
assert!(!crate::chrome::qat_enabled(h.state(), "edit.undo"), "nothing typed yet");
h.event(egui::Event::Text("Typed. ".into()));
h.run();
let typed = h.state().session.md_source.as_ref().unwrap().text.clone();
assert!(typed.contains("Typed. "), "{typed}");
assert!(crate::chrome::qat_enabled(h.state(), "edit.undo"));
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run();
assert_eq!(h.state().session.md_source.as_ref().unwrap().text, "# Heading\n\nSome text.\n", "Undo takes the typing back");
assert!(h.state().session.md_source.is_some(), "and stays in the pane");
assert!(crate::chrome::qat_enabled(h.state(), "edit.redo"));
h.state_mut().run("edit.redo", json!({})).unwrap();
h.run();
assert_eq!(h.state().session.md_source.as_ref().unwrap().text, typed, "Redo puts it back");
assert!(h.state_mut().run("edit.redo", json!({})).is_err(), "nothing more to redo");
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies rewrite warning stays displayed for twenty seconds on the status bar.
#[test]
fn the_rewrite_warning_stays_longer_than_other_messages() {
let (mut a, dir) = opened("warning");
a.run("text.insert", json!({"text": "Formatted edit. "})).unwrap();
a.run("file.save", json!({})).unwrap();
let (msg, at) = a.status_msg.clone().unwrap();
assert!(msg.contains("writing the Markdown out again"), "{msg}");
let left = at + crate::app::STATUS_MS - now_ms();
assert!(left > 15_000.0, "shown for about 20 seconds, {left} ms left");
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies formatting ribbon controls are greyed out while Markdown Source pane is active.
#[test]
fn the_ribbon_greys_out_edits_while_the_pane_is_open() {
let (mut a, dir) = opened("grey");
assert_eq!(a.session.why_disabled("format.bold"), None);
a.run("view.markdownSource", json!({})).unwrap();
assert_eq!(a.session.why_disabled("format.bold"), Some("leave Markdown Source first"));
assert_eq!(a.session.why_disabled("view.markdownSource"), None);
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies that typing or pasting beyond `MAX_SOURCE_BYTES` is refused, keeping the previous text and warning.
#[test]
fn text_exceeding_max_source_bytes_is_refused_and_warns() {
let (mut a, dir) = opened("oversize");
a.run("view.markdownSource", json!({})).unwrap();
let limit = wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES;
let half = limit / 2;
let original = format!("START {} END", "é".repeat(half - 10));
let original_len = original.len();
a.session.md_source.as_mut().unwrap().text = original.clone();
let mut h = harness(a);
h.run();
h.event(egui::Event::Text("é".repeat(100)));
h.run();
let current_text = h.state().session.md_source.as_ref().unwrap().text.clone();
assert_eq!(current_text, original, "over-limit text must be refused without truncating document end");
assert_eq!(current_text.len(), original_len);
let status = h.state().status_msg.as_ref().map(|(s, _)| s.as_str());
assert_eq!(status, Some("Markdown Source takes up to 2 MB of text"));
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies that pointer movement alone does not take a snapshot or alter Markdown Source state.
#[test]
fn pointer_movement_does_not_mutate_or_affect_markdown_source() {
let (mut a, dir) = opened("pointer");
a.run("view.markdownSource", json!({})).unwrap();
let mut h = harness(a);
h.run();
let before_text = h.state().session.md_source.as_ref().unwrap().text.clone();
h.event(egui::Event::PointerMoved(egui::pos2(100.0, 200.0)));
h.run();
assert_eq!(h.state().session.md_source.as_ref().unwrap().text, before_text);
assert!(!h.state().session.dirty);
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies that reopening Markdown Source after formatted edits resets undo history
/// so that previous visit edits cannot be restored by Undo.
#[test]
fn reopening_pane_resets_undo_history_from_previous_visit() {
let (mut a, dir) = opened("reopen_undo");
a.run("view.markdownSource", json!({})).unwrap();
let mut h = harness(a);
h.run();
h.event(egui::Event::Text("First visit. ".into()));
h.run();
assert!(crate::chrome::qat_enabled(h.state(), "edit.undo"), "undo enabled after typing");
// Leave pane back to formatted view
h.state_mut().run("view.markdownSource", json!({})).unwrap();
h.run();
assert!(h.state().session.md_source.is_none(), "left source view");
// Make formatted edits in document
h.state_mut().run("text.insert", json!({"text": "Formatted edit. "})).unwrap();
h.run();
// Reopen pane
h.state_mut().run("view.markdownSource", json!({})).unwrap();
h.run();
// Undo must NOT be enabled for the Markdown pane, and edit.undo must return error
assert!(!crate::chrome::qat_enabled(h.state(), "edit.undo"), "undo must be reset on reopening pane");
assert!(h.state_mut().run("edit.undo", json!({})).is_err(), "undo command must fail when nothing typed in current visit");
std::fs::remove_dir_all(&dir).unwrap();
}
}
+3
View File
@@ -5,6 +5,7 @@ use super::{Ui, WordApp, big, big_toggle, group, json, menu_button, small, vec2}
/// and Templates groups.
pub(super) fn view(app: &mut WordApp, ui: &mut Ui) {
let v = app.session.view.clone();
let v_source = app.session.md_source.is_some();
group(ui, "Views", None, app, |ui, app| {
big(ui, app, "readMode", "Read\nMode", "view.readMode", json!({}), false);
big(ui, app, "printLayout", "Print\nLayout", "view.printLayout", json!({}), false);
@@ -12,6 +13,8 @@ pub(super) fn view(app: &mut WordApp, ui: &mut Ui) {
stack(ui, |ui| {
small(ui, app, "outline", Some("Outline"), "Outline", crate::widgets::ButtonAction::new("view.outline", json!({}), false));
small(ui, app, "draft", Some("Draft"), "Draft", crate::widgets::ButtonAction::new("view.draft", json!({}), false));
let source = crate::widgets::ButtonAction::new("view.markdownSource", json!({}), v_source);
small(ui, app, "markdownSource", Some("Markdown"), "Markdown Source", source);
});
});
group(ui, "Immersive", None, app, |ui, app| {
+9 -1
View File
@@ -1,5 +1,5 @@
use super::state::DefaultApp;
use super::{FileDialogAnswer, FileDialogRequest, WordApp, async_saves, paste_picture, printing};
use super::{FileDialogAnswer, FileDialogRequest, WordApp, async_saves, paste_picture, printing, system_clipboard};
/// Platform services injected by the host (file dialogs, file I/O).
#[derive(Default)]
@@ -21,6 +21,10 @@ pub struct Services {
/// sender counts as cancelled. An `Err` says no system dialog could be shown, and the app shows
/// its built-in file browser instead (#576). The app shows one dialog at a time ([`file_dialogs`]).
pub file_dialog: Option<Box<dyn Fn(FileDialogRequest) -> std::sync::mpsc::Receiver<FileDialogAnswer>>>,
/// Desktop: stop waiting for the system file dialog the host is showing, if it can (a portal
/// that never answers). The app calls it when it gives the dialog up (File › Open › Browse in
/// WordCraft…), so the host closes the request and answers as cancelled.
pub cancel_file_dialog: Option<Box<dyn Fn()>>,
/// Web: open a file picker; the file arrives later through `inbox`.
pub open_async: Option<Box<dyn Fn(&str)>>,
/// Web: files (name, bytes) delivered asynchronously (picker, drag and drop).
@@ -73,6 +77,10 @@ pub struct Services {
/// 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: publish one picture's pixels, HTML and text together without clearing formats.
pub set_clipboard_image: Option<Box<dyn Fn(&system_clipboard::CopyImage, &str, &str) -> bool>>,
/// Whether the clipboard still contains this process's last copy.
pub clipboard_is_own: Option<Box<dyn Fn() -> 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
+228 -8
View File
@@ -11,6 +11,17 @@ use wordcraft_engine::cmd::paste::{MAX_PASTE_BYTES, MAX_PICTURE_BYTES};
use crate::{WordApp, paste_picture};
/// Straight RGBA8 pixels for a native image copy. Decode limits are applied before allocation.
#[derive(Clone, Debug)]
pub struct CopyImage {
/// Width in pixels.
pub width: usize,
/// Height in pixels.
pub height: usize,
/// Raw uncompressed RGBA pixel bytes (4 bytes per pixel).
pub rgba: Vec<u8>,
}
/// The commands that paste the clipboard.
pub(crate) fn is_paste(id: &str) -> bool {
matches!(id, "edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial")
@@ -30,6 +41,11 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option<Re
return None;
}
let o = params.as_object_mut()?;
// A caller that captured ownership already (Paste Special's dialog) keeps that value.
if app.services.clipboard_is_own.is_some() && !o.contains_key("privateClipboard") {
let own = app.clipboard_published && app.services.clipboard_is_own.as_ref().is_some_and(|f| f());
o.insert("privateClipboard".into(), Value::Bool(own));
}
if !o.contains_key("text")
&& let Some(text) = app.services.clipboard_text.as_ref().and_then(|f| f()).filter(|t| !t.is_empty())
{
@@ -56,6 +72,11 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option<Re
if let Some(rtf) = app.services.clipboard_rtf.as_ref().and_then(|f| f()).filter(fits) {
o.insert("rtf".into(), Value::String(rtf));
}
// Native bitmap/image formats are for other programs. Our own paste keeps the original
// fragment (size, cropping and floating-picture properties), while its owner mark lasts.
if app.session.clipboard.is_some() && o.get("privateClipboard").and_then(Value::as_bool) == Some(true) {
o.insert("text".into(), Value::String(app.session.clipboard_text.clone()));
}
if id == "edit.pasteSpecial"
&& let Some(png) = paste_picture::clipboard_png(app)
{
@@ -71,25 +92,115 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option<Re
/// 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) {
pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
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.
if !html.is_empty() {
if !(html.is_empty() && rtf.is_empty()) && app.services.set_clipboard_rich.as_ref().is_some_and(|f| f(text, html, rtf, mathml)) {
return true;
}
ctx.copy_text(String::new());
}
return;
return false;
}
if !(html.is_empty() && rtf.is_empty()) && app.services.set_clipboard_rich.as_ref().is_some_and(|f| f(text, html, rtf, mathml)) {
return;
return true;
}
ctx.copy_text(text.to_string());
false
}
/// Return whether the command result `r` contains any copied payload (text, HTML, RTF, or MathML).
fn has_copied_content(r: &Value) -> bool {
let s = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("");
!s("text").is_empty() || !s("html").is_empty() || !s("rtf").is_empty() || !s("mathml").is_empty()
}
/// Put what Copy or Cut returned on the system clipboard (see [`put`]).
pub(crate) fn put_result(app: &WordApp, ctx: &egui::Context, r: &Value) {
///
/// When `r` contains no copied content (e.g. Copy on a collapsed selection),
/// this does nothing: it avoids overwriting external clipboard contents with
/// previously-copied fragments or modifying the published ownership state.
pub(crate) fn put_result(app: &mut WordApp, ctx: &egui::Context, r: &Value) {
if !has_copied_content(r) {
return;
}
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"));
app.clipboard_published = false;
if app.services.set_clipboard_image.is_some()
&& let Some(res) = copied_image(app)
{
match res {
Ok(image) => {
let set = app.services.set_clipboard_image.as_ref().map(|f| f(&image, s("html"), s("text"))).unwrap_or(false);
if set {
app.clipboard_published = true;
return;
}
}
Err(e) => {
app.status(format!("Couldn't copy the picture: {e}"));
}
}
}
app.clipboard_published = put(app, ctx, s("text"), s("html"), s("rtf"), s("mathml"));
}
/// Extract the single copied image from the clipboard fragment, if that is all that was copied.
fn copied_image(app: &WordApp) -> Option<Result<CopyImage, String>> {
use wordcraft_doc::{Block, para::InlineObject};
let fragment = app.session.clipboard.as_ref()?;
let [Block::Para(para)] = fragment.blocks.as_slice() else { return None };
let [InlineObject::Image { media, .. }] = para.objects.as_slice() else { return None };
if para.text != wordcraft_doc::para::OBJ.to_string() {
return None;
}
let bytes = fragment.media.get(media).or_else(|| app.session.doc.media.get(media))?;
Some(match wordcraft_render::metafile_size(bytes) {
Some((w, h, _)) => {
let rect = wordcraft_geom::Rect::new(0.0, 0.0, w, h);
let items = [wordcraft_layout::display::Draw::Image {
rect,
media: media.clone(),
crop: [0.0; 4],
alpha: 1.0,
alt: String::new(),
fx: Default::default(),
decorative: false,
}];
let doc = fragment.to_document();
let image = wordcraft_render::render_items(&doc, &items, rect, 96.0 / 72.0, &Default::default());
Ok(CopyImage { width: image.width as usize, height: image.height as usize, rgba: image.to_straight() })
}
None => decode_image(bytes),
})
}
/// Decode raster image bytes into unpremultiplied RGBA8 pixels under bounded size limits.
fn decode_image(bytes: &[u8]) -> Result<CopyImage, String> {
if bytes.len() as u64 > paste_picture::MAX_FILE_BYTES {
return Err("the picture file is larger than 200 MB".into());
}
let reader = || image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| e.to_string());
let (w, h) = reader()?.into_dimensions().map_err(|e| e.to_string())?;
let pixels = (w as usize).checked_mul(h as usize);
if w == 0
|| h == 0
|| w as usize > paste_picture::MAX_SIDE
|| h as usize > paste_picture::MAX_SIDE
|| pixels.is_none_or(|n| n > paste_picture::MAX_PIXELS)
{
return Err(format!("the picture is too large to copy ({w} × {h} pixels)"));
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(paste_picture::MAX_SIDE as u32);
limits.max_image_height = Some(paste_picture::MAX_SIDE as u32);
limits.max_alloc = Some((paste_picture::MAX_PIXELS as u64).saturating_mul(4));
let mut reader = reader()?;
reader.limits(limits);
let rgba = reader.decode().map_err(|e| e.to_string())?.into_rgba8();
Ok(CopyImage { width: w as usize, height: h as usize, rgba: rgba.into_raw() })
}
/// Format names worth showing: not the clipboard protocol's own bookkeeping targets.
@@ -265,7 +376,7 @@ mod tests {
a.run("select.all", json!({})).unwrap();
let ctx = egui::Context::default();
let r = a.run("edit.copy", json!({})).unwrap();
put_result(&a, &ctx, &r);
put_result(&mut a, &ctx, &r);
let got_now = got.borrow().clone();
assert_eq!(got_now.len(), 1);
assert!(got_now[0].0.contains("<math") && got_now[0].1.contains("y=2x_s"), "{got_now:?}");
@@ -275,7 +386,7 @@ mod tests {
// and RTF for the programs that read that (gap-clipboard-rtf).
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);
put_result(&mut a, &ctx, &r);
let got_now = got.borrow().clone();
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:?}");
@@ -284,9 +395,118 @@ mod tests {
// 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();
put_result(&a, &ctx, &r);
put_result(&mut a, &ctx, &r);
let got_now = got.borrow().clone();
assert_eq!(got_now.len(), 3);
assert!(got_now[2].2.starts_with("<math") && got_now[2].2.contains("<msub>") && got_now[2].1 == "y=2x_s", "{got_now:?}");
}
#[test]
fn copying_an_image_publishes_image_and_html_to_system_clipboard() {
let got_image = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(usize, usize, String, String)>::new()));
let sink = got_image.clone();
let services = Services {
set_clipboard_image: Some(Box::new(move |img, html, text| {
sink.borrow_mut().push((img.width, img.height, html.to_string(), text.to_string()));
true
})),
..Default::default()
};
let mut a = app(services);
let png = paste_picture::png_from_rgba(2, 2, vec![255; 16]).unwrap();
let data = wordcraft_engine::cmd::insert::base64_encode(&png);
a.run("insert.picture", json!({"data": data})).unwrap();
a.run("select.all", json!({})).unwrap();
let ctx = egui::Context::default();
let r = a.run("edit.copy", json!({})).unwrap();
put_result(&mut a, &ctx, &r);
assert!(a.clipboard_published);
let got = got_image.borrow().clone();
assert_eq!(got.len(), 1);
assert_eq!(got[0].0, 2);
assert_eq!(got[0].1, 2);
assert!(got[0].2.contains("<img src=\"data:image/png;base64,"));
}
#[test]
fn pasting_own_image_uses_private_clipboard_fragment() {
let services =
Services { set_clipboard_image: Some(Box::new(|_, _, _| true)), clipboard_is_own: Some(Box::new(|| true)), ..Default::default() };
let mut a = app(services);
let png = paste_picture::png_from_rgba(2, 2, vec![255; 16]).unwrap();
let data = wordcraft_engine::cmd::insert::base64_encode(&png);
a.run("insert.picture", json!({"data": data})).unwrap();
a.run("select.all", json!({})).unwrap();
let ctx = egui::Context::default();
let r = a.run("edit.copy", json!({})).unwrap();
put_result(&mut a, &ctx, &r);
assert!(a.clipboard_published);
a.run("document.setText", json!({"text": ""})).unwrap();
assert_eq!(a.session.doc.body.len(), 1);
assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).is_empty());
let mut params = json!({});
fill(&mut a, "edit.paste", &mut params);
assert_eq!(params.get("privateClipboard"), Some(&Value::Bool(true)));
a.run("edit.paste", params).unwrap();
assert!(!a.session.doc.media.is_empty());
}
/// Paste Special captures ownership when it opens; `fill` must keep that captured value
/// when the dialog submits, even if the clipboard changed meanwhile. Callers that pass no
/// flag still get it read from the clipboard ownership service.
#[test]
fn fill_keeps_a_callers_private_clipboard_flag() {
let services = Services { clipboard_is_own: Some(Box::new(|| true)), ..Default::default() };
let mut a = app(services);
a.clipboard_published = true;
let mut params = json!({"privateClipboard": false, "text": "Bold"});
fill(&mut a, "edit.pasteSpecial", &mut params);
assert_eq!(params.get("privateClipboard"), Some(&Value::Bool(false)), "the captured flag wins");
let mut params = json!({});
fill(&mut a, "edit.paste", &mut params);
assert_eq!(params.get("privateClipboard"), Some(&Value::Bool(true)), "without a flag fill reads ownership");
}
#[test]
fn empty_copy_does_not_publish_or_overwrite_clipboard_state() {
let got_image = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(usize, usize, String, String)>::new()));
let sink = got_image.clone();
let services = Services {
set_clipboard_image: Some(Box::new(move |img, html, text| {
sink.borrow_mut().push((img.width, img.height, html.to_string(), text.to_string()));
true
})),
..Default::default()
};
let mut a = app(services);
let png = paste_picture::png_from_rgba(2, 2, vec![255; 16]).unwrap();
let data = wordcraft_engine::cmd::insert::base64_encode(&png);
a.run("insert.picture", json!({"data": data})).unwrap();
a.run("select.all", json!({})).unwrap();
let ctx = egui::Context::default();
let r = a.run("edit.copy", json!({})).unwrap();
put_result(&mut a, &ctx, &r);
assert!(a.clipboard_published);
assert_eq!(got_image.borrow().len(), 1);
a.run("caret.docEnd", json!({})).unwrap();
assert!(a.session.sel.is_collapsed());
let empty_r = a.run("edit.copy", json!({})).unwrap();
put_result(&mut a, &ctx, &empty_r);
assert_eq!(got_image.borrow().len(), 1, "empty copy must not call set_clipboard_image");
assert!(a.clipboard_published, "empty copy must not change published-copy state");
let mut a2 = app(Services { set_clipboard_image: Some(Box::new(|_, _, _| true)), ..Default::default() });
assert!(!a2.clipboard_published);
let empty_r2 = a2.run("edit.copy", json!({})).unwrap();
put_result(&mut a2, &ctx, &empty_r2);
assert!(!a2.clipboard_published, "empty copy must not change published-copy state when false");
}
}
+14
View File
@@ -3274,6 +3274,20 @@ fn window_close_save_writes_the_file_and_quits() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Save As suggests a Markdown file's own format, as it does a Word file's; anything else is .docx.
#[test]
fn save_as_suggests_a_markdown_files_own_format() {
let dir = scratch("save-name");
for (name, suggested) in [("notes.md", "notes.md"), ("notes.markdown", "notes.markdown"), ("notes.MD", "notes.MD"), ("notes.txt", "notes.docx")] {
let path = dir.join(name);
std::fs::write(&path, b"Hello\n").unwrap();
let mut a = app();
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(a.default_save_name(), suggested, "{name}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// #2376: a text file opened, edited and saved still asked to save on close, and Save in that
/// prompt wrote the file but left the window open; only Don't Save closed it.
#[test]
+1
View File
@@ -253,6 +253,7 @@ impl WordApp {
file_browser,
info_editing,
shown_document,
clipboard_published: _,
} = self;
let UiState {
// App-wide: preferences, recent lists, and the settings the session owns while
+5 -3
View File
File diff suppressed because one or more lines are too long
+6 -3
View File
File diff suppressed because one or more lines are too long