View: Bleed and Slug screen modes show their area; draw the slug guide

Bleed and Slug were selectable from the toolbar, the View menu and
`view.screenMode`, and were stored, but the canvas only ever asked whether the
mode was Preview or Presentation. Both therefore drew exactly what Normal drew:
guides, frame edges, the other non-printing marks, and content reaching across
the pasteboard.

They are preview modes now, and what each spread shows reaches out to its box:
the bleed for Bleed, the bleed and the slug for Slug (Slug shows every printing
element, and a slug narrower than the bleed does not pull the edge back in).
Preview and Presentation still stop at the trim.

The slug guide was never drawn either, so the Slug colour in Preferences ›
Guides & Pasteboard was stored, exported and round-tripped while nothing read
it. It is drawn beside the bleed guide, in that colour.

`Spread::bleed_rect`'s body becomes `outset_rect`, which measures either offset
from the page edges, with `bleed_rect` and `slug_rect` as its named callers.
This commit is contained in:
Hamza
2026-10-18 20:47:01 +05:00
parent 7141a68f18
commit b8ae284a4a
4 changed files with 124 additions and 15 deletions
+1
View File
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-18** · View › Screen Mode: Bleed and Slug do what they say. Both were selectable from the toolbar, the View menu and `view.screenMode` but drew the canvas exactly as Normal did, guides and all; they are preview modes now, and what each page shows reaches the bleed box or the slug box instead of stopping at the trim. The slug guide is drawn as well, in the Slug colour from Preferences › Guides & Pasteboard, which nothing had read. No readiness percentage change.
- **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change.
- **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change.
- **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `<Image FillColor>` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change.
+18 -5
View File
@@ -301,17 +301,27 @@ impl Spread {
pub fn bounds(&self) -> Rect {
self.pages.iter().map(Page::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO)
}
/// The bleed guide: one rectangle around the spread. `bleed` is the document's top, bottom,
/// The bleed guide: one rectangle around the spread. None without pages or without any bleed.
pub fn bleed_rect(&self, bleed: [f64; 4]) -> Option<Rect> {
self.outset_rect(bleed)
}
/// The slug guide. Slug offsets are measured from the page edges, like the bleed, so a slug
/// narrower than the bleed sits inside it, as in InDesign. None without pages or without any
/// slug.
pub fn slug_rect(&self, slug: [f64; 4]) -> Option<Rect> {
self.outset_rect(slug)
}
/// The spread's bounds grown by a document outset (the bleed or the slug): top, bottom,
/// inside, outside; the inside value applies only at an outer edge on the spine side (a lone
/// left page's right edge). The outer edges are the leftmost and rightmost pages, whichever
/// binding ordered the list. None without pages or without any bleed.
pub fn bleed_rect(&self, bleed: [f64; 4]) -> Option<Rect> {
/// binding ordered the list.
pub fn outset_rect(&self, outset: [f64; 4]) -> Option<Rect> {
let (l, r) = self.outer_page_indices()?;
let (first, last) = (self.pages.get(l)?, self.pages.get(r)?);
if !bleed.iter().any(|v| *v > 0.0) {
if !outset.iter().any(|v| *v > 0.0) {
return None;
}
let [top, bottom, inside, outside] = bleed;
let [top, bottom, inside, outside] = outset;
let l = if first.side == PageSide::Left { outside } else { inside };
let r = if matches!(last.side, PageSide::Right | PageSide::Single) { outside } else { inside };
let b = self.bounds();
@@ -464,6 +474,9 @@ mod tests {
let s = Spread { id: SpreadId(1), pages, items: vec![], parent: None, allow_shuffle: true };
assert_eq!(s.bleed_rect(bleed), Some(Rect::new(-4.0, -1.0, 1228.0, 794.0)));
assert_eq!(s.bleed_rect([0.0; 4]), None);
// The slug is measured the same way, from the page edges.
assert_eq!(s.slug_rect(bleed), s.bleed_rect(bleed));
assert_eq!(s.slug_rect([0.0; 4]), None);
}
let lone_left = Spread { id: SpreadId(1), pages: vec![page(PageSide::Left, 0.0)], items: vec![], parent: None, allow_shuffle: true };
assert_eq!(lone_left.bleed_rect(bleed), Some(Rect::new(-4.0, -1.0, 615.0, 794.0)));
+1 -1
View File
@@ -32,7 +32,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use catalog::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info};
pub use layout::CanvasLayout;
pub use layout::{CanvasLayout, SpreadSlot};
pub use nav::ZoomTool;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+104 -9
View File
@@ -5,7 +5,7 @@ use designcraft_compose as compose;
use designcraft_doc::{Content, Document, Item, SpreadRef};
use designcraft_engine::doc::Selection;
use designcraft_geom::{Affine, Point, Rect as DRect, Vec2 as DVec2};
use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolKey};
use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, SpreadSlot, ToolKey};
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::{Value, json};
@@ -477,7 +477,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
layout.slots = layout.slots.get(cur).copied().into_iter().collect();
}
let painter = ui.painter_at(screen);
let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation);
// Preview, Bleed, Slug and Presentation are all preview modes: guides, frame edges and the
// other non-printing marks go, and only the shown area of each page keeps its content. They
// differ in how much of the page shows, which `shown_area` gives.
let preview = app.ui.screen_mode != ScreenMode::Normal;
let bg = if app.ui.screen_mode == ScreenMode::Presentation { Color32::BLACK } else { t.pasteboard };
painter.rect_filled(screen, 0.0, bg);
// Page shadow: a hard 1.5 pt black offset on the right and bottom (InDesign 2026).
@@ -514,9 +517,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
}
mesh.indices.extend([0, 1, 2, 0, 2, 3]);
if preview {
// Preview: only page areas (trim) show content.
// Preview: only the shown area of each spread keeps its content.
for slot in &layout.slots {
let r = xf.rect(slot.bounds);
let r = shown_area(app, &doc, &xf, slot);
painter.with_clip_rect(r.intersect(screen)).add(egui::Shape::mesh(mesh.clone()));
}
} else {
@@ -933,6 +936,20 @@ fn baseline_grid_lines(start: f64, increment: f64, height: f64) -> impl Iterator
(0..MAX_BASELINES).map(move |n| first + n as f64 * step).take_while(move |y| *y < height)
}
/// What a screen mode shows of a spread, in screen coordinates: the trim, or out to the bleed or
/// the slug box. Slug shows every printing element, so the bleed as well — and a slug narrower
/// than the bleed does not pull the edge back in.
fn shown_area(app: &DesignApp, doc: &Document, xf: &Xf, slot: &SpreadSlot) -> Rect {
let trim = xf.rect(slot.bounds);
let boxes: &[[f64; 4]] = match app.ui.screen_mode {
ScreenMode::Bleed => &[doc.settings.bleed],
ScreenMode::Slug => &[doc.settings.bleed, doc.settings.slug],
_ => &[],
};
let Some(sp) = doc.spread(slot.spread) else { return trim };
boxes.iter().filter_map(|o| sp.outset_rect(*o)).map(|r| xf.rect(slot.xf.transform_rect_bbox(r))).fold(trim, |a, b| a.union(b))
}
fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) {
let s = &doc.settings;
for slot in &layout.slots {
@@ -941,11 +958,14 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document
let m = slot.xf;
let pt = |p: Point| xf.to_screen(m * p);
let rect = |r: DRect| xf.rect(m.transform_rect_bbox(r));
// Bleed: one rectangle around the spread (inside bleed only applies at the spread's outer edges).
if app.ui.guides
&& let Some(br) = sp.bleed_rect(s.bleed)
{
painter.rect_stroke(rect(br), 0.0, Stroke::new(hair(painter), c32(s.bleed_color)), StrokeKind::Middle);
// Bleed, then slug: one rectangle each around the spread (the inside offset only applies
// at the spread's outer edges).
if app.ui.guides {
for (r, color) in [(sp.bleed_rect(s.bleed), s.bleed_color), (sp.slug_rect(s.slug), s.slug_color)] {
if let Some(r) = r {
painter.rect_stroke(rect(r), 0.0, Stroke::new(hair(painter), c32(color)), StrokeKind::Middle);
}
}
}
let pasteboard = m.inverse().transform_rect_bbox(layout.pasteboard);
for (pi, p) in sp.pages.iter().enumerate() {
@@ -3028,6 +3048,81 @@ mod tests {
}
}
/// Bleed and Slug are preview modes: like Preview they hide the guides, and what the page
/// shows widens to the bleed or the slug box. The slug guide is drawn in Normal, in its own
/// colour, beside the bleed guide.
#[test]
fn bleed_and_slug_are_preview_modes_that_show_their_area() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", serde_json::json!({"bleed": 9, "slug": 18})).unwrap();
let strokes = |app: &mut DesignApp, want: [u8; 3]| -> usize {
let ctx = egui::Context::default();
let mut shapes = Vec::new();
for _ in 0..2 {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
shapes = out.shapes;
}
let want = c32(want);
shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(s) if s.stroke.color == want)).count()
};
let s = app.session.active().unwrap().doc.settings.clone();
// Normal draws every guide, the slug's among them.
app.ui.screen_mode = ScreenMode::Normal;
assert!(strokes(&mut app, s.margin_color) > 0, "Normal draws the margin guide");
assert!(strokes(&mut app, s.bleed_color) > 0, "Normal draws the bleed guide");
assert!(strokes(&mut app, s.slug_color) > 0, "Normal draws the slug guide");
// Every preview mode hides them.
for mode in [ScreenMode::Preview, ScreenMode::Bleed, ScreenMode::Slug, ScreenMode::Presentation] {
app.ui.screen_mode = mode;
assert_eq!(strokes(&mut app, s.margin_color), 0, "{mode:?} hides the margin guide");
assert_eq!(strokes(&mut app, s.slug_color), 0, "{mode:?} hides the slug guide");
}
}
/// Each preview mode's shown area: Preview stops at the trim, Bleed reaches the bleed box and
/// Slug the slug box. A slug narrower than the bleed never pulls the edge back in.
#[test]
fn shown_area_widens_to_the_bleed_and_slug_boxes() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", serde_json::json!({"bleed": 9, "slug": 18})).unwrap();
let doc = app.session.active().unwrap().doc.clone();
let layout = CanvasLayout::new(&doc, false);
let xf = Xf::new(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0)), &View::default());
let slot = &layout.slots[0];
let area = |app: &mut DesignApp, mode| {
app.ui.screen_mode = mode;
shown_area(app, &doc, &xf, slot)
};
let trim = xf.rect(slot.bounds);
assert_eq!(area(&mut app, ScreenMode::Preview), trim, "Preview stops at the trim");
assert_eq!(area(&mut app, ScreenMode::Presentation), trim, "Presentation stops at the trim");
let bleed = area(&mut app, ScreenMode::Bleed);
let slug = area(&mut app, ScreenMode::Slug);
assert!(bleed.contains_rect(trim) && bleed != trim, "Bleed reaches past the trim");
assert!(slug.contains_rect(bleed) && slug != bleed, "Slug reaches past the bleed");
// 9 pt of bleed and 18 pt of slug at this zoom, on every edge.
let grew = |r: Rect, pt: f64| {
let d = (pt * xf.zoom) as f32;
(r.min - trim.min + vec2(d, d)).length() < 0.01 && (r.max - trim.max - vec2(d, d)).length() < 0.01
};
assert!(grew(bleed, 9.0), "Bleed grows by the bleed: {bleed:?}");
assert!(grew(slug, 18.0), "Slug grows by the slug: {slug:?}");
// A slug inside the bleed: Slug still shows every printing element, so the bleed too.
app.run("file.new", serde_json::json!({"bleed": 18, "slug": 9})).unwrap();
let doc = app.session.active().unwrap().doc.clone();
app.ui.screen_mode = ScreenMode::Slug;
assert!(grew(shown_area(&app, &doc, &xf, slot), 18.0), "the wider of the two boxes wins");
// No bleed and no slug: nothing to widen to.
app.run("file.new", serde_json::json!({})).unwrap();
let doc = app.session.active().unwrap().doc.clone();
for mode in [ScreenMode::Bleed, ScreenMode::Slug] {
app.ui.screen_mode = mode;
assert_eq!(shown_area(&app, &doc, &xf, slot), trim, "{mode:?} without a bleed or slug");
}
}
fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut DesignApp| {