mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 16:31:54 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb178bb18d | ||
|
|
77780d19db | ||
|
|
92e0e67f3a | ||
|
|
a34c94e4c1 | ||
|
|
a42c9eaab3 | ||
|
|
12869b8e02 | ||
|
|
8548b33be1 | ||
|
|
abd5a6e957 | ||
|
|
8dac5c7802 |
+6
-1
File diff suppressed because one or more lines are too long
@@ -108,8 +108,8 @@ pub fn formats() -> Vec<String> {
|
||||
|
||||
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
|
||||
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
|
||||
/// from the header before anything is decoded (#3623).
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows and Wayland they are
|
||||
/// checked from the header before anything is decoded (#3623).
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
let mut clipboard = match arboard::Clipboard::new() {
|
||||
Ok(c) => c,
|
||||
@@ -136,6 +136,10 @@ fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
|
||||
if let Some(picture) = platform::picture() {
|
||||
return Some(picture);
|
||||
}
|
||||
match clipboard.get_image() {
|
||||
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
|
||||
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
|
||||
@@ -160,6 +164,7 @@ mod platform {
|
||||
use std::io::Read;
|
||||
|
||||
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
|
||||
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
|
||||
|
||||
fn wayland() -> bool {
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
@@ -364,6 +369,22 @@ mod platform {
|
||||
assert!(complete(0, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
|
||||
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
|
||||
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
|
||||
/// (or isn't reachable): arboard is asked then, which also covers X11.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
|
||||
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
|
||||
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -136,7 +136,34 @@ impl Server {
|
||||
if batch.is_empty() {
|
||||
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
|
||||
} else {
|
||||
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
||||
// Held to the same output budget as the `batch` tool: the rest of the batch is
|
||||
// refused once the replies pass it.
|
||||
let mut replies: Vec<Value> = Vec::new();
|
||||
let mut size = 0usize;
|
||||
for m in batch {
|
||||
if size > crate::tools::MAX_BATCH_OUTPUT {
|
||||
// Notifications get no reply, run or not.
|
||||
if let Some(id) = m.get("id").cloned() {
|
||||
replies.push(error(id, INTERNAL_ERROR, "the batch's replies passed the output limit; not run"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let id = m.get("id").cloned();
|
||||
if let Some(r) = self.handle(m) {
|
||||
size = size.saturating_add(crate::tools::json_len(&r));
|
||||
if size > crate::tools::MAX_BATCH_OUTPUT {
|
||||
// This reply is the one that crosses the limit: it is not kept.
|
||||
let id = id.unwrap_or(Value::Null);
|
||||
replies.push(error(
|
||||
id,
|
||||
INTERNAL_ERROR,
|
||||
"the batch's replies passed the output limit; this reply and the rest are left out",
|
||||
));
|
||||
} else {
|
||||
replies.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
(!replies.is_empty()).then_some(Value::Array(replies))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,3 +452,32 @@ fn invalid_utf8_does_not_change_arguments_or_stop_later_requests() {
|
||||
assert_eq!(replies[1]["result"]["isError"], false);
|
||||
assert_eq!(tool(&mut s, "get_text", json!({}))["text"], "good", "malformed text must never reach the backend");
|
||||
}
|
||||
|
||||
/// A backend whose every result is 1 MiB of text.
|
||||
struct Big;
|
||||
|
||||
impl crate::Backend for Big {
|
||||
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
|
||||
Ok(json!("x".repeat(1 << 20)))
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"big".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_batch_does_not_keep_the_reply_that_crosses_the_output_budget() {
|
||||
let n = (crate::tools::MAX_BATCH_OUTPUT >> 20) + 5;
|
||||
let one =
|
||||
|i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "tools/call", "params": {"name": "execute", "arguments": {"command": "edit.copy"}}});
|
||||
let line = Value::Array((0..n).map(one).collect()).to_string();
|
||||
let mut s = Server::new(Box::new(Big));
|
||||
let out = s.handle_line(&line).expect("reply");
|
||||
assert!(out.len() <= crate::tools::MAX_BATCH_OUTPUT + (1 << 16), "{}", out.len());
|
||||
let replies: Vec<Value> = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(replies.len(), n);
|
||||
assert!(replies.iter().any(|r| r["error"]["message"].as_str().is_some_and(|m| m.contains("left out"))));
|
||||
}
|
||||
|
||||
+98
-4
@@ -216,6 +216,26 @@ fn with_active_id(b: &mut dyn Backend, r: Result<Value, String>) -> Result<Value
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Most bytes of results one `batch` (or one legacy JSON-RPC batch) keeps for its reply.
|
||||
pub const MAX_BATCH_OUTPUT: usize = 64 << 20;
|
||||
|
||||
/// Length of `v` written as JSON, counted without keeping the text.
|
||||
pub(crate) fn json_len(v: &Value) -> usize {
|
||||
struct Count(usize);
|
||||
impl std::io::Write for Count {
|
||||
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
|
||||
self.0 = self.0.saturating_add(b.len());
|
||||
Ok(b.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut n = Count(0);
|
||||
let _ = serde_json::to_writer(&mut n, v);
|
||||
n.0
|
||||
}
|
||||
|
||||
fn wrap(r: Result<Value, String>) -> ToolResult {
|
||||
match r {
|
||||
Ok(v) => ToolResult::json(&v),
|
||||
@@ -267,15 +287,27 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
"batch" => {
|
||||
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut out = Vec::new();
|
||||
// What the results hold so far: past MAX_BATCH_OUTPUT the batch stops, rather than
|
||||
// keeping copies of a document command after command until the reply is written.
|
||||
let mut size = 0usize;
|
||||
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({})), doc);
|
||||
let failed = r.is_err();
|
||||
out.push(match r {
|
||||
let mut failed = r.is_err();
|
||||
let entry = match r {
|
||||
Ok(v) => json!({"command": id, "ok": true, "result": v}),
|
||||
Err(e) => json!({"command": id, "ok": false, "error": e}),
|
||||
});
|
||||
if failed && !keep {
|
||||
};
|
||||
// Counted as the reply stores it: the array is text content, so its quotes and
|
||||
// backslashes are escaped again.
|
||||
size = size.saturating_add(json_len(&Value::String(entry.to_string())));
|
||||
if size > MAX_BATCH_OUTPUT {
|
||||
out.push(json!({"command": id, "ok": false, "error": format!("the batch's results passed {MAX_BATCH_OUTPUT} bytes; this command's result and the commands after it are left out")}));
|
||||
failed = true;
|
||||
} else {
|
||||
out.push(entry);
|
||||
}
|
||||
if failed && (!keep || size > MAX_BATCH_OUTPUT) {
|
||||
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||
}
|
||||
}
|
||||
@@ -410,6 +442,68 @@ mod tests {
|
||||
assert!(b.seen.take().expect("called").1.get("doc").is_none());
|
||||
}
|
||||
|
||||
/// A backend whose every result is 1 MiB of text (a whole document, say).
|
||||
struct Big {
|
||||
calls: usize,
|
||||
}
|
||||
|
||||
impl Backend for Big {
|
||||
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
|
||||
self.calls += 1;
|
||||
Ok(json!("x".repeat(1 << 20)))
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"big".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// A backend whose every result is 1 MiB of quotes, which the reply escapes twice.
|
||||
struct Quoted {
|
||||
calls: usize,
|
||||
}
|
||||
|
||||
impl Backend for Quoted {
|
||||
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
|
||||
self.calls += 1;
|
||||
Ok(json!("\"".repeat(1 << 20)))
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"quoted".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_budgets_the_escaped_text_it_returns() {
|
||||
// Each result is 2 MiB as JSON and 4 MiB once the array is stored as text.
|
||||
let commands: Vec<Value> = (0..40).map(|_| json!({"command": "edit.copy"})).collect();
|
||||
let mut b = Quoted { calls: 0 };
|
||||
let r = call_tool(&mut b, "batch", &json!({"commands": commands, "keepGoing": true}));
|
||||
assert!(r.is_error);
|
||||
assert!(b.calls <= (MAX_BATCH_OUTPUT >> 22) + 1, "{} commands ran", b.calls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_stops_once_its_results_pass_the_output_budget() {
|
||||
// 200 commands of 1 MiB results each kept 200 MiB until the reply; it stops at 64 MiB.
|
||||
let commands: Vec<Value> = (0..200).map(|_| json!({"command": "edit.copy"})).collect();
|
||||
let mut b = Big { calls: 0 };
|
||||
let r = call_tool(&mut b, "batch", &json!({"commands": commands, "keepGoing": true}));
|
||||
assert!(r.is_error);
|
||||
assert!(b.calls <= (MAX_BATCH_OUTPUT >> 20) + 1, "{} commands ran", b.calls);
|
||||
let text = r.content[0]["text"].as_str().unwrap_or_default();
|
||||
assert!(text.len() <= MAX_BATCH_OUTPUT + 1024, "{}", text.len());
|
||||
assert!(text.contains("left out"));
|
||||
// A small batch is untouched.
|
||||
let r = call_tool(&mut b, "batch", &json!({"commands": [{"command": "edit.copy"}]}));
|
||||
assert!(!r.is_error);
|
||||
}
|
||||
|
||||
/// The password has to be in the schema, or a client has no way to know it can be sent.
|
||||
#[test]
|
||||
fn the_schema_advertises_the_password_as_optional() {
|
||||
|
||||
@@ -92,6 +92,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
if drag.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
crate::chrome::note_title_drag(ui.ctx(), band);
|
||||
// The chevron's stroke starts at the content edge.
|
||||
let c = if rtl {
|
||||
pos2(band.max.x - 26.0, band.center().y)
|
||||
|
||||
@@ -281,6 +281,24 @@ fn autosave_knob_x(rtl: bool, on: bool, r: Rect) -> f32 {
|
||||
crate::rtl::lead_x(rtl != on, r, 8.0)
|
||||
}
|
||||
|
||||
/// The `ctx.data` key for the part of the title bar that drags this document window.
|
||||
fn title_drag_id(ctx: &egui::Context) -> egui::Id {
|
||||
egui::Id::unique(("title_drag_rect", ctx.viewport_id()))
|
||||
}
|
||||
|
||||
/// Notes `rect` as the part of the title bar that drags the window in this frame: behind a modal
|
||||
/// dialog its backdrop has the pointer there, and moves the window instead ([`title_drag_rect`]).
|
||||
pub(crate) fn note_title_drag(ctx: &egui::Context, rect: Rect) {
|
||||
let (key, pass) = (title_drag_id(ctx), ctx.cumulative_pass_nr());
|
||||
ctx.data_mut(|d| d.insert_temp(key, (rect, pass)));
|
||||
}
|
||||
|
||||
/// The part of the title bar that drags the window, if it was drawn in this frame.
|
||||
pub(crate) fn title_drag_rect(ctx: &egui::Context) -> Option<Rect> {
|
||||
let (key, pass) = (title_drag_id(ctx), ctx.cumulative_pass_nr());
|
||||
ctx.data(|d| d.get_temp::<(Rect, u64)>(key)).filter(|(_, p)| *p == pass).map(|(r, _)| r)
|
||||
}
|
||||
|
||||
/// Draws the title bar with window dragging, AutoSave, quick access, account, community and search
|
||||
/// controls, and the centred clickable document title.
|
||||
pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
@@ -301,6 +319,10 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
if drag.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
// Elsewhere the system's own title bar above this one moves the window.
|
||||
if mac {
|
||||
note_title_drag(ui.ctx(), full);
|
||||
}
|
||||
if drag.double_clicked() {
|
||||
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
|
||||
|
||||
@@ -455,6 +455,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
let resp = egui::Modal::new(Id::unique("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d));
|
||||
// egui's Modal blocks the window behind it, but not the document's keyboard.
|
||||
crate::dialogs::release_keyboard(app, ctx);
|
||||
crate::dialogs::drag_by_title_bar(ctx, &resp.backdrop_response, resp.response.rect);
|
||||
let mut done = resp.inner;
|
||||
if done.is_none() && resp.should_close() {
|
||||
done = Some(false);
|
||||
|
||||
@@ -34,7 +34,9 @@ use bookmarks::bookmark_at_caret;
|
||||
#[cfg(test)]
|
||||
use bookmarks::bookmark_names;
|
||||
pub use chrome::show;
|
||||
pub(crate) use chrome::{block_behind, buttons, cancel_key_pressed, color_menu, forget_nudge, ok_cancel_responses, release_keyboard};
|
||||
pub(crate) use chrome::{
|
||||
block_behind, buttons, cancel_key_pressed, color_menu, drag_by_title_bar, forget_nudge, ok_cancel_responses, release_keyboard,
|
||||
};
|
||||
pub(crate) use dispatch::search_results;
|
||||
pub(crate) use find::swallow_shortcut_text;
|
||||
pub use find::{FindExtra, FindTab, GoToWhat};
|
||||
|
||||
@@ -67,6 +67,16 @@ fn count_dialog_frame(ctx: &egui::Context, id: egui::Id) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Behind a modal window, whose `backdrop` has the pointer, the app still moves by its title bar,
|
||||
/// as in Word for Mac (#5124); not from just inside the window's edge, where it reaches over the
|
||||
/// title bar.
|
||||
pub(crate) fn drag_by_title_bar(ctx: &egui::Context, backdrop: &egui::Response, window: egui::Rect) {
|
||||
let origin = ctx.input(|i| i.pointer.press_origin()).filter(|p| !window.contains(*p));
|
||||
if backdrop.drag_started() && origin.is_some_and(|p| crate::chrome::title_drag_rect(ctx).is_some_and(|r| r.contains(p))) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes `window` modal (#3859): the document gives up the keyboard now, and from the next frame
|
||||
/// nothing beneath the window takes the pointer or Tab. A press beside it nudges it ([`nudge`]).
|
||||
pub(crate) fn block_behind(app: &WordApp, ctx: &egui::Context, window: &egui::Response) {
|
||||
@@ -81,6 +91,7 @@ pub(crate) fn block_behind(app: &WordApp, ctx: &egui::Context, window: &egui::Re
|
||||
.inner;
|
||||
// The backdrop also has the pointer just inside the window's edge (egui's hit radius).
|
||||
let beside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| !window.rect.contains(p)));
|
||||
drag_by_title_bar(ctx, &backdrop, window.rect);
|
||||
ctx.move_to_top(layer);
|
||||
ctx.memory_mut(|m| m.set_modal_layer(layer));
|
||||
ctx.accesskit_node_builder(window.id, |node| node.set_modal());
|
||||
|
||||
@@ -2078,6 +2078,116 @@ fn the_page_waits_for_the_keyboard_until_a_modal_dialog_closes() {
|
||||
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "!", "the page has it after the dialog");
|
||||
}
|
||||
|
||||
/// Drags from `at` to 30 points right in `h`, and counts the window drags the app asked for.
|
||||
fn window_drags(h: &mut egui_kittest::Harness<WordApp>, at: egui::Pos2) -> usize {
|
||||
let mut drags = 0;
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
let events = [egui::Event::PointerMoved(at), button(at, true)]
|
||||
.into_iter()
|
||||
.chain([10.0, 20.0, 30.0].map(|dx| egui::Event::PointerMoved(at + vec2(dx, 0.0))))
|
||||
.chain([button(at + vec2(30.0, 0.0), false)]);
|
||||
for e in events {
|
||||
h.event(e);
|
||||
h.step();
|
||||
drags += h.output().viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::StartDrag)).count();
|
||||
}
|
||||
drags
|
||||
}
|
||||
|
||||
/// #5124: behind a modal dialog the window still moves by its title bar, as in Word for Mac, where
|
||||
/// WordCraft draws it, or by the top of File's side menu, which covers it there; elsewhere the
|
||||
/// system's title bar does, and WordCraft's bar under it doesn't.
|
||||
#[test]
|
||||
fn the_window_moves_by_its_title_bar_behind_a_modal_dialog() {
|
||||
for (mac, backstage) in [(true, false), (true, true), (false, false), (false, true)] {
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.integrated_titlebar = mac;
|
||||
a.ui.backstage = backstage;
|
||||
let mut h = app_harness(a);
|
||||
h.run_steps(6);
|
||||
let font = Dialog::open("font", h.state_mut());
|
||||
h.state_mut().dialog = font;
|
||||
h.run_steps(4);
|
||||
let at = egui::pos2(if backstage { 100.0 } else { 300.0 }, 12.0);
|
||||
assert_eq!(window_drags(&mut h, at), usize::from(mac), "macOS: {mac}, File: {backstage}");
|
||||
assert!(matches!(h.state().dialog, Some(Dialog::Font { .. })), "Font stays");
|
||||
assert_eq!(h.state().ui.backstage, backstage, "nothing behind it opened or closed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a modal state in `a`: the built-in file browser, a dialog by name, or Colors.
|
||||
fn open_modal_state(a: &mut WordApp, state: &str) {
|
||||
match state {
|
||||
"none" => {}
|
||||
"fileBrowser" => {
|
||||
let document = a.session.document_id();
|
||||
a.show_file_browser(
|
||||
crate::file_dialogs::FileDialogRequest::Open { purpose: "document".into() },
|
||||
crate::file_dialogs::AfterPick::OpenDocument { document },
|
||||
false,
|
||||
);
|
||||
}
|
||||
"colors" => a.colors = Some(crate::colors::ColorsDialog::new(None, crate::colors::Target::cmd("format.color"))),
|
||||
dialog => a.dialog = Dialog::open(dialog, a),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file browser, a modal dialog and Colors each block the window; nothing open or a modeless
|
||||
/// dialog doesn't.
|
||||
#[test]
|
||||
fn modal_open_is_true_for_the_file_browser_a_modal_dialog_and_colors() {
|
||||
for (state, modal) in [("none", false), ("find", false), ("font", true), ("fileBrowser", true), ("colors", true)] {
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
open_modal_state(&mut a, state);
|
||||
assert_eq!(a.modal_open(), modal, "{state}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5124: whatever is open, a drag on the title bar macOS draws moves the window; where the system
|
||||
/// draws it, WordCraft's bar under it only does while nothing blocks it.
|
||||
#[test]
|
||||
fn the_title_bar_moves_the_window_in_every_modal_state() {
|
||||
for (state, modal) in [("none", false), ("find", false), ("font", true), ("fileBrowser", true), ("colors", true)] {
|
||||
for mac in [true, false] {
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.integrated_titlebar = mac;
|
||||
let mut h = app_harness(a);
|
||||
h.run_steps(6);
|
||||
open_modal_state(h.state_mut(), state);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().modal_open(), modal, "{state}");
|
||||
assert_eq!(window_drags(&mut h, egui::pos2(300.0, 12.0)), usize::from(mac || !modal), "{state}, macOS: {mac}");
|
||||
assert_eq!(h.state().modal_open(), modal, "{state} stays open");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #5124: where a dialog reaches over the title bar (a short window), a drag on the dialog is the
|
||||
/// dialog's own and doesn't move the window.
|
||||
#[test]
|
||||
fn a_drag_on_a_dialog_over_the_title_bar_doesnt_move_the_window() {
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.integrated_titlebar = true;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 420.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(6);
|
||||
let font = Dialog::open("font", h.state_mut());
|
||||
h.state_mut().dialog = font;
|
||||
h.run_steps(4);
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Font")))).expect("the dialog is open");
|
||||
let at = egui::pos2(rect.center().x, rect.top() + 8.0);
|
||||
assert!(at.y < 30.0, "the dialog reaches over the title bar: {rect:?}");
|
||||
assert_eq!(window_drags(&mut h, at), 0, "a drag on its title");
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Font")))).expect("the dialog is open");
|
||||
assert_eq!(window_drags(&mut h, egui::pos2(rect.center().x, rect.top() + 1.0)), 0, "nor just inside its edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_sample_props_map_each_field_to_its_property() {
|
||||
use super::font::font_sample_props;
|
||||
|
||||
@@ -67,11 +67,16 @@ fn too_large_to_paste(width: u32, height: u32) -> ClipboardPicture {
|
||||
ClipboardPicture::Unreadable(format!("the clipboard picture is too large to paste ({width} × {height} pixels)"))
|
||||
}
|
||||
|
||||
/// Whether the renderer can decode `bytes` within its budget (a 16-bit picture takes more than its pixel count says).
|
||||
fn probe_fits(bytes: &[u8]) -> bool {
|
||||
wordcraft_geom::picture::probe(bytes).is_ok_and(|p| p.fits(wordcraft_geom::MAX_DECODED_BYTES))
|
||||
}
|
||||
|
||||
/// An encoded picture the host read from the clipboard (the Windows "PNG" format), refused from
|
||||
/// its header when a paste can't take its size, before anything decodes it (#3623).
|
||||
pub fn encoded_picture(bytes: Vec<u8>) -> ClipboardPicture {
|
||||
match wordcraft_geom::picture::probe(&bytes).map(|p| (p.width, p.height)) {
|
||||
Ok((w, h)) if pastes(w, h) => ClipboardPicture::Encoded(bytes),
|
||||
Ok((w, h)) if pastes(w, h) && probe_fits(&bytes) => ClipboardPicture::Encoded(bytes),
|
||||
Ok((w, h)) => too_large_to_paste(w, h),
|
||||
Err(e) => ClipboardPicture::Unreadable(e.to_string()),
|
||||
}
|
||||
@@ -258,6 +263,22 @@ mod tests {
|
||||
image::GrayImage::new(20_000, 1).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
|
||||
assert!(matches!(encoded_picture(wide), ClipboardPicture::Unreadable(e) if e.contains("too large")));
|
||||
assert!(matches!(encoded_picture(b"not a picture".to_vec()), ClipboardPicture::Unreadable(_)));
|
||||
// 8000 × 6000 passes the pixel limits, but 16-bit RGBA needs 12 bytes a pixel to decode.
|
||||
let mut big = Vec::new();
|
||||
image::DynamicImage::ImageRgba16(image::ImageBuffer::new(1, 1))
|
||||
.write_to(&mut std::io::Cursor::new(&mut big), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
big[16..20].copy_from_slice(&8000u32.to_be_bytes());
|
||||
big[20..24].copy_from_slice(&6000u32.to_be_bytes());
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in &big[12..29] {
|
||||
crc ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 1 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
big[29..33].copy_from_slice(&(!crc).to_be_bytes());
|
||||
assert!(matches!(encoded_picture(big), ClipboardPicture::Unreadable(e) if e.contains("too large")));
|
||||
// BGRA in memory: a half-transparent red pixel and an opaque blue one.
|
||||
let ClipboardPicture::Pixels { width, height, rgba } = dib_picture(dib(2, 1, &[[0, 0, 255, 128], [255, 0, 0, 255]])) else {
|
||||
panic!("pixels")
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-2
File diff suppressed because one or more lines are too long
@@ -55,7 +55,7 @@ esac
|
||||
# Only binaries for this machine run here (the --version check at the end); a FreeBSD target built
|
||||
# on another OS never does.
|
||||
HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="$(uname -m)"
|
||||
if [ -n "${CARGO_BUILD_TARGET:-}" ] && [ "$(uname -s)" != FreeBSD ]; then
|
||||
if [[ -n "${CARGO_BUILD_TARGET:-}" ]] && [[ "$(uname -s)" != FreeBSD ]]; then
|
||||
HOST_ARCH="$(uname -s) $HOST_ARCH"
|
||||
fi
|
||||
BASENAME="wordcraft-$VERSION-freebsd-$ARCH"
|
||||
@@ -96,12 +96,12 @@ mkdir -p "$DIST"
|
||||
tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
|
||||
echo "wrote $DIST/$BASENAME.tar.gz"
|
||||
|
||||
if [ -z "${CARGO_BUILD_TARGET:-}" ] && [ "$ARCH" = "$HOST_ARCH" ]; then
|
||||
if [[ -z "${CARGO_BUILD_TARGET:-}" ]] && [[ "$ARCH" = "$HOST_ARCH" ]]; then
|
||||
"$STAGE/bin/wordcraft-cli" --version
|
||||
else
|
||||
# They cannot run here, so check what they are instead: executables, not libraries or objects.
|
||||
# An older file(1) calls a PIE a shared object; its interpreter still marks it as a program.
|
||||
want="$ARCH"; [ "$ARCH" != x86_64 ] || want=x86-64
|
||||
want="$ARCH"; [[ "$ARCH" != x86_64 ]] || want=x86-64
|
||||
for b in "$STAGE"/bin/*; do
|
||||
case "$(file -b "$b")" in
|
||||
ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;;
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
sonar.projectKey=storytold_wordcraft
|
||||
sonar.organization=storytold
|
||||
|
||||
# S4790 (weak hash): SHA-1 is a start-key and "SHA1/1K" checksum algorithm that ODF package
|
||||
# encryption (ODF 1.3 Part 2 §4.16) names, and older office suites wrote it. It is needed to open
|
||||
# those files, not chosen for new security: WordCraft writes a SHA-256 start key and checksum.
|
||||
sonar.issue.ignore.multicriteria=odt_sha1
|
||||
sonar.issue.ignore.multicriteria.odt_sha1.ruleKey=rust:S4790
|
||||
sonar.issue.ignore.multicriteria.odt_sha1.resourceKey=crates/formats/src/odt/crypt.rs
|
||||
|
||||
Reference in New Issue
Block a user