mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 12:20:12 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12869b8e02 | ||
|
|
8548b33be1 | ||
|
|
abd5a6e957 | ||
|
|
8dac5c7802 | ||
|
|
2922fe566b | ||
|
|
43f94a5dca |
+3
-1
File diff suppressed because one or more lines are too long
@@ -123,13 +123,15 @@ mod tests {
|
||||
assert_eq!(parse_color("#f00"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("#f00f"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("#0080FF"), Some(Rgb(0, 128, 255)));
|
||||
assert_eq!(parse_color("#0080ff80"), Some(Rgb(0, 128, 255)));
|
||||
assert_eq!(parse_color("#0080FFff"), Some(Rgb(0, 128, 255)));
|
||||
// A colour with alpha is composited over the white page it lands on (#0080ff at 50%).
|
||||
assert_eq!(parse_color("#0080ff80"), Some(Rgb(127, 191, 255)));
|
||||
assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Rgb(1, 2, 3)));
|
||||
assert_eq!(parse_color("rgb(1 2 3)"), Some(Rgb(1, 2, 3)));
|
||||
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(1, 2, 3)));
|
||||
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(128, 129, 129)));
|
||||
assert_eq!(parse_color("hsl(0, 100%, 50%)"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("hsl(120deg 100% 50%)"), Some(Rgb(0, 255, 0)));
|
||||
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(0, 0, 255)));
|
||||
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(51, 51, 255)));
|
||||
assert_eq!(parse_color("navy"), Some(Rgb(0, 0, 128)));
|
||||
assert_eq!(parse_color("#zz"), None);
|
||||
assert_eq!(parse_color("rgb(1)"), None);
|
||||
|
||||
@@ -34,24 +34,59 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> Rgb {
|
||||
Rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// Composite `rgb` over the white page a paste lands on, at CSS alpha `a` (0..1): `rgba(0, 0, 0,
|
||||
/// 0.45)` reads like the rgb(140, 140, 140) it looks like on the site, instead of solid black.
|
||||
fn over_white(r: u8, g: u8, b: u8, a: f32) -> Rgb {
|
||||
let mix = |c: u8| (c as f32 * a + 255.0 * (1.0 - a)).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(mix(r), mix(g), mix(b))
|
||||
}
|
||||
|
||||
/// The alpha of a CSS colour's `/ alpha` or 4th-value part, where present: 1.0 (opaque) otherwise.
|
||||
fn css_alpha(after: Option<&str>) -> f32 {
|
||||
let Some(a) = after else { return 1.0 };
|
||||
let a = a.trim();
|
||||
// CSS Colour 4's `none` is a missing component, and a missing alpha is transparent.
|
||||
if a.eq_ignore_ascii_case("none") {
|
||||
return 0.0;
|
||||
}
|
||||
// A value that is not a finite number (`nan`, `inf`) is no alpha at all: clamp would keep
|
||||
// NaN, and `over_white` would turn the colour black.
|
||||
let parse = |t: &str| t.parse::<f32>().ok().filter(|f| f.is_finite());
|
||||
if let Some(p) = a.strip_suffix('%') {
|
||||
return parse(p.trim()).map(|f| f.clamp(0.0, 100.0) / 100.0).unwrap_or(1.0);
|
||||
}
|
||||
parse(a).map(|f| f.clamp(0.0, 1.0)).unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Parse a CSS / HTML colour: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `hsl(...)` or a basic name.
|
||||
/// A colour with alpha is composited over white: the model keeps opaque colours, and a paste lands
|
||||
/// on a white page.
|
||||
pub fn parse_color(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
if let Some(h) = s.strip_prefix('#') {
|
||||
if (h.len() == 3 || h.len() == 4) && h.is_ascii() {
|
||||
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
|
||||
return Some(Rgb(d(0)?, d(1)?, d(2)?));
|
||||
let (r, g, b) = (d(0)?, d(1)?, d(2)?);
|
||||
// A 4th digit is the alpha, composited over the page like `rgba(...)`'s is.
|
||||
let a = if h.len() == 4 { d(3)? as f32 / 255.0 } else { 1.0 };
|
||||
return Some(over_white(r, g, b, a));
|
||||
}
|
||||
if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
|
||||
return Some(Rgb(p(0)?, p(2)?, p(4)?));
|
||||
return Some(over_white(p(0)?, p(2)?, p(4)?, p(6)? as f32 / 255.0));
|
||||
}
|
||||
return Rgb::parse(h);
|
||||
}
|
||||
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
|
||||
let inner = inner.trim_end_matches(')').trim();
|
||||
let before_slash = inner.split('/').next().unwrap_or(inner);
|
||||
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
|
||||
let (rgb_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
|
||||
// Legacy comma syntax carries the alpha as a 4th value (`rgba(0, 0, 0, 0.45)`).
|
||||
let parts: Vec<&str> = if rgb_part.contains(',') { rgb_part.split(',').collect() } else { rgb_part.split_whitespace().collect() };
|
||||
let alpha = match alpha_part {
|
||||
Some(a) => css_alpha(Some(a)),
|
||||
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
|
||||
_ => 1.0,
|
||||
};
|
||||
let v: Vec<u8> = parts
|
||||
.iter()
|
||||
.take(3)
|
||||
@@ -65,14 +100,19 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
|
||||
})
|
||||
.collect();
|
||||
if let [r, g, b] = v[..] {
|
||||
return Some(Rgb(r, g, b));
|
||||
return Some(over_white(r, g, b, alpha));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if let Some(inner) = s.strip_prefix("hsl(").or_else(|| s.strip_prefix("hsla(")) {
|
||||
let inner = inner.trim_end_matches(')').trim();
|
||||
let before_slash = inner.split('/').next().unwrap_or(inner);
|
||||
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
|
||||
let (hsl_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
|
||||
let parts: Vec<&str> = if hsl_part.contains(',') { hsl_part.split(',').collect() } else { hsl_part.split_whitespace().collect() };
|
||||
let alpha = match alpha_part {
|
||||
Some(a) => css_alpha(Some(a)),
|
||||
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
|
||||
_ => 1.0,
|
||||
};
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
@@ -96,7 +136,8 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
|
||||
};
|
||||
let s = parse_pct(parts[1])?;
|
||||
let l = parse_pct(parts[2])?;
|
||||
return Some(hsl_to_rgb(h, s, l));
|
||||
let Rgb(r, g, b) = hsl_to_rgb(h, s, l);
|
||||
return Some(over_white(r, g, b, alpha));
|
||||
}
|
||||
Some(match s.as_str() {
|
||||
"black" => Rgb(0, 0, 0),
|
||||
@@ -127,6 +168,29 @@ mod tests {
|
||||
#[test]
|
||||
fn eight_digit_hex_rejects_signs() {
|
||||
assert_eq!(parse_color("#+1+2+3+4"), None);
|
||||
assert_eq!(parse_color("#11223344"), Some(Rgb(0x11, 0x22, 0x33)));
|
||||
// #11223344 is alpha 0x44 over white.
|
||||
assert_eq!(parse_color("#11223344"), Some(Rgb(192, 196, 201)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_with_alpha_composite_over_white() {
|
||||
// The secondary text colour of dark-theme sites: solid black would read as the site's
|
||||
// own emphasis, white is invisible on the page; the composite is what the eye saw.
|
||||
assert_eq!(parse_color("rgba(0, 0, 0, 0.45)"), Some(Rgb(140, 140, 140)));
|
||||
assert_eq!(parse_color("rgb(0 0 0 / 0.45)"), Some(Rgb(140, 140, 140)));
|
||||
assert_eq!(parse_color("rgba(0, 0, 0, 0)"), Some(Rgb(255, 255, 255)));
|
||||
assert_eq!(parse_color("rgba(255, 0, 0, 1)"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("rgba(255, 0, 0, 45%)"), Some(Rgb(255, 140, 140)));
|
||||
assert_eq!(parse_color("hsla(120, 100%, 50%, 0.5)"), Some(Rgb(128, 255, 128)));
|
||||
// A 4-digit hex's last digit is the alpha, too: #0008 is black at 8/15 over white.
|
||||
assert_eq!(parse_color("#0008"), Some(Rgb(119, 119, 119)));
|
||||
// `none` is a missing alpha, and a missing alpha is transparent, not the opaque default
|
||||
// an omitted alpha takes.
|
||||
assert_eq!(parse_color("rgb(0 0 0 / none)"), Some(Rgb(255, 255, 255)));
|
||||
assert_eq!(parse_color("rgb(0 0 0)"), Some(Rgb(0, 0, 0)));
|
||||
// An alpha that is not a finite number is no alpha: the colour stays opaque, not black.
|
||||
assert_eq!(parse_color("rgba(0, 0, 0, nan)"), Some(Rgb(0, 0, 0)));
|
||||
assert_eq!(parse_color("rgba(0, 0, 0, inf)"), Some(Rgb(0, 0, 0)));
|
||||
assert_eq!(parse_color("rgba(0, 0, 0, nan%)"), Some(Rgb(0, 0, 0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,13 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
|
||||
}
|
||||
match n {
|
||||
b'\'' => {
|
||||
let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
// `from_str_radix` takes a leading `+`: a pair that isn't both hex digits
|
||||
// is not an escape at all.
|
||||
let h = bytes
|
||||
.get(i + 2..i + 4)
|
||||
.and_then(|h| std::str::from_utf8(h).ok())
|
||||
.filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
.and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
match h {
|
||||
Some(v) => {
|
||||
if r.st.dest == Dest::Pict {
|
||||
@@ -150,7 +156,17 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
|
||||
}
|
||||
i += 4;
|
||||
}
|
||||
None => i += 2,
|
||||
// Not two hex digits: the escape is malformed. Consume the escape and
|
||||
// any hex digits of a truncated pair, and leave every other byte,
|
||||
// `}` or a control word's `\`, to be read as the structure it is.
|
||||
None => {
|
||||
i += 2;
|
||||
let mut n = 0;
|
||||
while n < 2 && bytes.get(i).is_some_and(u8::is_ascii_hexdigit) {
|
||||
i += 1;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b'*' => {
|
||||
|
||||
@@ -37,6 +37,73 @@ fn basic_reader() {
|
||||
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_names_take_unicode_escapes() {
|
||||
// \u116?=t \u105?=i \u116?=t \u108?=l \u101?=e — how Word writes a non-Latin style name.
|
||||
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u116?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
assert_eq!(texts(&f)[0], "Title:T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_names_take_negative_escapes() {
|
||||
// 't' as a signed 16-bit escape: 116 - 65536 = -65420.
|
||||
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u-65420?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
assert_eq!(texts(&f)[0], "Title:T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_names_honour_the_uc_fallback_count() {
|
||||
// \uc2: two fallback bytes follow each \uN? — here a second `?`.
|
||||
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \uc2\u116??\u105??\u116??\u108??\u101??;}}\pard\s2 T\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
assert_eq!(texts(&f)[0], "Title:T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_hex_escape_leaves_no_stray_text() {
|
||||
// A `\'` that is not followed by two hex digits is malformed: the escape itself goes, any
|
||||
// hex digits of a truncated pair go with it, and everything else — non-hex bytes, `}` —
|
||||
// is read as what it is. `\'gZ` keeps "gZ" as text; `\'4}` keeps the closing brace.
|
||||
let rtf = br"{\rtf1\ansi \'gZok\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
assert_eq!(texts(&f)[0], "Normal:gZok");
|
||||
|
||||
// The bytes after a truncated pair must not swallow the group's closing brace or the next
|
||||
// control word: the group and its destination state end where they should. Paragraph text
|
||||
// alone would not show that: `survivor` reads the same inside the bold group, so the run's
|
||||
// formatting is checked too.
|
||||
let rtf = br"{\rtf1\ansi{\b \'4}survivor\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
assert!(texts(&f).iter().any(|t| t.contains("survivor")), "{:?}", texts(&f));
|
||||
let survivor_bold = f.blocks.iter().find_map(|b| match b {
|
||||
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
|
||||
Inline::Text(t, fmt) if t.contains("survivor") => Some(fmt.bold),
|
||||
_ => None,
|
||||
}),
|
||||
FBlock::Table(_) => None,
|
||||
});
|
||||
assert_eq!(survivor_bold, Some(false), "survivor must not be inside the bold group: {:?}", f.blocks);
|
||||
// The truncated pair's hex digit goes with the escape: no stray "4" is left in the text.
|
||||
assert!(!texts(&f).iter().any(|t| t.contains('4')), "{:?}", texts(&f));
|
||||
|
||||
// A leading `+` is not a hex digit: `\'+4` is no escape: it stays text, not the byte 0x04.
|
||||
let rtf = br"{\rtf1\ansi \'+4z\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
assert_eq!(texts(&f)[0], "Normal:+4z");
|
||||
|
||||
let rtf = br"{\rtf1\ansi{\'}}after\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
assert!(texts(&f).iter().any(|t| t.contains("after")), "{:?}", texts(&f));
|
||||
|
||||
// A hex digit that starts a valid pair with the byte after it is part of the escape:
|
||||
// \'4} reads '4' as the escape's byte and leaves `}` to close the group.
|
||||
let rtf = br"{\rtf1\ansi\'4}x\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
assert!(texts(&f).iter().any(|t| t.contains('x') && !t.contains('}')), "{:?}", texts(&f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_fallback_uses_the_declared_count() {
|
||||
for (rtf, expected) in [
|
||||
|
||||
@@ -403,7 +403,8 @@ impl Reader {
|
||||
}
|
||||
true
|
||||
}
|
||||
Dest::StyleSheet => {
|
||||
// Style names take Unicode escapes (with fallback skipping) like the body.
|
||||
Dest::StyleSheet if !matches!(word, "u" | "uc") => {
|
||||
let def = &mut self.style_def_cur;
|
||||
match word {
|
||||
"s" | "cs" | "ds" | "ts" => {
|
||||
@@ -420,6 +421,7 @@ impl Reader {
|
||||
}
|
||||
true
|
||||
}
|
||||
Dest::StyleSheet => false,
|
||||
Dest::ListTable => {
|
||||
self.list_table_word(word, value);
|
||||
true
|
||||
|
||||
@@ -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;
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
@@ -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