Merge #591: Double-click a frame handle to fit the frame to its content; Fit Frame to Content fits text frames

Conflict: crates/engine/src/cmd/object.rs: object.fit's doc keeps next's text and adds the `axis` clause; test modules from both sides
Conflict: crates/ui-egui/src/canvas.rs: kept next's input code (it sends the second press as Down with clicks: 2) and dropped the PR's release-time DoubleClick; the PR's UI test stays
Conflict: crates/tools/src/select.rs: the handle double-click joins next's second-press preamble, after the port checks and before editing text and the frame/content toggle (Down or DoubleClick with click_count 2 on a handle runs object.fit along fitting_axis); the DoubleClick arm stays empty; module doc from both
Conflict: crates/tools/src/tests.rs, ROADMAP.md: both sides
This commit is contained in:
aiongg
2026-10-12 18:56:35 +00:00
5 changed files with 358 additions and 4 deletions
+1
View File
@@ -162,6 +162,7 @@ Newest first.
groups (#255): spinners, fill/stroke chips, proportion links, corners, Auto-Fit, alignment.
- **2026-10-12** · A gradient feather on a group fades the group as one, on screen and in PDF. Before, the screen ignored it, and PDF masked the group with a box of zero size because a group has no path of its own. No readiness percentage change.
- **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change.
- **2026-10-12** · Double-clicking a frame handle fits the frame to its content as in InDesign (a corner fits both sides, an edge only its own). Object › Fitting › Fit Frame to Content now fits text frames to their text too, growing them for overset text. No readiness percentage change.
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
- **2026-10-12** · Load Swatches refuses an `.ase` file with more than 100,000 colors, and `swatch.load` merges a file of 100,000 colors in under 0.1 s (it took 9.6 s of CPU in a test build). No readiness percentage change.
- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change.
+240 -3
View File
@@ -568,7 +568,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fitting",
["Object", "Fitting"],
None,
"{mode: fillProportionally (preserves aspect ratio, covers frame, clips overflow)|fitProportionally (preserves aspect ratio, shows whole image, may leave empty space)|fitContentToFrame (stretches content to frame)|centerContent (keeps current size, positions at fitting alignment)|fitFrameToContent (resizes frame to content), ids?}",
"{mode: fillProportionally (preserves aspect ratio, covers frame, clips overflow)|fitProportionally (preserves aspect ratio, shows whole image, may leave empty space)|fitContentToFrame (stretches content to frame)|centerContent (keeps current size, positions at fitting alignment)|fitFrameToContent (resizes frame to content), axis?: both|height|width (frame-local sides Fit Frame to Content moves; default both), ids?}",
has_selection,
fit
),
@@ -2367,9 +2367,32 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fit(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let doc = &s.doc()?.doc;
let ids: Vec<_> =
targets(s, p)?.into_iter().filter(|id| doc.item(*id).is_some_and(|it| !it.locked && doc.layer(it.layer).is_none_or(|l| !l.locked))).collect();
let mode = str_param(p, "mode").unwrap_or("fitProportionally").to_string();
let (height, width) = match str_param(p, "axis").unwrap_or("both") {
"height" => (true, false),
"width" => (false, true),
"both" => (true, true),
other => return Err(bad("object.fit", format!("unknown axis `{other}`"))),
};
// Text frames: Fit Frame to Content fits the frame to its composed text.
let mut text_fits = Vec::new();
if mode == "fitFrameToContent" {
let d = s.doc()?.doc.clone();
for id in &ids {
if let Some(r) = fitted_text_frame(&d, *id, height, width)? {
text_fits.push((*id, r));
}
}
}
s.edit(|d, _| {
for (id, r) in &text_fits {
if let Some(it) = d.item_mut(*id) {
it.path = shapes::rectangle(*r);
}
}
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
let r = it.inner_bounds();
@@ -2377,7 +2400,12 @@ fn fit(s: &mut Session, p: &Value) -> Result<Value> {
let (nw, nh) = g.size;
let cur = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh));
if mode == "fitFrameToContent" {
it.path = shapes::rectangle(cur);
it.path = shapes::rectangle(Rect::new(
if width { cur.x0 } else { r.x0 },
if height { cur.y0 } else { r.y0 },
if width { cur.x1 } else { r.x1 },
if height { cur.y1 } else { r.y1 },
));
continue;
}
let m = fitting_mode(&mode)
@@ -2391,6 +2419,91 @@ fn fit(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// The inner rectangle that fits text frame `id` to its text (Fit Frame to Content): the smallest
/// fitting height and the right side at the longest line's end (`width`, one column only),
/// including insets; the top-left stays. Overset text grows the frame first. None for
/// frames that aren't horizontal text frames, have no text, or are threaded on to another frame.
fn fitted_text_frame(d: &Document, id: ItemId, height: bool, width: bool) -> Result<Option<Rect>> {
let Some(it) = d.item(id) else { return Ok(None) };
let Some(tf) = it.text_frame() else { return Ok(None) };
if d.frame_vertical(it) || tf.options.path.is_some() {
return Ok(None);
}
let Some(story) = d.story(tf.story) else { return Ok(None) };
// The last frame of its thread only: an earlier frame's text runs on.
if story.frames.last() != Some(&id) {
return Ok(None);
}
let r = it.inner_bounds();
if !(r.width().is_finite() && r.height().is_finite() && r.width() > 0.0 && r.height() > 0.0) {
return Err(bad("object.fit", "text frame bounds must be finite and nonempty"));
}
// Keep trial layouts out of the session cache. Search with the actual columns and frame
// options: measuring a very tall frame collapses columns and includes vertical alignment's
// extra space. A fitting trial must compose all the text, regardless of its justification.
let cache = designcraft_compose::Cache::new();
let mut trial = d.clone();
let mut compose = |rect| {
if let Some(it) = trial.item_mut(id) {
it.path = shapes::rectangle(rect);
}
cache.get(&trial, tf.story, None)
};
let initial = compose(r);
if initial.frame(id).is_none_or(|ft| ft.lines.is_empty()) && !initial.is_overset() {
return Ok(None);
}
let mut out = r;
// First fit the height at the existing width, then measure the complete text's width.
// Recheck height after fitting width, since a small width change can change a line break.
if height {
out = fit_text_height(out, &mut compose)?;
}
if width && tf.options.columns <= 1 {
let cs = compose(out);
if let Some(ft) = cs.frame(id) {
let right = ft.lines.iter().map(|l| l.end_x).fold(f64::NEG_INFINITY, f64::max);
if right.is_finite() {
out.x1 = (right + tf.options.inset[3] + 0.001).max(r.x0 + 1.0);
}
}
if height {
out = fit_text_height(out, &mut compose)?;
}
}
Ok(Some(out))
}
fn fit_text_height(mut rect: Rect, compose: &mut impl FnMut(Rect) -> Arc<designcraft_compose::ComposedStory>) -> Result<Rect> {
let mut low = 1.0;
let limit = 20_000.0;
let mut high = rect.height().clamp(1.0, limit);
rect.y1 = rect.y0 + high;
while compose(rect).is_overset() {
if high >= limit {
return Err(bad("object.fit", "text does not fit within the 20,000 pt fitting limit"));
}
low = high;
high = (high * 2.0).min(limit);
rect.y1 = rect.y0 + high;
}
// Bounded search, always retaining a verified fitting upper bound.
for _ in 0..32 {
if high - low < 0.001 {
break;
}
let mid = low + (high - low) / 2.0;
rect.y1 = rect.y0 + mid;
if compose(rect).is_overset() {
low = mid;
} else {
high = mid;
}
}
rect.y1 = rect.y0 + high;
Ok(rect)
}
pub(crate) trait RemoveKeep {
fn remove_item_keep_story(&mut self, id: ItemId) -> Result<Item>;
}
@@ -3917,3 +4030,127 @@ mod content_transform_tests {
assert!(s.execute("transform.set", &json!({"ids": [e], "content": true, "rotation": 10})).is_err());
}
}
#[cfg(test)]
mod fit_text_frame_tests {
use super::*;
fn frame(s: &mut Session, rect: [f64; 4], text: &str) -> ItemId {
let id = ItemId(s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap()["id"].as_u64().unwrap());
s.execute("selection.set", &json!({"ids": [id.0]})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
id
}
fn bounds(s: &Session, id: ItemId) -> Rect {
s.doc().unwrap().doc.item(id).unwrap().bounds()
}
#[test]
fn fit_frame_to_content_fits_a_text_frame_to_its_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = frame(&mut s, [100.0, 100.0, 500.0, 300.0], "INDESIGN");
// The height only (a top or bottom handle): the width stays.
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "height", "ids": [id.0]})).unwrap();
let h = bounds(&s, id);
assert!(h.x1 == 500.0 && h.y0 == 100.0 && h.height() > 30.0 && h.height() < 45.0, "{h:?}");
// Both (a corner handle, or the menu): around the line.
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "ids": [id.0]})).unwrap();
let b = bounds(&s, id);
assert!(b.x0 == 100.0 && b.width() > 100.0 && b.width() < 250.0, "{b:?}");
let d = s.doc().unwrap().doc.clone();
let sid = d.item(id).unwrap().text_frame().unwrap().story;
assert!(!s.cache.get(&d, sid, None).is_overset(), "still fits");
}
#[test]
fn fit_frame_to_content_grows_a_frame_with_overset_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = frame(&mut s, [100.0, 100.0, 300.0, 140.0], "one two three four five six seven eight nine ten eleven twelve");
let d = s.doc().unwrap().doc.clone();
let sid = d.item(id).unwrap().text_frame().unwrap().story;
assert!(s.cache.get(&d, sid, None).is_overset());
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "height", "ids": [id.0]})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(!s.cache.get(&d, sid, None).is_overset(), "{:?}", bounds(&s, id));
assert!(bounds(&s, id).height() > 100.0);
assert!(s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "diagonal", "ids": [id.0]})).is_err());
}
#[test]
fn fitting_text_keeps_columns_and_vertical_justification() {
use designcraft_doc::VerticalJustification::{Bottom, Center, Justify, Top};
for columns in [1, 2] {
for justification in [Top, Bottom, Center, Justify] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = frame(&mut s, [100.0, 100.0, 500.0, 500.0], "one two three four five six seven eight nine ten eleven twelve");
let options = &mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(id).unwrap().text_frame_mut().unwrap().options;
options.columns = columns;
options.vertical_justification = justification;
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "height"})).unwrap();
let d = &s.doc().unwrap().doc;
let it = d.item(id).unwrap();
let tf = it.text_frame().unwrap();
let composed = s.cache.get(d, tf.story, None);
assert!(!composed.is_overset(), "{columns} {justification:?}");
assert!(it.bounds().height() < 250.0, "{columns} {justification:?}: {:?}", it.bounds());
assert_eq!(tf.options.vertical_justification, justification);
assert_eq!(tf.options.columns, columns);
if columns == 2 {
let ft = composed.frame(id).unwrap();
assert!(ft.lines.iter().any(|l| l.column == 1), "both columns stay in use: {ft:?}");
}
}
}
}
#[test]
fn graphic_fitting_obeys_each_axis_and_locked_text_and_graphics_stay_put() {
for graphic in [false, true] {
for lock_layer in [false, true] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = frame(&mut s, [100.0, 100.0, 500.0, 300.0], "Fit me");
if graphic {
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "test.png", "frame": id.0})).unwrap();
let it = Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(id).unwrap();
let Content::Graphic(g) = &mut it.content else { panic!("graphic") };
g.xf = Affine::translate((80.0, 90.0)) * Affine::scale(3.0);
let original = it.inner_bounds();
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "width", "ids": [id.0]})).unwrap();
let fitted = s.doc().unwrap().doc.item(id).unwrap().inner_bounds();
assert_eq!(fitted, Rect::new(80.0, original.y0, 200.0, original.y1));
Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(id).unwrap().path = shapes::rectangle(original);
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "height", "ids": [id.0]})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(id).unwrap().inner_bounds(), Rect::new(original.x0, 90.0, original.x1, 150.0));
Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(id).unwrap().path = shapes::rectangle(original);
}
let before = bounds(&s, id);
if lock_layer {
let layer = s.doc().unwrap().doc.item(id).unwrap().layer;
Arc::make_mut(&mut s.doc_mut().unwrap().doc).layer_mut(layer).unwrap().locked = true;
} else {
Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(id).unwrap().locked = true;
}
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "ids": [id.0]})).unwrap();
assert_eq!(bounds(&s, id), before, "graphic={graphic}, layer={lock_layer}");
}
}
}
#[test]
fn an_unfittable_frame_returns_an_error_without_changing_any_target() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = frame(&mut s, [100.0, 100.0, 500.0, 300.0], "Fits");
let b = frame(&mut s, [100.0, 400.0, 500.0, 600.0], "Cannot fit");
Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(b).unwrap().text_frame_mut().unwrap().options.first_baseline_min = 30_000.0;
let before = [bounds(&s, a), bounds(&s, b)];
assert!(s.execute("object.fit", &json!({"mode": "fitFrameToContent", "ids": [a.0, b.0]})).is_err());
assert_eq!([bounds(&s, a), bounds(&s, b)], before);
}
}
+32 -1
View File
@@ -2,7 +2,8 @@
//!
//! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains),
//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool.
//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool and
//! double-clicking a handle fits the frame to its content (corner: both sides, edge: that one).
//! A click on a selected text frame's in or out port loads the text cursor (see [`crate::thread`]).
//!
//! Content (a placed graphic, or items pasted into a frame) moves inside its frame: the Direct
@@ -294,6 +295,26 @@ fn handle_at(cx: &ToolContext, p: Point) -> Option<usize> {
handles(b).iter().position(|h| (h.x - p.x).abs() <= tol && (h.y - p.y).abs() <= tol)
}
/// Side handles are drawn on the canvas bounding box; fitting uses the frame's local axes.
fn fitting_axis(cx: &ToolContext, handle: usize) -> &'static str {
if handle.is_multiple_of(2) {
return "both";
}
let canvas_height = matches!(handle, 1 | 5);
let local_width_is_vertical = cx
.selection
.items
.first()
.and_then(|id| {
let it = cx.doc.item(*id)?;
let xf = cx.item_canvas_xf(*id)? * it.xf;
let [a, b, _, _, _, _] = xf.as_coeffs();
Some(b.abs() > a.abs())
})
.unwrap_or(false);
if canvas_height == local_width_is_vertical { "width" } else { "height" }
}
/// Just outside a corner handle of the selection → rotate.
fn rotate_zone(cx: &ToolContext, p: Point) -> Option<Point> {
let b = cx.selection_bounds()?;
@@ -540,6 +561,16 @@ impl Tool for SelectionTool {
{
return actions;
}
// The second press of a double click on a selection handle: Fit Frame to Content, along
// that handle's side (a corner fits both).
if !self.direct
&& matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick)
&& ev.click_count() == 2
&& let Some(h) = handle_at(cx, p)
{
self.drag = Drag::None;
return vec![Action::Exec("object.fit".into(), json!({"mode": "fitFrameToContent", "axis": fitting_axis(cx, h)}))];
}
// The second press of a double click on a text frame: edit its text.
if matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick)
&& ev.click_count() == 2
+42
View File
@@ -1457,3 +1457,45 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() {
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y));
assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))]);
}
#[test]
fn double_clicking_a_handle_fits_the_frame_to_its_content_along_that_side() {
let mut d = Document::new(&NewDocument::default());
let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 200.0));
let s = Selection { items: vec![id], ..Default::default() };
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
let off = l.xf(SpreadRef::Doc(0)).translation();
let cx = ctx(&d, &s, &c, &l);
let mut t = create("selection");
for ((x, y), axis) in [((300.0, 200.0), "both"), ((200.0, 200.0), "height"), ((300.0, 150.0), "width")] {
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x + off.x, y + off.y));
assert_eq!(a, vec![Action::Exec("object.fit".into(), serde_json::json!({"mode": "fitFrameToContent", "axis": axis}))], "{axis}");
}
}
#[test]
fn fitting_handle_axes_follow_spread_and_frame_rotation() {
for spread_turn in 0..4 {
for frame_turn in 0..4 {
let mut d = Document::new(&NewDocument::default());
let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 200.0));
d.item_mut(id).unwrap().xf = designcraft_geom::Affine::rotate(f64::from(frame_turn) * std::f64::consts::FRAC_PI_2);
Arc::make_mut(&mut d.spreads[0]).pages[0].view_rotation = spread_turn;
let s = Selection { items: vec![id], ..Default::default() };
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
let cx = ctx(&d, &s, &c, &l);
let b = cx.selection_bounds().unwrap();
let mut t = create("selection");
let swapped = (spread_turn + frame_turn) % 2 == 1;
for (index, axis) in [(1, if swapped { "width" } else { "height" }), (3, if swapped { "height" } else { "width" }), (4, "both")] {
let p = select::handles(b)[index];
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, p.x, p.y));
assert_eq!(
a,
vec![Action::Exec("object.fit".into(), serde_json::json!({"mode": "fitFrameToContent", "axis": axis}))],
"spread={spread_turn}, frame={frame_turn}, handle={index}"
);
}
}
}
}
+43
View File
@@ -2891,6 +2891,49 @@ mod tests {
assert!(matches!(at(h.state()), Some((SpreadRef::Doc(0), 0, 0, _))), "the page's own guide is");
}
#[test]
fn mouse_double_clicking_a_frame_handle_fits_with_one_undo_step() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let id = designcraft_doc::ItemId(
app.run("frame.create", json!({"rect": [100, 100, 400, 300], "content": "text", "text": "Fit this text"})).unwrap()["id"]
.as_u64()
.unwrap(),
);
app.run("selection.set", json!({"ids": [id.0]})).unwrap();
let original = app.session.doc().unwrap().doc.item(id).unwrap().bounds();
// 60 frames a second: the default 0.25 s step puts two clicks past egui's double-click delay.
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut DesignApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
let app = h.state();
let st = app.session.doc().unwrap();
let layout = CanvasLayout::new(&st.doc, false);
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let p = xf.to_screen(layout.xf(SpreadRef::Doc(0)) * Point::new(original.x1, original.y1));
h.hover_at(p);
h.step();
// Separate frames model the actual mouse, including egui's release-time double click.
for _ in 0..2 {
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
}
h.run_steps(2);
let fitted = h.state().session.doc().unwrap().doc.item(id).unwrap().bounds();
assert!(fitted.width() < original.width() && fitted.height() < original.height(), "{fitted:?}");
assert_eq!(h.state().session.tool_id(), "selection");
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.doc().unwrap().doc.item(id).unwrap().bounds(), original);
}
#[test]
fn right_clicking_a_ruler_sets_that_rulers_units() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());