mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 03:06:56 -06:00
Pages panel: double-clicking a parent shows that parent; parents are identified by id
- layout.parents.edit takes `parent` (id, full name, or a prefix only one parent has) and asks the canvas to fit that parent's spread; turning editing on without one shows the first parent. The result names the parent shown. - Commands resolve parents by id, then full name, then a unique prefix. Unknown or ambiguous names are errors, so a typo no longer clears a page's parent; null or "[None]" clears it. - Pages panel: only the row of the parent the canvas shows is selected; rows, drag payloads and the Apply Parent menu carry the parent id. Parent rows show rendered thumbnails of the parent spread, cached by document revision like page thumbnails. - Delete All Guides on a parent acts on the parent the canvas shows. Fixes #624.
This commit is contained in:
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Pages panel parents) · **Target:** Adobe InDesign 2026
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
|
||||
format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-12** · Pages panel: double-clicking a parent shows that parent and selects only its row; parent rows show rendered thumbnails. Commands name parents by id, full name, or a prefix only one parent has; an unknown or ambiguous name is an error instead of clearing the page's parent (#624). No readiness percentage change.
|
||||
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
|
||||
- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change.
|
||||
|
||||
+126
-18
@@ -82,14 +82,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Pages…",
|
||||
["Layout", "Pages"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{count?: 1, after?: page index (default: last), parent?: \"A\"|null}",
|
||||
"{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize;
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.page_count();
|
||||
let after = p.get("after").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(n - 1);
|
||||
let parent = parent_ref(&st.doc, p.get("parent")).unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
|
||||
let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
|
||||
s.edit(|d, _| {
|
||||
let ids = d.insert_pages(Some(after), count, parent)?;
|
||||
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
|
||||
@@ -155,7 +155,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Parent to Pages…",
|
||||
["Layout", "Pages"],
|
||||
None,
|
||||
"{pages: [index], parent: \"A\"|null}",
|
||||
"{pages: [index], parent: id|\"A-Parent\"|\"A\"|null (null or \"[None]\" clears)}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let pages: Vec<usize> = p
|
||||
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect())
|
||||
.unwrap_or_default();
|
||||
let parent = parent_ref(&s.doc()?.doc, p.get("parent")).unwrap_or(None);
|
||||
let parent = parent_ref("layout.pages.applyParent", &s.doc()?.doc, p.get("parent"))?.unwrap_or(None);
|
||||
s.edit(|d, _| {
|
||||
d.apply_parent(&pages, parent)?;
|
||||
ok()
|
||||
@@ -179,13 +179,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"id": id.0, "prefix": prefix}))
|
||||
})
|
||||
}),
|
||||
cmd!(noundo "layout.parents.edit", "Edit Parents", [], None, "{on: bool} — show parent spreads on the canvas", has_doc, |s, p| {
|
||||
let st = s.doc_mut()?;
|
||||
st.editing_parents = p.get("on").and_then(Value::as_bool).unwrap_or(!st.editing_parents);
|
||||
st.selection = Default::default();
|
||||
st.revision += 1;
|
||||
ok()
|
||||
}),
|
||||
cmd!(
|
||||
noundo "layout.parents.edit",
|
||||
"Edit Parents",
|
||||
[],
|
||||
None,
|
||||
"{on?: bool, parent?: id|\"A-Parent\"|\"A\"} — show parent spreads on the canvas; `parent` shows that parent (and implies on). Turning on without `parent` shows the first parent",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
const ID: &str = "layout.parents.edit";
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let parent = match p.get("parent") {
|
||||
None | Some(Value::Null) => None,
|
||||
v => match parent_ref(ID, &doc, v)? {
|
||||
Some(Some(id)) => Some(id),
|
||||
_ => return Err(bad(ID, "`parent` names a parent to show")),
|
||||
},
|
||||
};
|
||||
let st = s.doc_mut()?;
|
||||
let was = st.editing_parents;
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(parent.is_some() || !was);
|
||||
st.editing_parents = on;
|
||||
st.selection = Default::default();
|
||||
st.revision += 1;
|
||||
// The parent to show: the one asked for, or the first when editing starts.
|
||||
let index = match parent {
|
||||
Some(id) => doc.parent_index(id),
|
||||
None if !was && !doc.parents.is_empty() => Some(0),
|
||||
None => None,
|
||||
};
|
||||
let shown = index.filter(|_| on);
|
||||
if let Some(i) = shown {
|
||||
s.ui_requests.push(crate::UiRequest::View { params: json!({"showSpread": designcraft_doc::SpreadRef::Parent(i)}) });
|
||||
}
|
||||
Ok(json!({"on": on, "parent": shown.and_then(|i| doc.parents.get(i)).map(|x| x.id.0)}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"layout.marginsAndColumns",
|
||||
"Margins and Columns…",
|
||||
@@ -609,13 +638,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// `"A"` → that parent's id; `null` → Some(None) (= [None]); missing → None (use default).
|
||||
fn parent_ref(d: &designcraft_doc::Document, v: Option<&Value>) -> Option<Option<SpreadId>> {
|
||||
match v? {
|
||||
Value::Null => Some(None),
|
||||
Value::String(s) if s == "[None]" => Some(None),
|
||||
Value::String(s) => Some(d.parents.iter().find(|p| p.parent.as_ref().is_some_and(|i| i.prefix == *s || i.label() == *s)).map(|p| p.id)),
|
||||
_ => None,
|
||||
/// A parent named by a command parameter: its id (a number), its full label (`"A-Parent"`), or
|
||||
/// its prefix (`"A"`) when only one parent has it. `null` or `"[None]"` → `Some(None)` (no parent);
|
||||
/// missing → `None` (the command's default). Unknown or ambiguous names are errors, so a typo never
|
||||
/// clears a page's parent.
|
||||
fn parent_ref(cmd: &str, d: &designcraft_doc::Document, v: Option<&Value>) -> Result<Option<Option<SpreadId>>> {
|
||||
let Some(v) = v else { return Ok(None) };
|
||||
match v {
|
||||
Value::Null => Ok(Some(None)),
|
||||
Value::String(s) if s == "[None]" => Ok(Some(None)),
|
||||
Value::Number(n) => {
|
||||
let id = n.as_u64().ok_or_else(|| bad(cmd, format!("no parent with id {n}")))?;
|
||||
d.parents.iter().find(|p| p.id.0 == id).map(|p| Some(Some(p.id))).ok_or_else(|| bad(cmd, format!("no parent with id {id}")))
|
||||
}
|
||||
Value::String(s) => {
|
||||
let by = |f: &dyn Fn(&designcraft_doc::ParentInfo) -> bool| -> Vec<SpreadId> {
|
||||
d.parents.iter().filter(|p| p.parent.as_ref().is_some_and(f)).map(|p| p.id).collect()
|
||||
};
|
||||
let mut hits = by(&|i| i.label() == *s);
|
||||
if hits.is_empty() {
|
||||
hits = by(&|i| i.prefix == *s);
|
||||
}
|
||||
match hits.as_slice() {
|
||||
[id] => Ok(Some(Some(*id))),
|
||||
[] => Err(bad(cmd, format!("no parent named `{s}`"))),
|
||||
_ => Err(bad(cmd, format!("more than one parent is named `{s}`: give its id"))),
|
||||
}
|
||||
}
|
||||
_ => Err(bad(cmd, "`parent` is a parent id, a parent name or null")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -921,3 +971,61 @@ mod adjust_tests {
|
||||
assert_eq!(b, b2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parent_tests {
|
||||
use serde_json::json;
|
||||
|
||||
fn parent_of(s: &crate::Session, page: usize) -> Option<u64> {
|
||||
s.doc().unwrap().doc.page(page).unwrap().parent.map(|p| p.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parents_with_one_prefix_stay_distinct() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2})).unwrap();
|
||||
let a = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Parent"})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Half"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap();
|
||||
assert_eq!(parent_of(&s, 0), Some(b));
|
||||
s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Half"})).unwrap();
|
||||
assert_eq!(parent_of(&s, 1), Some(b));
|
||||
s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Parent"})).unwrap();
|
||||
assert_eq!(parent_of(&s, 1), Some(a));
|
||||
// A prefix two parents share, or a name no parent has, is an error and changes nothing.
|
||||
assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG"})).is_err());
|
||||
assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "Nope"})).is_err());
|
||||
assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": 999_999})).is_err());
|
||||
assert_eq!(parent_of(&s, 1), Some(a));
|
||||
s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "[None]"})).unwrap();
|
||||
assert_eq!(parent_of(&s, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_parents_shows_the_named_parent() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let ids: Vec<u64> =
|
||||
["B", "C", "D"].iter().map(|p| s.execute("layout.parents.new", &json!({"prefix": p})).unwrap()["id"].as_u64().unwrap()).collect();
|
||||
s.ui_requests.clear();
|
||||
let r = s.execute("layout.parents.edit", &json!({"parent": ids[2]})).unwrap();
|
||||
assert_eq!(r, json!({"on": true, "parent": ids[2]}));
|
||||
assert!(s.doc().unwrap().editing_parents);
|
||||
let n = s.doc().unwrap().doc.parent_index(designcraft_doc::SpreadId(ids[2])).unwrap();
|
||||
let shown: Vec<_> = s
|
||||
.ui_requests
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
crate::UiRequest::View { params } => params.get("showSpread").cloned(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(shown, vec![json!({"kind": "parent", "index": n})]);
|
||||
let r = s.execute("layout.parents.edit", &json!({"on": true, "parent": "C-Parent"})).unwrap();
|
||||
assert_eq!(r["parent"], ids[1]);
|
||||
assert!(s.execute("layout.parents.edit", &json!({"parent": "Z-Parent"})).is_err());
|
||||
let r = s.execute("layout.parents.edit", &json!({"on": false})).unwrap();
|
||||
assert_eq!(r, json!({"on": false, "parent": null}));
|
||||
assert!(!s.doc().unwrap().editing_parents);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,14 +114,35 @@ pub fn fit(app: &mut DesignApp, rect: Rect, what: &str) {
|
||||
let Some(u) = sel.into_iter().reduce(|a, b| a.union(b)) else { return };
|
||||
b = u.inflate(u.width().max(u.height()) * 0.05 + 1.0, u.width().max(u.height()) * 0.05 + 1.0);
|
||||
}
|
||||
fit_bounds(app, rect, b);
|
||||
}
|
||||
|
||||
/// Zoom and scroll so canvas rectangle `b` fills the canvas `rect`.
|
||||
fn fit_bounds(app: &mut DesignApp, rect: Rect, b: DRect) {
|
||||
let pad = 40.0;
|
||||
let zoom = ((rect.width() as f64 - 2.0 * pad) / b.width()).min((rect.height() as f64 - 2.0 * pad) / b.height()).clamp(0.05, 40.0);
|
||||
let zoom =
|
||||
((rect.width() as f64 - 2.0 * pad) / b.width().max(1e-6)).min((rect.height() as f64 - 2.0 * pad) / b.height().max(1e-6)).clamp(0.05, 40.0);
|
||||
let origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / zoom, b.center().y - rect.height() as f64 / 2.0 / zoom);
|
||||
if let Some(v) = app.view_mut() {
|
||||
*v = View { zoom, origin, fitted: true, rotation: v.rotation };
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit spread `r` (a document or parent spread on the canvas as it is laid out now) in the canvas.
|
||||
/// Before the canvas has a size, the next frame fits whatever spread is current.
|
||||
pub fn show_spread(app: &mut DesignApp, r: SpreadRef) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let Some(b) = CanvasLayout::new(&st.doc, st.editing_parents).slot(r).map(|s| s.bounds) else { return };
|
||||
match app.canvas_rect {
|
||||
Some(rect) => fit_bounds(app, rect, b),
|
||||
None => {
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.fitted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the slot nearest the view centre.
|
||||
pub fn current_slot(app: &DesignApp, layout: &CanvasLayout) -> Option<usize> {
|
||||
let v = app.view()?;
|
||||
@@ -182,6 +203,9 @@ pub fn set_zoom(app: &mut DesignApp, z: f64) {
|
||||
}
|
||||
|
||||
pub fn apply_view_request(app: &mut DesignApp, p: &Value) {
|
||||
if let Some(r) = p.get("showSpread").and_then(|v| serde_json::from_value::<SpreadRef>(v.clone()).ok()) {
|
||||
show_spread(app, r);
|
||||
}
|
||||
// Power Zoom (Hand tool, Alt-press): zoom out to the spread, aim a view-sized rectangle, zoom
|
||||
// back in where it was released.
|
||||
if let Some(phase) = p.get("powerZoom").and_then(Value::as_str) {
|
||||
|
||||
@@ -979,7 +979,14 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
"app.deleteAllGuides" => {
|
||||
let Some(st) = app.session.active() else { return Some(Err("no document open".into())) };
|
||||
let page = crate::canvas::current_page(app).unwrap_or(0);
|
||||
let spread = if st.editing_parents { json!({"kind": "parent", "index": 0}) } else { json!(st.doc.page_loc(page).map_or(0, |l| l.0)) };
|
||||
let spread = if st.editing_parents {
|
||||
// The parent spread the canvas shows.
|
||||
let layout = designcraft_tools::CanvasLayout::new(&st.doc, true);
|
||||
let shown = crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.spread);
|
||||
json!(shown.unwrap_or(designcraft_doc::SpreadRef::Parent(0)))
|
||||
} else {
|
||||
json!(st.doc.page_loc(page).map_or(0, |l| l.0))
|
||||
};
|
||||
return Some(app.run("guide.deleteAll", json!({"spread": spread})));
|
||||
}
|
||||
"app.deletePage" | "app.duplicateSpread" => {
|
||||
|
||||
@@ -3,38 +3,61 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use designcraft_doc::{SpreadId, SpreadRef};
|
||||
use egui::{Color32, Sense, Stroke, StrokeKind, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{DesignApp, icons};
|
||||
|
||||
type ThumbKey = (u64, u64, usize);
|
||||
/// What a thumbnail shows: a document page (absolute index) or a whole parent spread.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum ThumbOf {
|
||||
Page(usize),
|
||||
Parent(SpreadId),
|
||||
}
|
||||
|
||||
type ThumbKey = (u64, u64, ThumbOf);
|
||||
|
||||
static THUMBS: Mutex<Option<HashMap<ThumbKey, egui::TextureHandle>>> = Mutex::new(None);
|
||||
|
||||
fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option<egui::TextureHandle> {
|
||||
/// Widest parent thumbnail, in pixels (a spread of many pages stays a small texture).
|
||||
const MAX_THUMB_W: f64 = 1024.0;
|
||||
|
||||
fn thumb(app: &mut DesignApp, ctx: &egui::Context, of: ThumbOf, h: f32) -> Option<egui::TextureHandle> {
|
||||
let st = app.session.active()?;
|
||||
let key = (st.uid, st.revision, abs);
|
||||
let key = (st.uid, st.revision, of);
|
||||
let mut g = THUMBS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let map = g.get_or_insert_with(HashMap::new);
|
||||
if let Some(t) = map.get(&key) {
|
||||
return Some(t.clone());
|
||||
}
|
||||
let page = st.doc.page(abs)?;
|
||||
let scale = (h as f64 * ctx.pixels_per_point() as f64) / page.height;
|
||||
let px = h as f64 * ctx.pixels_per_point() as f64;
|
||||
let mut r = designcraft_render::Renderer::new();
|
||||
r.threads = 0;
|
||||
let img = r.render_page(
|
||||
&st.doc,
|
||||
&app.session.cache,
|
||||
abs,
|
||||
scale,
|
||||
false,
|
||||
&designcraft_render::RenderOptions { printing_only: true, ..Default::default() },
|
||||
)?;
|
||||
let opts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() };
|
||||
let (img, name) = match of {
|
||||
ThumbOf::Page(abs) => {
|
||||
let page = st.doc.page(abs)?;
|
||||
let img = r.render_page(&st.doc, &app.session.cache, abs, px / page.height.max(1e-6), false, &opts)?;
|
||||
(img, format!("thumb{abs}"))
|
||||
}
|
||||
ThumbOf::Parent(id) => {
|
||||
let i = st.doc.parent_index(id)?;
|
||||
let b = st.doc.parents.get(i)?.bounds();
|
||||
if !(b.width() > 0.0 && b.height() > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let scale = (px / b.height()).min(MAX_THUMB_W / b.width());
|
||||
let (w, h) = ((b.width() * scale).round().max(1.0) as u32, (b.height() * scale).round().max(1.0) as u32);
|
||||
let view = designcraft_geom::Affine::scale(scale) * designcraft_geom::Affine::translate(-b.origin().to_vec2());
|
||||
let o = designcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..opts };
|
||||
let placed = [designcraft_render::Placed { spread: SpreadRef::Parent(i), xf: designcraft_geom::Affine::IDENTITY }];
|
||||
(r.render(&st.doc, &app.session.cache, &placed, w, h, view, &o), format!("thumbp{}", id.0))
|
||||
}
|
||||
};
|
||||
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||
let tex = ctx.load_texture(format!("thumb{abs}"), ci, egui::TextureOptions::LINEAR);
|
||||
let tex = ctx.load_texture(name, ci, egui::TextureOptions::LINEAR);
|
||||
// Drop thumbnails of older revisions.
|
||||
let (uid, rev) = (st.uid, st.revision);
|
||||
map.retain(|k, _| !(k.0 == uid && k.1 != rev));
|
||||
@@ -47,8 +70,8 @@ fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option
|
||||
enum PageDrag {
|
||||
/// A document page (absolute index).
|
||||
Page(usize),
|
||||
/// A parent (prefix; `None` = [None]).
|
||||
Parent(Option<String>),
|
||||
/// A parent (`None` = [None]).
|
||||
Parent(Option<SpreadId>),
|
||||
}
|
||||
|
||||
/// Selected-page tint (multiplied over the thumbnail).
|
||||
@@ -60,14 +83,41 @@ const THUMB_H: f32 = 37.0;
|
||||
/// Parent rows.
|
||||
const ROW_H: f32 = 29.0;
|
||||
|
||||
/// Paint a blank page icon of `n` pages (parents list), right-aligned at `right`.
|
||||
fn blank_spread(p: &egui::Painter, right: egui::Pos2, n: usize, aspect: f32, t: &Tokens) {
|
||||
let h = 24.0;
|
||||
let w = h * aspect;
|
||||
let n = n.max(1);
|
||||
for k in 0..n {
|
||||
let r = egui::Rect::from_min_size(egui::pos2(right.x - w * (n - k) as f32, right.y - h / 2.0), vec2(w, h));
|
||||
p.rect_filled(r, 0.0, Color32::WHITE);
|
||||
/// Parent icon height.
|
||||
const PARENT_THUMB_H: f32 = 24.0;
|
||||
|
||||
/// Paint a parent's icon right-aligned at `right`: its rendered spread, or blank pages of `aspect`
|
||||
/// for [None] (`parent` = `None`) and when the spread can't be rendered.
|
||||
fn parent_icon(app: &mut DesignApp, ui: &egui::Ui, right: egui::Pos2, parent: Option<(usize, SpreadId)>, aspect: f32, t: &Tokens) {
|
||||
let h = PARENT_THUMB_H;
|
||||
let doc = app.session.active().map(|st| st.doc.clone());
|
||||
let spread = parent.and_then(|(i, _)| doc.as_ref()?.parents.get(i).cloned());
|
||||
// Page outlines in icon coordinates (left, width), from the spread's own pages.
|
||||
let (pages, total): (Vec<(f32, f32)>, f32) = match &spread {
|
||||
Some(sp) if sp.bounds().height() > 0.0 => {
|
||||
let b = sp.bounds();
|
||||
let k = h as f64 / b.height();
|
||||
(sp.pages.iter().map(|p| (((p.x - b.x0) * k) as f32, (p.width * k) as f32)).collect(), (b.width() * k) as f32)
|
||||
}
|
||||
_ => {
|
||||
let n = spread.as_ref().map_or(1, |sp| sp.pages.len().max(1));
|
||||
let w = h * aspect;
|
||||
((0..n).map(|k| (w * k as f32, w)).collect(), w * n as f32)
|
||||
}
|
||||
};
|
||||
let area = egui::Rect::from_min_size(egui::pos2(right.x - total, right.y - h / 2.0), vec2(total, h));
|
||||
let tex = parent.and_then(|(_, id)| thumb(app, ui.ctx(), ThumbOf::Parent(id), h));
|
||||
let p = ui.painter();
|
||||
match tex {
|
||||
Some(tex) => {
|
||||
p.image(tex.id(), area, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
None => {
|
||||
p.rect_filled(area, 0.0, Color32::WHITE);
|
||||
}
|
||||
}
|
||||
for (x, w) in pages {
|
||||
let r = egui::Rect::from_min_size(egui::pos2(area.min.x + x, area.min.y), vec2(w, h));
|
||||
p.rect_stroke(r, 0.0, Stroke::new(0.5, t.border), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
@@ -80,26 +130,38 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let aspect = (doc.settings.page_width / doc.settings.page_height.max(1.0)) as f32;
|
||||
let mut drop: Option<(PageDrag, usize)> = None;
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
// ---- Parents: [None] and each parent, 29 pt rows with a blank thumbnail at the right.
|
||||
let mut rows: Vec<(String, Option<String>, usize)> = vec![("[None]".into(), None, 1)];
|
||||
for p in &doc.parents {
|
||||
// ---- Parents: [None] and each parent, 29 pt rows with the parent's thumbnail at the right.
|
||||
// While parents are edited, the row of the parent the canvas shows is selected.
|
||||
let cur_parent = if editing_parents {
|
||||
let layout = designcraft_tools::CanvasLayout::new(&doc, true);
|
||||
crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).and_then(|s| match s.spread {
|
||||
SpreadRef::Parent(i) => Some(i),
|
||||
SpreadRef::Doc(_) => None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut rows: Vec<(String, Option<(usize, SpreadId)>)> = vec![("[None]".into(), None)];
|
||||
for (i, p) in doc.parents.iter().enumerate() {
|
||||
let label = p.parent.as_ref().map(|i| i.label()).unwrap_or_default();
|
||||
rows.push((label, p.parent.as_ref().map(|i| i.prefix.clone()), p.pages.len()));
|
||||
rows.push((label, Some((i, p.id))));
|
||||
}
|
||||
for (label, prefix, n) in rows {
|
||||
for (label, parent) in rows {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag());
|
||||
let active = editing_parents && prefix.is_some();
|
||||
let active = parent.is_some_and(|(i, _)| cur_parent == Some(i));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &label));
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
ui.painter().text(r.left_center() + vec2(3.0, 0.0), egui::Align2::LEFT_CENTER, &label, egui::FontId::proportional(11.5), t.text_strong);
|
||||
blank_spread(ui.painter(), egui::pos2(r.max.x - 10.0, r.center().y), n, aspect, &t);
|
||||
resp.dnd_set_drag_payload(PageDrag::Parent(prefix.clone()));
|
||||
if resp.double_clicked() && prefix.is_some() {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": true}));
|
||||
app.views.clear();
|
||||
parent_icon(app, ui, egui::pos2(r.max.x - 10.0, r.center().y), parent, aspect, &t);
|
||||
resp.dnd_set_drag_payload(PageDrag::Parent(parent.map(|(_, id)| id)));
|
||||
if resp.double_clicked()
|
||||
&& let Some((_, id)) = parent
|
||||
{
|
||||
let _ = app.run("layout.parents.edit", json!({"on": true, "parent": id.0}));
|
||||
}
|
||||
if resp.dragged() {
|
||||
drag_ghost(ui, &label, &t);
|
||||
@@ -132,7 +194,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let resp = ui.interact(pr, ui.id().with(("page", abs)), Sense::click_and_drag());
|
||||
let selected = abs == cur && !editing_parents;
|
||||
let tint = if selected { SELECTED_TINT } else { Color32::WHITE };
|
||||
if let Some(tex) = thumb(app, ui.ctx(), abs, THUMB_H) {
|
||||
if let Some(tex) = thumb(app, ui.ctx(), ThumbOf::Page(abs), THUMB_H) {
|
||||
ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint);
|
||||
} else {
|
||||
ui.painter().rect_filled(pr, 0.0, tint);
|
||||
@@ -167,7 +229,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
if resp.clicked() || resp.double_clicked() {
|
||||
if editing_parents {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": false}));
|
||||
app.views.clear();
|
||||
// The canvas refits to the page's spread once it is centred.
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.fitted = false;
|
||||
}
|
||||
}
|
||||
crate::canvas::go_to_page(app, abs);
|
||||
}
|
||||
@@ -209,8 +274,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
Some((PageDrag::Page(from), to)) if from != to => {
|
||||
let _ = app.run("layout.pages.move", json!({"from": from, "to": to}));
|
||||
}
|
||||
Some((PageDrag::Parent(prefix), to)) => {
|
||||
let _ = app.run("layout.pages.applyParent", json!({"pages": [to], "parent": prefix}));
|
||||
Some((PageDrag::Parent(parent), to)) => {
|
||||
let _ = app.run("layout.pages.applyParent", json!({"pages": [to], "parent": parent.map(|id| id.0)}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -264,7 +329,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu
|
||||
for pp in &doc.parents {
|
||||
let l = pp.parent.as_ref().map(|i| i.label()).unwrap_or_default();
|
||||
if ui.button(&l).clicked() {
|
||||
let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": pp.parent.as_ref().map(|i| i.prefix.clone())}));
|
||||
let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": pp.id.0}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
@@ -280,3 +345,49 @@ fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) {
|
||||
painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
painter.galley(r.min + vec2(5.0, 3.0), g, t.text_strong);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{Event, Modifiers, PointerButton, vec2};
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::test_window;
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_parent_shows_that_parent() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
for prefix in ["B", "C", "D"] {
|
||||
app.run("layout.parents.new", json!({"prefix": prefix})).unwrap();
|
||||
}
|
||||
app.ui.dock_tab = "pages".into();
|
||||
let mut h = test_window::open(app, vec2(1400.0, 900.0));
|
||||
let names = ["A-Parent", "B-Parent", "C-Parent", "D-Parent"];
|
||||
for name in names {
|
||||
assert_ne!(h.get_by_label(name).accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "{name} is not selected yet");
|
||||
}
|
||||
// Both clicks in one frame: the harness's event queue spaces events too far apart for a
|
||||
// double click.
|
||||
let at = h.get_by_label("C-Parent").rect().center();
|
||||
h.input_mut().events.push(Event::PointerMoved(at));
|
||||
for pressed in [true, false, true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(4);
|
||||
let app = &h.state().app;
|
||||
assert!(app.session.active().unwrap().editing_parents);
|
||||
let st = app.session.active().unwrap();
|
||||
let layout = designcraft_tools::CanvasLayout::new(&st.doc, true);
|
||||
let slot = crate::canvas::current_slot(app, &layout).unwrap();
|
||||
assert_eq!(layout.slots[slot].spread, designcraft_doc::SpreadRef::Parent(2));
|
||||
for name in names {
|
||||
assert_eq!(h.get_by_label(name).accesskit_node().toggled() == Some(egui::accesskit::Toggled::True), name == "C-Parent", "{name}");
|
||||
}
|
||||
// Parent rows show a rendered thumbnail.
|
||||
let id = st.doc.parents[2].id;
|
||||
let rev = (st.uid, st.revision);
|
||||
let thumbs = super::THUMBS.lock().unwrap();
|
||||
assert!(thumbs.as_ref().unwrap().keys().any(|k| (k.0, k.1) == rev && k.2 == super::ThumbOf::Parent(id)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user