mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 17:03:12 -06:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bf96e65e4 | ||
|
|
d54fb78544 | ||
|
|
aa6deaee03 | ||
|
|
869350d4c7 | ||
|
|
139aa7a51b | ||
|
|
76d5f13a03 | ||
|
|
11318f9226 | ||
|
|
0196647dc6 | ||
|
|
10e5bb8bce | ||
|
|
c8e64d72cc | ||
|
|
664b033071 | ||
|
|
1a8fd899d3 | ||
|
|
2d286fe252 | ||
|
|
ab90b7142e | ||
|
|
02f8022e57 | ||
|
|
afcb2ce46e | ||
|
|
4671e47b50 | ||
|
|
cd4e63849e | ||
|
|
a1d4495cfb | ||
|
|
07f977372d | ||
|
|
cc27f29a01 | ||
|
|
28af39b610 | ||
|
|
22f01131d8 | ||
|
|
860426adda | ||
|
|
7458a18468 | ||
|
|
49e777cce5 |
@@ -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
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(®ion) };
|
||||
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.
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -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, ¤t.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\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");
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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(¬e, 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
|
||||
|
||||
@@ -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))];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.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| {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+6
-3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user