mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 03:56:50 -06:00
Merge integration track r1-t02 (dialogs, view, text editing, panes, status bar, other)
ROADMAP.md, docs/gaps.md: union of both tracks' log rows.
This commit is contained in:
@@ -165,6 +165,9 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-18 | Charts: a series' own data-label setting is honoured — numbers PowerPoint shows on a graph no longer disappear, and a bare `c:showVal` means on (#868) |
|
||||
| 2026-10-15 | Render: quick styles render on empty placeholders — the fill gate now checks for style and line overrides too (#832) |
|
||||
| 2026-10-15 | Canvas: the resize cursor follows the handle's rotated diagonal — rotated shapes showed the wrong diagonal cursor (#745) |
|
||||
| 2026-10-20 | Tests: the Options dialog's Cancel, Esc and close button put the settings back the way they were (#878) |
|
||||
| 2026-10-20 | Tests: Ctrl+A in the slide sorter selects every slide, as in PowerPoint (#936) |
|
||||
| 2026-10-18 | Thumbnails: navigating slides with the arrow keys scrolls the thumbnail pane to keep the current slide visible (#917) |
|
||||
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
|
||||
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
|
||||
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
|
||||
@@ -176,6 +179,8 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-15 | Canvas: the Format and Animation Painter override the cursor to show the tool is armed (#830) |
|
||||
| 2026-10-15 | Edit Points: right-click a segment for Straight/Curved Segment, a point for Open/Close Path (`shape.segmentType`, `shape.openPath`, `shape.closePath`); Delete Segment and Add Point in the menu still to come |
|
||||
| 2026-10-15 | Design › Browse for Themes… with a `.pptx` or `.potx` brings its slide master and layouts, not just the theme: the master the current slide uses is replaced (a preserved one stays beside it), the template stretches to the deck's slide size, and each slide moves to the layout with the same name or kind and keeps its content. One undo step (#807) |
|
||||
| 2026-10-15 | Clicking the frame of a text box or placeholder selects the box whole, so it can be moved, duplicated or deleted; only a click inside puts the caret in the text (#787) |
|
||||
| 2026-10-15 | Review › Compare: Reject and Reject All drop revisions without applying them (`review.reject`); Accept All skips what was rejected |
|
||||
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
|
||||
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
|
||||
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
|
||||
@@ -289,6 +294,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
|
||||
| 2026-10-22 | trivial | Progress log: chart series pattern and no fills |
|
||||
| 2026-10-15 | trivial | Progress log: Browse for Themes applies a template's master and layouts (#807) |
|
||||
| 2026-10-15 | trivial | Progress log: Compare Reject and Reject All |
|
||||
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
|
||||
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
|
||||
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>DeckCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="deckcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.deckcraft.png" />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/deckcraft.ico" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#deckcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
@@ -59,3 +59,6 @@ real_name = "Ryan C. Shoemaker"
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -713,7 +713,8 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
|
||||
super::text::edit_text(s, move |body, t| {
|
||||
for pi in a.0..=b.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
||||
let n = para.char_len();
|
||||
let from = if pi == a.0 { a.1 } else { 0 };
|
||||
|
||||
@@ -59,6 +59,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
|
||||
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
|
||||
cmd!(
|
||||
noundo "review.reject",
|
||||
"Reject Revision",
|
||||
["Review", "Compare"],
|
||||
None,
|
||||
"{index} | {all: true} → {rejected} (drop revisions without applying them: the open deck stays as it is; not an undo step)",
|
||||
comparing,
|
||||
reject
|
||||
),
|
||||
cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare),
|
||||
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
|
||||
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
|
||||
@@ -655,7 +664,7 @@ fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
|
||||
fn revisions_json(st: &crate::DocState) -> Result<Value> {
|
||||
let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?;
|
||||
let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone());
|
||||
Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
|
||||
Ok(json!({"name": name, "changes": crate::compare::pending(&st.doc, c), "byPosition": c.by_position}))
|
||||
}
|
||||
|
||||
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -663,7 +672,7 @@ fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let revised = super::file::open_presentation(&name, &bytes)?;
|
||||
let st = s.doc_mut()?;
|
||||
let by_position = crate::compare::unrelated(&st.doc, &revised);
|
||||
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position });
|
||||
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position, rejected: Default::default() });
|
||||
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
|
||||
revisions_json(s.doc()?)
|
||||
}
|
||||
@@ -673,7 +682,8 @@ fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||||
let cmp = s.doc()?.compare.clone().ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||||
let rev = cmp.revised.clone();
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let index = usize_param(p, "index");
|
||||
if !all && index.is_none() {
|
||||
@@ -684,7 +694,7 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Each accept shifts indices, so the list is recomputed; bounded by the slide counts.
|
||||
let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1);
|
||||
for _ in 0..limit {
|
||||
let changes = crate::compare::diff(doc, &rev, by_pos);
|
||||
let changes = crate::compare::pending(doc, &cmp);
|
||||
let c = if all {
|
||||
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
|
||||
} else {
|
||||
@@ -707,6 +717,24 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn reject(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let index = usize_param(p, "index");
|
||||
if !all && index.is_none() {
|
||||
return Err(bad("review.reject", "give `index` or `all`"));
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
let doc = st.doc.clone();
|
||||
let c = st.compare.as_mut().ok_or_else(|| bad("review.reject", "not comparing"))?;
|
||||
let pending = crate::compare::pending(&doc, c);
|
||||
let picked: Vec<_> = if all { pending } else { pending.into_iter().nth(index.unwrap_or(0)).into_iter().collect() };
|
||||
if picked.is_empty() {
|
||||
return Err(bad("review.reject", "no such revision"));
|
||||
}
|
||||
c.rejected.extend(picked.iter().map(|ch| crate::compare::ChangeKey::of(&doc, ch)));
|
||||
Ok(json!({"rejected": picked.len()}))
|
||||
}
|
||||
|
||||
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.doc_mut()?.compare = None;
|
||||
ok()
|
||||
|
||||
@@ -333,7 +333,12 @@ fn decode_text(raw: &str) -> String {
|
||||
|
||||
fn numeric_char(digits: &str) -> Option<char> {
|
||||
let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X'));
|
||||
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
|
||||
// `parse` and `from_str_radix` accept a leading '+', which is not a valid reference.
|
||||
let (body, radix) = hex.map_or((digits, 10), |h| (h, 16));
|
||||
if body.is_empty() || !body.chars().all(|c| c.is_digit(radix)) {
|
||||
return None;
|
||||
}
|
||||
let n = u32::from_str_radix(body, radix).ok()?;
|
||||
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
|
||||
}
|
||||
|
||||
@@ -352,6 +357,13 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signed_numeric_entities_are_kept_as_typed() {
|
||||
assert_eq!(decode_text("a&#+65;b"), "a&#+65;b");
|
||||
assert_eq!(decode_text("a&#x+41;b"), "a&#x+41;b");
|
||||
assert_eq!(decode_text("aAbA"), "aAbA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn google_docs_normal_weight_wrapper_is_not_bold() {
|
||||
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
|
||||
|
||||
@@ -417,7 +417,8 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
|
||||
let c = ed::insert(body, at, &text, props);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
for pi in at.0..=c.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in at.0..=c.0.min(last) {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
@@ -784,7 +785,8 @@ pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result<Value> {
|
||||
let c = ed::insert_body(body, at, src);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
for pi in at.0..=c.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in at.0..=c.0.min(last) {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
@@ -1124,7 +1126,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => match to.as_str() {
|
||||
"up" => (caret.0.saturating_sub(1), caret.1),
|
||||
"down" => ((caret.0 + 1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
|
||||
"down" => (caret.0.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
|
||||
"lineStart" => (caret.0, 0),
|
||||
_ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
||||
},
|
||||
@@ -1189,9 +1191,11 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
||||
let at = pos_param(p, "at").unwrap_or(t.caret);
|
||||
let body = body_of(st, &t);
|
||||
// Clamp to the last paragraph so a huge index can neither overflow nor select past the body.
|
||||
let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1);
|
||||
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
|
||||
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
|
||||
let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) };
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (at.0, 0);
|
||||
@@ -1371,7 +1375,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Default::default());
|
||||
}
|
||||
for pi in a.0..=b.0 {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
if let Some(p) = body.paragraphs.get_mut(pi) {
|
||||
f(p);
|
||||
}
|
||||
@@ -1519,3 +1524,23 @@ mod replace_font_tests {
|
||||
assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod select_paragraph_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn select_paragraph_huge_at() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
|
||||
s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "hello"})).unwrap();
|
||||
s.execute("text.edit", &json!({})).unwrap();
|
||||
let r = s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
// Clamped to the last (only) paragraph, never past the end of the body.
|
||||
assert_eq!((t.anchor, t.caret), ((0, 0), (0, 5)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Review › Compare: the differences between the open presentation and a revised copy of it, slide
|
||||
//! by slide, and accepting a revised slide into the open one.
|
||||
//! by slide, and accepting a revised slide into the open one or rejecting it.
|
||||
//!
|
||||
//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no
|
||||
//! slide id at all, by position. Shapes match by id, then by name.
|
||||
@@ -18,6 +18,34 @@ pub struct Compare {
|
||||
/// Slides match by position (the decks shared no slide id when the compare started); fixed
|
||||
/// for the session so accepting slides doesn't switch the matching half way.
|
||||
pub by_position: bool,
|
||||
/// Revisions rejected: no longer listed and never accepted, the open deck left as it is.
|
||||
pub rejected: HashSet<ChangeKey>,
|
||||
}
|
||||
|
||||
/// What a revision is about, stable while slides move: the open slide (for changed and deleted)
|
||||
/// and the revised slide (for changed and inserted).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ChangeKey {
|
||||
kind: ChangeKind,
|
||||
open: Option<SlideId>,
|
||||
revised: Option<usize>,
|
||||
}
|
||||
|
||||
impl ChangeKey {
|
||||
pub fn of(doc: &Presentation, change: &Change) -> ChangeKey {
|
||||
let open = match change.kind {
|
||||
ChangeKind::Inserted => None,
|
||||
ChangeKind::Changed | ChangeKind::Deleted => doc.slides.get(change.slide).map(|s| s.id),
|
||||
};
|
||||
ChangeKey { kind: change.kind, open, revised: change.revised }
|
||||
}
|
||||
}
|
||||
|
||||
/// The revisions still to review: [`diff`] without the rejected ones.
|
||||
pub fn pending(doc: &Presentation, c: &Compare) -> Vec<Change> {
|
||||
let mut out = diff(doc, &c.revised, c.by_position);
|
||||
out.retain(|ch| !c.rejected.contains(&ChangeKey::of(doc, ch)));
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `cur` and `rev` share no slide id (then slides match by position).
|
||||
@@ -26,7 +54,7 @@ pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool {
|
||||
!cur.slides.iter().any(|s| ids.contains(&s.id))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChangeKind {
|
||||
/// Only in the revised deck.
|
||||
@@ -434,6 +462,50 @@ mod tests {
|
||||
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_keeps_the_open_deck_and_accept_all_skips_rejected_revisions() {
|
||||
let (mut s, rev, n) = decks();
|
||||
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
|
||||
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
let ins = before.iter().position(|c| c["kind"] == "inserted").unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("review.reject", &json!({"index": ins})).unwrap()["rejected"], 1);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc), "rejecting leaves the open deck as it is");
|
||||
let left = s.execute("review.revisions", &json!({})).unwrap();
|
||||
assert_eq!(kinds(&left).len(), before.len() - 1);
|
||||
assert!(!kinds(&left).contains(&"inserted".into()), "{left}");
|
||||
// Accept All takes the rest, never the rejected slide.
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.slides.len(), n - 1, "the deleted slide goes, the rejected insertion never comes");
|
||||
assert!(!st.doc.slides.iter().any(|x| super::slide_title(x) == "Brand new"));
|
||||
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "rejecting is not an undo step");
|
||||
// Reject All: nothing left to review, the deck as it was; Accept All then has nothing to take.
|
||||
assert_eq!(s.execute("review.reject", &json!({"all": true})).unwrap()["rejected"], before.len() - 1);
|
||||
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
|
||||
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
|
||||
assert!(s.execute("review.reject", &json!({"all": true})).is_err(), "nothing left to reject");
|
||||
assert!(s.execute("review.reject", &json!({})).is_err());
|
||||
s.execute("review.endCompare", &json!({})).unwrap();
|
||||
assert!(s.execute("review.reject", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejecting_one_revision_keeps_the_others_in_order() {
|
||||
let (mut s, rev, _) = decks();
|
||||
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
|
||||
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
assert!(s.execute("review.reject", &json!({"index": before.len()})).is_err());
|
||||
s.execute("review.reject", &json!({"index": 0})).unwrap();
|
||||
let left = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
assert_eq!(left.as_slice(), &before[1..], "the rejected one goes, the others stay in order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_decks_match_by_position_and_bad_input_fails() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -3259,6 +3259,70 @@ fn dragging_the_frame_of_edited_text_moves_the_box() {
|
||||
assert_eq!(text_box_xfrm(&s, id), x);
|
||||
}
|
||||
|
||||
/// Press and release at `at` without moving.
|
||||
fn click_at(s: &mut Session, at: (f64, f64)) {
|
||||
let ev = |kind| PointerEvent { kind, x: at.0, y: at.1, mods: Mods::default(), tol: 3.0, pressure: None };
|
||||
s.pointer(ev(PointerKind::Down)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_the_frame_of_a_text_box_selects_it_whole() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
let sid = deckcraft_model::ShapeId(id as u32);
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
// Leave editing and select nothing: a click on empty slide.
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
assert!(s.doc().unwrap().selection.shapes.is_empty());
|
||||
|
||||
// Edges and corner bands, inside and just outside the box, select it but don't start typing.
|
||||
for (lx, ly) in [(x.w / 4.0, 1.0), (x.w / 4.0, -2.0), (x.w * 0.75, x.h - 1.0), (1.0, x.h / 2.0), (x.w + 2.0, x.h / 2.0)] {
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
click_at(&mut s, on_box(&x, lx, ly));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.shapes, vec![sid], "({lx}, {ly})");
|
||||
assert!(st.selection.text.is_none(), "({lx}, {ly}) entered text");
|
||||
}
|
||||
|
||||
// Dragging the frame moves the box; the inside of an unselected box still takes the caret.
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
let from = on_box(&x, x.w / 4.0, 1.0);
|
||||
drag_gesture(&mut s, from, (from.0 + 30.0, from.1 + 20.0));
|
||||
let moved = text_box_xfrm(&s, id);
|
||||
assert!((moved.x - (x.x + 30.0)).abs() < 1e-9 && (moved.y - (x.y + 20.0)).abs() < 1e-9, "{moved:?}");
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
click_at(&mut s, on_box(&moved, moved.w / 4.0, moved.h / 2.0));
|
||||
assert!(s.doc().unwrap().selection.text.is_some());
|
||||
|
||||
// The frame also deletes: select by frame, press Delete.
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
click_at(&mut s, on_box(&moved, moved.w / 4.0, 1.0));
|
||||
s.execute("edit.delete", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().shape(sid).is_none());
|
||||
assert!(s.doc().unwrap().history.undo.len() > undo_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_the_frame_of_a_selected_plain_shape_still_takes_the_caret() {
|
||||
let mut s = session();
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id =
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 150.0, 240.0, 60.0], "text": "Hello"})).unwrap()["id"].as_u64().unwrap();
|
||||
let sid = deckcraft_model::ShapeId(id as u32);
|
||||
let x = text_box_xfrm(&s, id);
|
||||
// The first click selects an ordinary shape, the second puts the caret, as before; the frame
|
||||
// rule is for text boxes and placeholders only.
|
||||
click_at(&mut s, (20.0, 20.0));
|
||||
click_at(&mut s, on_box(&x, x.w / 4.0, x.h / 2.0));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.shapes, vec![sid]);
|
||||
assert!(st.selection.text.is_none());
|
||||
click_at(&mut s, on_box(&x, x.w / 4.0, 1.0));
|
||||
assert!(s.doc().unwrap().selection.text.is_some(), "frame click on a selected plain shape lost the caret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_inside_edited_text_selects_text() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
@@ -4054,6 +4118,32 @@ fn table_insert_below_or_right_of_a_huge_index_appends_at_the_end() {
|
||||
assert_eq!(t.cell(0, 1).unwrap().text.text(), "b", "the original columns keep their place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_commands_clamp_a_hostile_text_selection() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [u64::MAX, 0]})).unwrap();
|
||||
s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]})).unwrap();
|
||||
s.execute("format.alignCenter", &json!({})).unwrap();
|
||||
s.execute("format.case", &json!({"mode": "upper"})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "A"})).unwrap();
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert!(t.caret.0 < 1_000, "the caret stayed inside the body: {t:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_commands_clamp_a_direct_hostile_text_selection() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [u64::MAX, 0]})).unwrap();
|
||||
s.execute("format.alignCenter", &json!({})).unwrap();
|
||||
s.execute("format.case", &json!({"mode": "upper"})).unwrap();
|
||||
let body = s.doc().unwrap().shape(deckcraft_model::ShapeId(title)).unwrap().text.as_ref().unwrap();
|
||||
assert_eq!(body.paragraphs[0].props.align, Some(deckcraft_model::text::Align::Center));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_column_width_of_a_huge_column_is_ignored() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -697,7 +697,12 @@ impl Session {
|
||||
// placeholders take the caret straight away like PowerPoint. A click in a selected
|
||||
// table puts the caret in the clicked cell.
|
||||
let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_)));
|
||||
let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
|
||||
// A click on the frame band of a text box selects the box whole, as PowerPoint
|
||||
// does, so it can be moved, duplicated or deleted; only the inside takes the caret.
|
||||
let on_frame = text
|
||||
&& is_text_first(st, id)
|
||||
&& st.shape(id).is_some_and(|s| edit_zone(&xfrm_of(&st.doc, &st.selection, s), p, tol) == EditZone::Frame);
|
||||
let enter_text = (!toggle && !on_frame && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
|
||||
let ids2 = ids.clone();
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = ids2;
|
||||
|
||||
@@ -389,6 +389,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(page_bg)).show(ui, |ui| {
|
||||
// Fields, boxes and buttons sit on the page's own colour: outline them, as PowerPoint does.
|
||||
ui.visuals_mut().text_edit_bg_color = Some(t.field_bg);
|
||||
let w = &mut ui.visuals_mut().widgets;
|
||||
w.inactive.bg_fill = t.field_bg;
|
||||
w.inactive.weak_bg_fill = t.field_bg;
|
||||
|
||||
@@ -561,3 +561,20 @@ fn print_slides_follow_the_settings() {
|
||||
bs.print_layout = "slides";
|
||||
assert_eq!(print_page_size(&bs, &doc), (doc.slide_size.width as f32, doc.slide_size.height as f32), "slides print slide-shaped");
|
||||
}
|
||||
|
||||
/// Options in the File tab opens the Preferences dialog, and Cancel closes it (#782, #752).
|
||||
#[test]
|
||||
fn options_in_backstage_opens_preferences_and_cancel_closes_it() {
|
||||
let mut h = harness(new_app());
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().backstage_open());
|
||||
h.get_by_label("Options").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_some(), "Options opens Preferences dialog");
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
|
||||
h.query_all_by_role_and_label(Role::Button, "Cancel").next().expect("Cancel button").click_accesskit();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none(), "Cancel closes Preferences dialog");
|
||||
assert!(h.state().backstage_open(), "Backstage remains open");
|
||||
}
|
||||
|
||||
@@ -50,6 +50,18 @@ fn pane_opens_lists_changes_and_accept_all_applies_them() {
|
||||
assert!(h.state().session.active().unwrap().compare.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_all_empties_the_pane_and_keeps_the_deck() {
|
||||
let (mut h, n) = harness();
|
||||
let doc = h.state().session.active().unwrap().doc.clone();
|
||||
h.get_by_label_contains("Reject All").click();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "nothing applied");
|
||||
assert_eq!(st.doc.slides.len(), n);
|
||||
assert!(h.query_by_label("No differences left.").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "writes a screenshot for review"]
|
||||
fn screenshot() {
|
||||
|
||||
@@ -2199,8 +2199,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
_ => about(app, ui),
|
||||
}
|
||||
let (ok, _) = buttons(ui, "Close");
|
||||
ok
|
||||
let (ok, cancel) = buttons(ui, "Close");
|
||||
ok || cancel
|
||||
}
|
||||
"autocorrect" => {
|
||||
let pr = &mut app.session.prefs;
|
||||
@@ -3886,3 +3886,7 @@ mod about_credits_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "preferences_tests.rs"]
|
||||
mod preferences_tests;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Text fields must show where they are: in the light theme the field was as white as the dialog,
|
||||
//! pane or ribbon it sits on and had no outline, so only its label (or hint) was visible (#1204).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::accesskit::Role;
|
||||
use egui::epaint::Shape;
|
||||
use egui::{Color32, Rect, Stroke};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp, theme};
|
||||
|
||||
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
// The app keeps asking for frames, so step instead of run().
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// The largest per-channel difference between two colours.
|
||||
fn distance(a: Color32, b: Color32) -> u8 {
|
||||
a.r().abs_diff(b.r()).max(a.g().abs_diff(b.g())).max(a.b().abs_diff(b.b()))
|
||||
}
|
||||
|
||||
/// The frame egui painted for the text field at `field` (its fill and outline).
|
||||
fn frame_at(h: &Harness<'_, SlideApp>, field: Rect) -> (Color32, Stroke) {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|c| match &c.shape {
|
||||
Shape::Rect(r) if r.rect.min.distance(field.min) < 2.0 && r.rect.max.distance(field.max) < 2.0 => Some((r.fill, r.stroke)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no frame painted for the field at {field:?}"))
|
||||
}
|
||||
|
||||
/// What `c` shows when painted over the opaque `surface`. `Color32` is premultiplied, so each
|
||||
/// channel is `c + surface * (255 - alpha) / 255`.
|
||||
fn over(c: Color32, surface: Color32) -> Color32 {
|
||||
let behind = |s: u8| (u16::from(s) * u16::from(255 - c.a()) / 255) as u8;
|
||||
Color32::from_rgb(c.r().saturating_add(behind(surface.r())), c.g().saturating_add(behind(surface.g())), c.b().saturating_add(behind(surface.b())))
|
||||
}
|
||||
|
||||
/// A frame with this `fill` and `stroke` stands apart from the opaque `surface`: it paints a fill
|
||||
/// or an outline (at least 1 px wide) that, as actually shown over the surface, differs from it.
|
||||
fn stands_apart(fill: Color32, stroke: Stroke, surface: Color32) -> bool {
|
||||
let outlined = stroke.width >= 1.0 && distance(over(stroke.color, surface), surface) >= 24;
|
||||
outlined || distance(over(fill, surface), surface) >= 16
|
||||
}
|
||||
|
||||
/// The field stands apart from `surface` by its fill or by its outline.
|
||||
fn assert_visible(h: &Harness<'_, SlideApp>, what: &str, surface: Color32) {
|
||||
let fields: Vec<Rect> = h.query_all_by_role(Role::TextInput).map(|n| n.rect()).collect();
|
||||
assert!(!fields.is_empty(), "{what}: no text field");
|
||||
for field in fields {
|
||||
let (fill, stroke) = frame_at(h, field);
|
||||
assert!(
|
||||
stands_apart(fill, stroke, surface),
|
||||
"{what}: the field at {field:?} (fill {fill:?}, outline {stroke:?}) can't be told from the {surface:?} behind it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_painted_fill_or_outline_stands_apart() {
|
||||
let surface = Color32::WHITE;
|
||||
// Stroke::NONE is transparent black: nothing is painted, whatever its RGB.
|
||||
assert!(!stands_apart(surface, Stroke::NONE, surface));
|
||||
assert!(!stands_apart(Color32::WHITE, Stroke::NONE, Color32::from_rgb(250, 250, 250)));
|
||||
// A fully transparent fill shows the surface.
|
||||
assert!(!stands_apart(Color32::TRANSPARENT, Stroke::NONE, surface));
|
||||
assert!(!stands_apart(Color32::TRANSPARENT, Stroke::new(0.5, Color32::BLACK), surface));
|
||||
// A transparent outline paints nothing either.
|
||||
assert!(!stands_apart(surface, Stroke::new(1.0, Color32::TRANSPARENT), surface));
|
||||
// A real 1 px dark outline, or a clearly different opaque fill, does.
|
||||
assert!(stands_apart(surface, Stroke::new(1.0, Color32::from_gray(120)), surface));
|
||||
assert!(stands_apart(Color32::from_gray(200), Stroke::NONE, surface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_text_fields_show_in_dialogs_panes_and_the_ribbon() {
|
||||
let light = theme::Tokens::light();
|
||||
|
||||
// Home › Find: "Find what:" in the dialog card.
|
||||
let mut h = harness(SlideApp::new(Session::with_new(), Services::default()));
|
||||
h.state_mut().run("app.find", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_visible(&h, "Find and Replace", h.ctx.global_style().visuals.window_fill);
|
||||
// Still visible with the keyboard in it.
|
||||
let field = h.get_by_role(Role::TextInput).rect();
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.memory(|m| m.focused().is_some()), "the click puts the keyboard in the field");
|
||||
let (fill, stroke) = frame_at(&h, field);
|
||||
// Same surface as the dialog check above: the field sits on the dialog's window fill.
|
||||
let surface = h.ctx.global_style().visuals.window_fill;
|
||||
assert!(stands_apart(fill, stroke, surface), "focused field: fill {fill:?}, outline {stroke:?}");
|
||||
|
||||
// Review › Thesaurus: the search box on the task pane.
|
||||
let mut h = harness(SlideApp::new(Session::with_new(), Services::default()));
|
||||
h.state_mut().ui.pane = Some("thesaurus".into());
|
||||
h.run_steps(3);
|
||||
assert_visible(&h, "Thesaurus pane", light.card);
|
||||
|
||||
// Transitions › Duration: the spinner on the ribbon.
|
||||
let mut app = SlideApp::new(Session::with_new(), Services::default());
|
||||
app.ui.tab = "Transitions".into();
|
||||
let h = harness(app);
|
||||
assert_visible(&h, "Transitions ribbon", light.card);
|
||||
}
|
||||
@@ -244,6 +244,10 @@ pub struct UiState {
|
||||
pub notes: bool,
|
||||
pub notes_height: f32,
|
||||
pub thumbs_width: f32,
|
||||
/// Vertical scroll offset of the thumbnail pane, recorded each frame for the control
|
||||
/// channel and tests; not persisted.
|
||||
#[serde(skip)]
|
||||
pub thumbs_scroll: f32,
|
||||
/// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`,
|
||||
/// `smartart` (Text Pane).
|
||||
pub pane: Option<String>,
|
||||
@@ -286,6 +290,7 @@ impl Default for UiState {
|
||||
notes: true,
|
||||
notes_height: 52.0,
|
||||
thumbs_width: 236.0,
|
||||
thumbs_scroll: 0.0,
|
||||
pane: None,
|
||||
pane_width: 300.0,
|
||||
ruler: false,
|
||||
@@ -1728,7 +1733,20 @@ impl SlideApp {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if ctx.memory(|m| m.focused().is_some()) || self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some() {
|
||||
let modal = self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some();
|
||||
// While slide text is being edited, the text owns the keyboard (#93). Tab and the arrow
|
||||
// keys move egui's keyboard focus onto ordinary widgets (the window's drag handle, ribbon
|
||||
// buttons), and that stray focus would swallow every later key and end text entry. Let it
|
||||
// go; a real text field or an open menu keeps its keys.
|
||||
if !modal
|
||||
&& self.session.active().is_some_and(|d| d.selection.text.is_some())
|
||||
&& !ctx.text_edit_focused()
|
||||
&& !ctx.any_popup_open()
|
||||
&& let Some(id) = ctx.memory(|m| m.focused())
|
||||
{
|
||||
ctx.memory_mut(|m| m.surrender_focus(id));
|
||||
}
|
||||
if ctx.memory(|m| m.focused().is_some()) || modal {
|
||||
return;
|
||||
}
|
||||
if self.backstage.is_some() {
|
||||
@@ -2098,6 +2116,8 @@ mod compare_tests;
|
||||
#[cfg(test)]
|
||||
mod eyedropper_tests;
|
||||
#[cfg(test)]
|
||||
mod field_frame_tests;
|
||||
#[cfg(test)]
|
||||
mod find_dialog_tests;
|
||||
#[cfg(test)]
|
||||
mod huge_deck_tests;
|
||||
@@ -2569,6 +2589,39 @@ mod tests {
|
||||
assert_eq!(level(&harness), 1);
|
||||
}
|
||||
|
||||
/// #93: Tab in an edited list indented the item but handed the keyboard to egui's focus
|
||||
/// cycle: the stray focus (e.g. on the window's drag handle) swallowed every later key, so the
|
||||
/// list could neither be typed into nor continued with Enter.
|
||||
#[test]
|
||||
fn tab_in_an_edited_list_keeps_the_keyboard() {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
harness.event(egui::Event::Text("one".into()));
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Tab); // Increase List Level
|
||||
harness.run_steps(2);
|
||||
harness.event(egui::Event::Text("sub".into()));
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter); // continues the list
|
||||
harness.run_steps(2);
|
||||
harness.event(egui::Event::Text("two".into()));
|
||||
harness.run_steps(2);
|
||||
assert_eq!(title_text(harness.state()), "one\nsub\ntwo", "typing after Tab must reach the text");
|
||||
let body = harness.state().session.active().unwrap().doc.slides[0].shapes[0].text.clone().unwrap();
|
||||
let levels: Vec<u8> = body.paragraphs.iter().map(|p| p.level).collect();
|
||||
assert_eq!(levels, vec![0, 1, 1], "Tab indents one level; Enter keeps the level");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_needs_an_open_presentation() {
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
@@ -2749,6 +2802,41 @@ mod tests {
|
||||
assert_ne!(s.session.prefs.snap_to_grid, before.snap_to_grid, "Done keeps snap to grid");
|
||||
}
|
||||
|
||||
/// About dialog: Close and Cancel each close the dialog.
|
||||
#[test]
|
||||
fn about_dialog_close_and_cancel_close_it() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
harness.state_mut().run("app.about", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_some());
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel")
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no Cancel button in About dialog"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none(), "Cancel closes About dialog");
|
||||
|
||||
harness.state_mut().run("app.about", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_some());
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Close")
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no Close button in About dialog"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none(), "Close closes About dialog");
|
||||
}
|
||||
|
||||
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
|
||||
@@ -1189,7 +1189,8 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let _ = paint_anim_tile;
|
||||
}
|
||||
|
||||
/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it.
|
||||
/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it,
|
||||
/// Reject to drop it.
|
||||
fn revisions(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Ok(v) = app.session.execute("review.revisions", &json!({})) else {
|
||||
@@ -1210,9 +1211,14 @@ fn revisions(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ui.button(format!("Accept All ({})", changes.len())).clicked() {
|
||||
run(app, "review.accept", json!({"all": true}));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(format!("Accept All ({})", changes.len())).clicked() {
|
||||
run(app, "review.accept", json!({"all": true}));
|
||||
}
|
||||
if ui.button(format!("Reject All ({})", changes.len())).clicked() {
|
||||
run(app, "review.reject", json!({"all": true}));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
for (i, c) in changes.iter().enumerate() {
|
||||
let slide = c["slide"].as_u64().unwrap_or(0);
|
||||
@@ -1231,6 +1237,9 @@ fn revisions(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "slide.go", json!({"index": (slide as usize).min(max)}));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("Reject").clicked() {
|
||||
run(app, "review.reject", json!({"index": i}));
|
||||
}
|
||||
if ui.small_button("Accept").clicked() {
|
||||
run(app, "review.accept", json!({"index": i}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The Preferences (Options) dialog: controls apply as they change and Cancel, Esc and the
|
||||
//! window's close button put the settings back the way they were (#752, reported as #878).
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use egui_kittest::{Harness, Node};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::theme::Brightness;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// The slider itself, not the label drawn beside it.
|
||||
fn interface_size<'a>(h: &'a Harness<'_, SlideApp>) -> Node<'a> {
|
||||
h.get_all_by_label("Interface size").find(|n| n.accesskit_node().role() == egui::accesskit::Role::Slider).expect("the slider is there")
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
// The app keeps asking for frames, so step instead of run().
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn open_options(h: &mut Harness<'_, SlideApp>) {
|
||||
h.state_mut().run("app.preferences", json!({})).expect("open Options");
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn dark(app: &SlideApp) -> bool {
|
||||
app.ui.brightness == Brightness::Dark
|
||||
}
|
||||
|
||||
/// A checkbox applies as it is toggled and Cancel puts the old value back.
|
||||
#[test]
|
||||
fn options_cancel_undoes_the_changes() {
|
||||
let mut h = harness();
|
||||
let was = h.state().session.prefs.smart_guides;
|
||||
open_options(&mut h);
|
||||
|
||||
h.query_by_label("Show smart guides").expect("the checkbox is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, !was, "the change applies as it is made");
|
||||
|
||||
h.query_by_label("Cancel").expect("the Cancel button is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, was, "Cancel restores the setting");
|
||||
assert!(h.state().dialog.is_none(), "and the dialog closes");
|
||||
}
|
||||
|
||||
/// Every Editing setting and the name field go back on Cancel, not only the first checkbox: a
|
||||
/// reporter who changed a different control and saw Cancel do nothing has the same answer.
|
||||
#[test]
|
||||
fn options_cancel_undoes_every_editing_setting() {
|
||||
let mut h = harness();
|
||||
let before = h.state().session.prefs.clone();
|
||||
open_options(&mut h);
|
||||
|
||||
for label in ["Snap objects to grid", "Replace straight quotes with smart quotes", "AutoCorrect as you type"] {
|
||||
h.query_by_label(label).unwrap_or_else(|| panic!("the {label} checkbox is there")).click();
|
||||
h.run_steps(1);
|
||||
}
|
||||
// The name field: it writes into the settings as it is typed.
|
||||
// The name field is the last text input of the dialog (the label is a separate widget).
|
||||
let name = h.get_all_by_role(egui::accesskit::Role::TextInput).last().expect("the name field is there");
|
||||
name.click();
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::Text("Z".into()));
|
||||
h.run_steps(1);
|
||||
let changed = h.state().session.prefs.clone();
|
||||
assert_ne!(
|
||||
(changed.snap_to_grid, changed.smart_quotes, changed.autocorrect),
|
||||
(before.snap_to_grid, before.smart_quotes, before.autocorrect),
|
||||
"the settings apply as they are changed"
|
||||
);
|
||||
assert_ne!(changed.author, before.author, "the name applies as it is typed: {:?}", changed.author);
|
||||
|
||||
h.query_by_label("Cancel").expect("the Cancel button is there").click();
|
||||
h.run_steps(2);
|
||||
let after = h.state().session.prefs.clone();
|
||||
assert_eq!(after.snap_to_grid, before.snap_to_grid, "snap to grid is restored");
|
||||
assert_eq!(after.smart_quotes, before.smart_quotes, "smart quotes are restored");
|
||||
assert_eq!(after.autocorrect, before.autocorrect, "autocorrect is restored");
|
||||
assert_eq!(after.author, before.author, "the name is restored");
|
||||
}
|
||||
|
||||
/// The appearance toggle and the interface size go back on Cancel, too; Esc acts as Cancel.
|
||||
#[test]
|
||||
fn options_cancel_puts_the_appearance_and_scale_back() {
|
||||
let mut h = harness();
|
||||
let (dark_before, scale_before) = (dark(h.state()), h.state().ui.ui_scale);
|
||||
open_options(&mut h);
|
||||
|
||||
h.query_by_label("Dark appearance").expect("the checkbox is there").click();
|
||||
h.run_steps(2);
|
||||
assert!(dark(h.state()), "the appearance applies as it is toggled");
|
||||
|
||||
// Clicking the slider's middle sets it near the middle of its range, away from the default.
|
||||
interface_size(&h).click();
|
||||
h.run_steps(2);
|
||||
let scale_in_dialog = h.state().ui.ui_scale;
|
||||
assert!((scale_in_dialog - scale_before).abs() > 1e-3, "the slider applied a new size");
|
||||
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert_eq!(dark(h.state()), dark_before, "Esc restores the appearance");
|
||||
assert!((h.state().ui.ui_scale - scale_before).abs() < 1e-3, "Esc restores the size");
|
||||
assert!(h.state().dialog.is_none(), "and the dialog closes");
|
||||
}
|
||||
|
||||
/// The window's close button is the same as Cancel: the settings go back.
|
||||
#[test]
|
||||
fn options_close_button_restores_the_settings() {
|
||||
let mut h = harness();
|
||||
let was = h.state().session.prefs.smart_guides;
|
||||
open_options(&mut h);
|
||||
|
||||
h.query_by_label("Show smart guides").expect("the checkbox is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, !was, "the change applies as it is made");
|
||||
|
||||
h.get_all_by_label("Close window").next().expect("the window's close button is there").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prefs.smart_guides, was, "the close button restores the setting");
|
||||
assert!(h.state().dialog.is_none(), "and the dialog closes");
|
||||
}
|
||||
@@ -15,7 +15,11 @@ fn harness() -> Harness<'static, SlideApp> {
|
||||
s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap();
|
||||
s.execute("format.size", &json!({"size": 40})).unwrap();
|
||||
let app = SlideApp::new(s, Services::default());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
// Home's ribbon is wider than it was when this test was written: at 1400 the Editing group
|
||||
// begins at x=1398, so the Replace Fonts button is two pixels wide on screen and a click at
|
||||
// its centre lands outside the window. The ribbon does not scroll sideways, so the window has
|
||||
// to be wide enough to hold it -- 1800 is what the other full-ribbon tests here use.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
|
||||
@@ -169,3 +169,13 @@ fn dragging_a_section_moves_it_and_keeps_it_collapsed() {
|
||||
let (beta, alpha) = (h.get_by_label("Beta").rect(), h.get_by_label("Alpha").rect());
|
||||
assert!(beta.top() < alpha.top());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_a_selects_every_slide() {
|
||||
let mut h = harness(false);
|
||||
h.key_combination_modifiers(egui::Modifiers::COMMAND, &[egui::Key::A]);
|
||||
h.run_steps(2);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let all = st.doc.slides.len();
|
||||
assert_eq!(st.selection.slides.len(), all, "Ctrl+A in the sorter selects every slide");
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{SlideApp, ViewMode};
|
||||
|
||||
/// The status bar: slide counter, proofing language and status message on the left; notes,
|
||||
/// comments, views and zoom on the right.
|
||||
pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::bottom("status").exact_size(28.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
|
||||
@@ -19,22 +21,31 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
_ => "Slide Master".to_string(),
|
||||
};
|
||||
let p = ui.painter();
|
||||
p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim);
|
||||
// Left to right: slide counter, language, status message. Each starts at its usual column, or
|
||||
// after the one before it when that runs long ("Slide 100 of 120", "… (not checked)").
|
||||
let sr = p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim);
|
||||
// Where the controls on the right begin; nothing on the left paints or clicks past it.
|
||||
let right_x = rect.max.x - 560.0;
|
||||
// The selection's proofing language; a click opens Review › Language.
|
||||
let (lang, no_proof) = crate::dialogs::selected_language(app);
|
||||
let name = crate::squiggles::lang_name(&lang) + if no_proof { " (not checked)" } else { "" };
|
||||
let lr = p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim);
|
||||
if ui.interact(lr.expand(3.0), ui.id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() {
|
||||
let lx = (rect.min.x + 120.0).max(sr.max.x + 16.0);
|
||||
let lclip = Rect::from_min_max(pos2(lx - 3.0, rect.min.y), pos2(right_x.max(lx - 3.0), rect.max.y));
|
||||
let lr = p.with_clip_rect(lclip).text(pos2(lx, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim);
|
||||
let hit = lr.expand(3.0).intersect(lclip);
|
||||
if hit.is_positive() && ui.interact(hit, ui.id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("language"));
|
||||
}
|
||||
// Transient status message.
|
||||
// Transient status message, clipped before the controls on the right.
|
||||
if let Some(msg) = app.fresh_status() {
|
||||
p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);
|
||||
let mx = (rect.min.x + 290.0).max(lr.max.x + 16.0);
|
||||
let clip = Rect::from_min_max(pos2(mx, rect.min.y), pos2(right_x.max(mx), rect.max.y));
|
||||
p.with_clip_rect(clip).text(pos2(mx, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
let mut right = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(rect.max.x - 560.0, rect.min.y), pos2(rect.max.x - 8.0, rect.max.y)))
|
||||
.max_rect(Rect::from_min_max(pos2(right_x, rect.min.y), pos2(rect.max.x - 8.0, rect.max.y)))
|
||||
.layout(egui::Layout::right_to_left(egui::Align::Center)),
|
||||
);
|
||||
right.spacing_mut().item_spacing.x = 4.0;
|
||||
@@ -248,3 +259,44 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
app.notes_buf.2 = draft;
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// A long proofing language in a narrow window must not take clicks meant for the controls on
|
||||
/// the right: Notes toggles the notes pane and the Language dialog stays closed.
|
||||
#[test]
|
||||
fn a_long_language_name_leaves_the_notes_button_clickable() {
|
||||
let mut app = SlideApp::new(Session::with_new(), Services::default());
|
||||
app.session.execute("shape.insert", &json!({"preset": "rect", "rect": [80, 120, 400, 100], "text": "hello"})).unwrap();
|
||||
app.session.execute("format.language", &json!({"lang": "en-US-x-a-very-long-private-use-tag"})).unwrap();
|
||||
let size = egui::vec2(700.0, 800.0);
|
||||
let mut h = Harness::builder().with_size(size).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().ui.notes);
|
||||
// The Notes button is the rightmost control, 8 points in from the right edge.
|
||||
let at = egui::pos2(size.x - 8.0 - 20.0, size.y - 14.0);
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert!(h.state().ui.notes, "the click reached the Notes button");
|
||||
assert!(h.state().dialog.is_none(), "the Language dialog stayed closed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct Tokens {
|
||||
/// The ribbon card and popovers.
|
||||
pub card: Color32,
|
||||
pub card_border: Color32,
|
||||
/// Text fields. They have no outline, so the fill alone must set them apart from the card.
|
||||
pub text_field: Color32,
|
||||
/// Canvas around the slide.
|
||||
pub canvas: Color32,
|
||||
pub text: Color32,
|
||||
@@ -41,6 +43,12 @@ pub struct Tokens {
|
||||
pub caret: Color32,
|
||||
pub text_selection: Color32,
|
||||
pub field_bg: Color32,
|
||||
/// Unchecked checkbox and radio boxes and slider rails, drawn without an outline, so they
|
||||
/// must stand apart from the card and the chrome.
|
||||
pub control: Color32,
|
||||
/// [`Tokens::control`] under the pointer and pressed: a step darker each in light mode.
|
||||
pub control_hover: Color32,
|
||||
pub control_pressed: Color32,
|
||||
pub shadow: Color32,
|
||||
}
|
||||
|
||||
@@ -51,6 +59,7 @@ impl Tokens {
|
||||
chrome: Color32::from_rgb(0xF5, 0xF5, 0xF5),
|
||||
card: Color32::WHITE,
|
||||
card_border: Color32::from_rgb(0xE3, 0xE3, 0xE3),
|
||||
text_field: Color32::from_rgb(0xEB, 0xEB, 0xEB),
|
||||
canvas: Color32::from_rgb(0xF0, 0xF0, 0xF0),
|
||||
text: Color32::from_rgb(0x24, 0x24, 0x24),
|
||||
text_dim: Color32::from_rgb(0x5C, 0x5C, 0x5C),
|
||||
@@ -70,6 +79,9 @@ impl Tokens {
|
||||
caret: Color32::BLACK,
|
||||
text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90),
|
||||
field_bg: Color32::WHITE,
|
||||
control: Color32::from_rgb(0xD6, 0xD6, 0xD6),
|
||||
control_hover: Color32::from_rgb(0xC8, 0xC8, 0xC8),
|
||||
control_pressed: Color32::from_rgb(0xBC, 0xBC, 0xBC),
|
||||
shadow: Color32::from_black_alpha(28),
|
||||
}
|
||||
}
|
||||
@@ -79,6 +91,7 @@ impl Tokens {
|
||||
chrome: Color32::from_rgb(0x26, 0x26, 0x26),
|
||||
card: Color32::from_rgb(0x33, 0x33, 0x33),
|
||||
card_border: Color32::from_rgb(0x44, 0x44, 0x44),
|
||||
text_field: Color32::from_rgb(0x2A, 0x2A, 0x2A),
|
||||
canvas: Color32::from_rgb(0x1E, 0x1E, 0x1E),
|
||||
text: Color32::from_rgb(0xEE, 0xEE, 0xEE),
|
||||
text_dim: Color32::from_rgb(0xBB, 0xBB, 0xBB),
|
||||
@@ -98,6 +111,9 @@ impl Tokens {
|
||||
caret: Color32::BLACK,
|
||||
text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90),
|
||||
field_bg: Color32::from_rgb(0x2A, 0x2A, 0x2A),
|
||||
control: Color32::from_rgb(0x2A, 0x2A, 0x2A),
|
||||
control_hover: Color32::from_rgb(0x40, 0x40, 0x40),
|
||||
control_pressed: Color32::from_rgb(0x4C, 0x4C, 0x4C),
|
||||
shadow: Color32::from_black_alpha(90),
|
||||
}
|
||||
}
|
||||
@@ -118,6 +134,7 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
v.panel_fill = t.chrome;
|
||||
v.window_fill = t.card;
|
||||
v.extreme_bg_color = t.field_bg;
|
||||
v.text_edit_bg_color = Some(t.text_field);
|
||||
v.faint_bg_color = t.hover;
|
||||
v.window_stroke = Stroke::new(1.0, t.card_border);
|
||||
v.window_corner_radius = CornerRadius::same(10);
|
||||
@@ -131,13 +148,13 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
}
|
||||
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.separator);
|
||||
v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
|
||||
v.widgets.inactive.bg_fill = t.field_bg;
|
||||
v.widgets.inactive.bg_fill = t.control;
|
||||
v.widgets.inactive.bg_stroke = Stroke::NONE;
|
||||
v.widgets.hovered.weak_bg_fill = t.hover;
|
||||
v.widgets.hovered.bg_fill = t.hover;
|
||||
v.widgets.hovered.bg_fill = t.control_hover;
|
||||
v.widgets.hovered.bg_stroke = Stroke::NONE;
|
||||
v.widgets.active.weak_bg_fill = t.pressed;
|
||||
v.widgets.active.bg_fill = t.pressed;
|
||||
v.widgets.active.bg_fill = t.control_pressed;
|
||||
v.widgets.open.weak_bg_fill = t.pressed;
|
||||
v.window_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 18, spread: 0, color: t.shadow };
|
||||
v.popup_shadow = egui::epaint::Shadow { offset: [0, 3], blur: 12, spread: 0, color: t.shadow };
|
||||
@@ -257,6 +274,24 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_checkboxes_and_slider_rails_stand_out_from_their_background() {
|
||||
// An unchecked checkbox and a slider rail are filled with the inactive widget fill and
|
||||
// have no outline, so in light mode they vanished into the white dialog (#766).
|
||||
let ctx = egui::Context::default();
|
||||
super::apply(&ctx, &super::Tokens::light());
|
||||
let v = ctx.global_style().visuals.clone();
|
||||
assert_eq!(v.widgets.inactive.bg_stroke, egui::Stroke::NONE, "the test assumes no outline");
|
||||
let fill = v.widgets.inactive.bg_fill;
|
||||
for (name, bg) in [("dialog", v.window_fill), ("panel", v.panel_fill)] {
|
||||
let diff = [fill.r().abs_diff(bg.r()), fill.g().abs_diff(bg.g()), fill.b().abs_diff(bg.b())];
|
||||
assert!(diff.iter().all(|d| *d >= 24), "control fill {fill:?} is too close to the {name} background {bg:?}");
|
||||
}
|
||||
// Hovering and pressing darken the control a step each, as they do buttons.
|
||||
let (hovered, active) = (v.widgets.hovered.bg_fill, v.widgets.active.bg_fill);
|
||||
assert!(fill.r() > hovered.r() && hovered.r() > active.r(), "idle {fill:?}, hovered {hovered:?}, active {active:?} don't darken in turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_punctuation_is_drawn_in_interface_text() {
|
||||
// En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face,
|
||||
|
||||
@@ -26,11 +26,13 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
master_list(app, ui);
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
let out = egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.add_space(8.0);
|
||||
list(app, ui);
|
||||
ui.add_space(20.0);
|
||||
});
|
||||
// Where the pane is scrolled to, for the control channel and tests (#917).
|
||||
app.ui.thumbs_scroll = out.state.offset.y;
|
||||
});
|
||||
// Focus follows the last press: inside the pane keeps it, anywhere else (canvas, ribbon) drops it.
|
||||
if let Some(pos) = ui.input(|i| if i.pointer.primary_pressed() { i.pointer.interact_pos() } else { None }) {
|
||||
@@ -47,6 +49,16 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let width = ui.available_width();
|
||||
let tw = (width - 40.0).max(60.0);
|
||||
let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32;
|
||||
// Scroll the current slide into view when it changes, so arrow-key navigation follows
|
||||
// along in the pane (#917). The scroll itself is minimal, only when the thumbnail leaves
|
||||
// the view, as in PowerPoint, so the user's own scrolling stands; the first frame only
|
||||
// records the slide.
|
||||
let scroll_key = ui.id().with("thumb_scrolled_to");
|
||||
let slide_changed = ui.data_mut(|d| {
|
||||
let last: Option<usize> = d.get_temp(scroll_key);
|
||||
d.insert_temp(scroll_key, cur);
|
||||
last.is_some_and(|last| last != cur)
|
||||
});
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mut budget = 3u32;
|
||||
let mut rects = vec![];
|
||||
@@ -170,6 +182,10 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if need_more {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// Scroll the current slide's thumbnail into view when the slide changes (#917).
|
||||
if slide_changed && let Some((_, row_rect)) = rects.iter().find(|(i, _)| *i == cur) {
|
||||
ui.scroll_to_rect(*row_rect, None);
|
||||
}
|
||||
// Drag reorder: insertion line.
|
||||
if let Some((from, _)) = app.thumb_drag {
|
||||
if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) {
|
||||
@@ -327,3 +343,58 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use egui_kittest::Harness;
|
||||
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use deckcraft_engine::Session;
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// Vertical scroll offset of the thumbnail pane, as recorded each frame by `show`.
|
||||
fn thumbs_scroll(h: &Harness<'_, SlideApp>) -> f32 {
|
||||
h.state().ui.thumbs_scroll
|
||||
}
|
||||
|
||||
/// #917: navigating to a slide below the fold scrolls the pane to reveal it; a neighbour
|
||||
/// that is already on screen does not move the pane.
|
||||
#[test]
|
||||
fn navigating_slides_keeps_the_current_thumbnail_visible() {
|
||||
let mut h = harness();
|
||||
for _ in 0..30 {
|
||||
h.state_mut().session.execute("slide.new", &json!({})).unwrap();
|
||||
}
|
||||
// slide.new selects each new slide; back to the first so the pane starts at the top.
|
||||
h.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
h.run_steps(3);
|
||||
let last = h.state().session.active().unwrap().doc.slides.len() - 1;
|
||||
assert_eq!(thumbs_scroll(&h), 0.0, "the pane starts at the top");
|
||||
|
||||
// Jump to the last slide: the pane follows and reveals it.
|
||||
h.state_mut().session.execute("slide.go", &json!({"index": last})).unwrap();
|
||||
h.run_steps(120); // the scroll eases in over several frames
|
||||
let jumped = thumbs_scroll(&h);
|
||||
assert!(jumped > 100.0, "the pane scrolled to the last slide, offset {jumped}");
|
||||
|
||||
// One slide up was already on screen after that scroll: the pane holds still.
|
||||
h.state_mut().session.execute("slide.go", &json!({"index": last - 1})).unwrap();
|
||||
h.run_steps(120);
|
||||
let held = thumbs_scroll(&h);
|
||||
assert!((held - jumped).abs() < 1.0, "no re-scroll for a visible slide: {jumped} -> {held}");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -277,7 +277,7 @@ Zoom: live thumbnails, the zoom-in animation and the native `p166:sldZm` element
|
||||
artistic effects (3–6 h for the other 15 of 22; 7 approximated and round-tripped), remove
|
||||
background (6–10 h; needs a model or a classical matte), insert screenshot (2–3 h), record
|
||||
audio/screen and record slide show (8–12 h), compare presentations beyond slide-level accept
|
||||
(reject, per-object accept, markers on the slide: 3–5 h), password protection (4–6 h, ECMA-376
|
||||
and reject (per-object accept and reject, markers on the slide: 2–4 h), password protection (4–6 h, ECMA-376
|
||||
agile encryption), ink to text and math beyond Ink to Shape (6–10 h), 3-D models and stock content
|
||||
(6–10 h; Insert › Icons places a Lucide stroke set).
|
||||
|
||||
@@ -366,6 +366,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-15 | trivial | PPTX trigger sequences keep later On Click steps instead of converting them to After Previous; mixed starts, independent buttons and paragraph builds have synthetic round-trip regressions |
|
||||
| 2026-10-16 | trivial | AutoFormat superscripts ordinals (1st, 22nd) as you type, with an AutoCorrect option to turn it off; automatic bulleted lists remain |
|
||||
| 2026-10-15 | minor | Edit Points: Straight/Curved Segment on a right-clicked segment and Open/Close Path on a point (`shape.segmentType`, `shape.openPath`, `shape.closePath`); left: Delete Segment and Add Point in the menu |
|
||||
| 2026-10-15 | trivial | Compare: slide-level Reject and Reject All landed; the entry keeps per-object accept and reject and the slide markers |
|
||||
| 2026-10-14 | trivial | Fixed #718: remote MCP no longer accepts unrelated or buffered stale success; loopback regressions cover invalid envelopes, incomplete and oversized frames |
|
||||
| 2026-10-14 | minor | Community PR wave folded: one entry each for the soak tests (engine and UI), the PPTX corpus (seven owned corpus files), edit points / text warp / 3-D, proofing (Hunspell spelling, thesaurus, AutoCorrect), the smaller missing rows, dialogs (41 vs ~60; Print and Insert Object left) and pen/trackpad input; ranked rows 3, 4 and 10 de-duplicated (comment threads #70 and the pane timeline are done); the fixed command-palette entry (#79) removed |
|
||||
| 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain |
|
||||
|
||||
@@ -94,6 +94,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Text caret, selection, double/triple click, drag-select | yes | yes |
|
||||
| Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time |
|
||||
| Drag the frame of a text box being edited to move it | yes | yes |
|
||||
| Click the frame of a text box or placeholder to select it whole (then move, duplicate or delete it) | yes | yes; a click inside takes the caret |
|
||||
| Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells |
|
||||
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first |
|
||||
| Group fill and outline | formats the members | Shape Fill and Shape Outline reach nested members in one undo step; a transparency-only fill edit retains each member's colour (#1079) |
|
||||
@@ -200,6 +201,7 @@ consistency is open (#73, #56).
|
||||
| 2026-10-12 | minor | Dragging the frame of a text box being edited moves it, as in PowerPoint |
|
||||
| 2026-10-12 | minor | Table cells: caret and selection shown in the edited cell, clicks place the caret in the clicked cell, row and column borders drag to resize |
|
||||
| 2026-10-14 | minor | Format Background pane: solid transparency, picture transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture. Not a new dialog; the dialog count stays 27 |
|
||||
| 2026-10-15 | minor | Clicking the frame of a text box or placeholder selects the box whole instead of starting text entry (#787) |
|
||||
| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) |
|
||||
| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) with other apps |
|
||||
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide on the editor canvas |
|
||||
|
||||
Reference in New Issue
Block a user