Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 b426859b15 Fix #833: isolation mode locks and dims the art around the group
While a group or layer was isolated, clicks, hovers and drags still
reached the art outside it, and that art drew as usual. Now only the
isolated container's contents hit: the art around it is locked and
clicks pass through it. On the canvas it draws at half opacity, above
the group as well as below, each run of it as one dimmed layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:57 +07:00
bflatasticandClaude Opus 5.5 61d372bb7f Fix #775: the Properties panel's Paragraph section justifies and hyphenates
With type selected, the Properties panel's Paragraph section had only
Align Left, Center and Right. It now has the Paragraph panel's seven
alignments (the justified ones too), its Hyphenate checkbox and a "…"
button that opens the Paragraph panel, as Illustrator's does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:56 +07:00
16 changed files with 175 additions and 20 deletions

No files matched your search

+20 -5
View File
@@ -86,6 +86,19 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
/// [`hit_test`] passing over the nodes `skip` names (and their contents).
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool) -> Option<Hit> {
let finish = |mut h: Hit| {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
h
};
// Isolation mode: only what is inside the isolated container hits. The art around it is
// locked, and clicks pass through it (#833).
if let Some(scope) = opt.scope
&& let Some(container) = doc.node(scope).filter(|n| n.is_container())
&& let Some(mut chain) = doc.ancestry(scope)
{
return hit_children(container, p, opt, skip, &mut chain).map(finish);
}
let mut chain = Vec::new();
for layer in doc.layers.iter().rev() {
if !layer.visible || layer.locked {
@@ -100,10 +113,8 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
}
chain.push(layer.id);
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
if let Some(mut h) = hit_children(layer, p, opt, skip, &mut chain) {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
return Some(h);
if let Some(h) = hit_children(layer, p, opt, skip, &mut chain) {
return Some(finish(h));
}
chain.pop();
}
@@ -560,7 +571,11 @@ world",
d.insert(Some(l), 9, far).unwrap();
let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope);
assert_eq!(at(30.0, None), vec![g, a_id]);
assert_eq!(at(30.0, Some(g))[..2], [c_id, b_id], "isolated: the group's own objects");
assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects");
// Isolated, the art around the group is locked: clicks pass through it (#833).
assert!(at(5.0, Some(g)).is_empty());
assert!(at(130.0, Some(g)).is_empty());
assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none());
assert_eq!(at(5.0, None), vec![a_id]);
assert!(at(90.0, None).is_empty());
}
+70
View File
@@ -107,8 +107,14 @@ pub struct RenderOptions {
/// Images are sampled smoothly when scaled or rotated; off, each pixel takes its nearest image
/// pixel (Pixel Preview with File Handling › Display Bitmaps as Anti-aliased Images off).
pub smooth_images: bool,
/// Screen view in isolation mode: the isolated group or layer. Everything around it draws
/// dimmed ([`ISOLATION_DIM`]).
pub isolated: Option<NodeId>,
}
/// The opacity of the art around an isolated group or layer.
pub const ISOLATION_DIM: f32 = 0.5;
/// How edges are rasterized (raster export option).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AntiAlias {
@@ -174,6 +180,7 @@ impl Default for RenderOptions {
progressive_placed: false,
trace_views: false,
smooth_images: true,
isolated: None,
}
}
}
@@ -355,6 +362,11 @@ pub struct Renderer {
/// Inline graphics being drawn inside inline graphics (a symbol whose art holds text showing
/// it): drawing stops at [`MAX_INLINE_DEPTH`].
inline_depth: u32,
/// [`RenderOptions::isolated`]'s layers and groups, from the top layer down to it, for the
/// frame (`stamp`) and document they were found in.
isolation: Option<(u64, usize, NodeId, Vec<NodeId>)>,
/// Drawing inside the isolated container or a dimmed object: nothing further dims.
isolation_settled: bool,
}
/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing.
@@ -441,6 +453,8 @@ impl Renderer {
adjusted: Default::default(),
dim_images: None,
inline_depth: 0,
isolation: None,
isolation_settled: false,
}
}
@@ -876,6 +890,46 @@ impl Renderer {
/// [`Self::draw_node`] after culling and Layer Options.
fn draw_layer_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) {
// Isolation mode: the art around the isolated container draws dimmed (#833).
if let Some((isolated, path)) = self.isolation_path(f) {
// A container on the way down to it draws as usual: its members decide.
if path.contains(&n.id) && n.id != isolated {
return self.draw_layer_node_now(ctx, f, n);
}
self.isolation_settled = true;
if n.id == isolated {
self.draw_layer_node_now(ctx, f, n);
} else {
self.dimmed(ctx, f, &mut |r, c, fr| r.draw_layer_node_now(c, fr, n));
}
self.isolation_settled = false;
return;
}
self.draw_layer_node_now(ctx, f, n);
}
/// In isolation mode, while drawing outside the isolated container and the art dimmed around
/// it: the isolated container and its layers and groups, from the top layer down to it.
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
let doc = std::ptr::from_ref(f.doc) as usize;
if !self.isolation.as_ref().is_some_and(|(stamp, d, id, _)| (*stamp, *d, *id) == (self.stamp, doc, isolated)) {
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
}
let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?;
Some((isolated, path))
}
/// `draw` at [`ISOLATION_DIM`], with nothing inside dimmed again.
fn dimmed(&mut self, ctx: &mut RenderContext, f: &Frame, draw: &mut group::Content) {
let settled = std::mem::replace(&mut self.isolation_settled, true);
let comp = Composite { opacity: ISOLATION_DIM, ..Default::default() };
self.group(ctx, f, comp, draw);
self.isolation_settled = settled;
}
/// [`Self::draw_layer_node`] past isolation mode's dimming.
fn draw_layer_node_now(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) {
if fx::has_object_fx(n) {
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
}
@@ -998,6 +1052,22 @@ impl Renderer {
fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>], knockout: bool) {
if knockout {
self.draw_knockout(ctx, f, children);
} else if let Some((_, path)) = self.isolation_path(f) {
// Isolation mode, in a container on the way down to the isolated one: each run of
// members around it draws dimmed as one (#833).
let mut rest = children;
while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) {
let (around, from) = rest.split_at(i);
if !around.is_empty() {
self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n)));
}
let Some((on_path, after)) = from.split_first() else { break };
self.draw_arc(ctx, f, on_path);
rest = after;
}
if !rest.is_empty() {
self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n)));
}
} else {
for c in children {
self.draw_arc(ctx, f, c);
+27
View File
@@ -218,3 +218,30 @@ fn copying_the_backdrop_changes_nothing_without_blending() {
let worst = a.iter().zip(&b).map(|(x, y)| (*x as i32 - *y as i32).abs()).max().unwrap();
assert!(worst <= 1, "differs by {worst}/255");
}
/// Isolation mode (#833): the art around the isolated group draws at half opacity, above it as
/// well as below; the group's own art as usual.
#[test]
fn isolation_mode_dims_the_art_around_the_isolated_group() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [0.0, 0.0, 0.0]);
let inside = rect(&mut d, Rect::new(60.0, 60.0, 100.0, 100.0), [0.0, 0.0, 0.0]);
let above = rect(&mut d, Rect::new(60.0, 0.0, 100.0, 40.0), [0.0, 0.0, 0.0]);
let g = group(&mut d, vec![inside], 1.0, Blend::Normal, false);
let gid = g.id;
for n in [below, g, above] {
d.insert(Some(l), usize::MAX, n).unwrap();
}
let alpha = |img: &Rendered, x: usize, y: usize| img.pixels[(y * 100 + x) * 4 + 3];
let plain = render(&d);
let isolated = |id| render_with(&mut Renderer::new(), &d, &RenderOptions { isolated: Some(id), ..Default::default() });
let dimmed = isolated(gid);
assert_eq!(alpha(&dimmed, 80, 80), 255, "the isolated group's art");
for (x, y) in [(20, 20), (80, 20)] {
assert_eq!(alpha(&plain, x, y), 255);
assert!((120..=135).contains(&alpha(&dimmed, x, y)), "({x}, {y}): {}", alpha(&dimmed, x, y));
}
// An object that isn't in the document dims nothing.
assert_eq!(isolated(NodeId(u64::MAX)).pixels, plain.pixels);
}
+3
View File
@@ -249,6 +249,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
placed: vectorcraft_render::placed_document::generation(),
pixel,
smooth_images,
isolated: st.isolation.map(|id| id.0),
};
// Placed documents' bitmaps are being made: draw again when they are ready.
if vectorcraft_render::placed_document::busy() {
@@ -298,6 +299,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
progressive_placed: true,
trace_views: true,
smooth_images,
// Isolation mode: the art around the isolated group or layer is dimmed (#833).
isolated: key.isolated.map(vectorcraft_doc::NodeId),
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
+1
View File
@@ -1498,6 +1498,7 @@
More Más
More Pathfinder options Más opciones de Buscatrazos
More Align options Más opciones de alineación
More Paragraph options Más opciones de párrafo
More Settings Más ajustes
Move Mover
Move All Objects Mover todos los objetos
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1497,6 +1497,7 @@
More Plus
More Pathfinder options Plus d’options Pathfinder
More Align options Plus d’options d’alignement
More Paragraph options Plus d’options de paragraphe
More Settings Plus de réglages
Move Déplacer
Move All Objects Déplacer tous les objets
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1498,6 +1498,7 @@
More Altro
More Pathfinder options Altre opzioni di Elaborazione tracciati
More Align options Altre opzioni di allineamento
More Paragraph options Altre opzioni di paragrafo
More Settings Altre impostazioni
Move Sposta
Move All Objects Sposta tutti gli oggetti
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1640,6 +1640,7 @@
More 多く
More Pathfinder options その他のパスファインダーオプション
More Align options その他の整列オプション
More Paragraph options その他の段落オプション
More Settings その他の設定
Move 移動
Move Anchors アンカーポイントを移動
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1314,6 +1314,7 @@
More Mais
More Pathfinder options Mais opções de Localizador de Caminhos
More Align options Mais opções de alinhamento
More Paragraph options Mais opções de parágrafo
More Settings Mais Configurações
Move Mover
Move Anchors Mover Âncoras
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1496,6 +1496,7 @@
More Больше
More Pathfinder options Дополнительные параметры обработки контуров
More Align options Дополнительные параметры выравнивания
More Paragraph options Дополнительные параметры абзаца
More Settings Дополнительные настройки
Move Переместить
Move All Objects Переместить все объекты
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1492,6 +1492,7 @@
More Більше
More Pathfinder options Додаткові параметри обробки контурів
More Align options Додаткові параметри вирівнювання
More Paragraph options Додаткові параметри абзацу
More Settings Додаткові налаштування
Move Перемістити
Move All Objects Перемістити всі об’єкти
Can't render this file because it contains an unexpected character in line 632 and column 40.
+1
View File
@@ -1456,6 +1456,7 @@
More 较多
More Pathfinder options 更多路径查找器选项
More Align options 更多对齐选项
More Paragraph options 更多段落选项
More Settings 更多设置
Move 移动
Move All Objects 移动所有对象
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1498,6 +1498,7 @@
More 更多
More Pathfinder options 更多路徑管理員選項
More Align options 更多對齊選項
More Paragraph options 更多段落選項
More Settings 更多設定
Move 移動
Move All Objects 移動所有物件
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -293,6 +293,8 @@ pub struct CacheKey {
pub pixel: Option<[i64; 4]>,
/// Images sampled smoothly ([`vectorcraft_render::RenderOptions::smooth_images`]).
pub smooth_images: bool,
/// Isolation mode's group or layer ([`vectorcraft_render::RenderOptions::isolated`]).
pub isolated: Option<u64>,
}
#[derive(Clone, Copy, Debug, Default)]
+20 -9
View File
@@ -60,6 +60,24 @@ fn shown_alignment(justify: Justify, rtl: bool) -> Justify {
}
}
/// The seven alignment buttons, `size` points square, the one of `para`'s alignment on (the
/// Paragraph panel's and the Properties panel's).
pub fn alignment_buttons(app: &mut VectorcraftApp, ui: &mut Ui, para: &ParaStyle, size: f32) {
let shown = shown_alignment(para.justify, resolved_rtl(app, para));
for (j, icon, tip, id) in ALIGNMENTS {
if widgets::icon_button(ui, icon, tip, shown == j, size).clicked() {
para_cmd(app, "text.setStyle", json!({"justify": id}));
}
}
}
/// The Hyphenate checkbox of `para`'s paragraphs.
pub fn hyphenate_check(app: &mut VectorcraftApp, ui: &mut Ui, para: &ParaStyle) {
if widgets::check(ui, tl!("Hyphenate"), para.hyphenate, true) {
format(app, json!({"hyphenate": !para.hyphenate}));
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some((_, para)) = text_style(app) else {
@@ -67,14 +85,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
return;
};
let rtl = resolved_rtl(app, &para);
let shown = shown_alignment(para.justify, rtl);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
for (j, icon, tip, id) in ALIGNMENTS {
if widgets::icon_button(ui, icon, tip, shown == j, 28.0).clicked() {
para_cmd(app, "text.setStyle", json!({"justify": id}));
}
}
alignment_buttons(app, ui, &para, 28.0);
// Paragraph direction, with the Middle Eastern (Indic) options.
if app.session.prefs.show_indic_options {
ui.add_space(6.0);
@@ -127,9 +140,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if pstate::<bool>(ui.ctx(), "pa-hide-options") {
return;
}
if widgets::check(ui, tl!("Hyphenate"), para.hyphenate, true) {
format(app, json!({"hyphenate": !para.hyphenate}));
}
hyphenate_check(app, ui, &para);
// Japanese composition: how punctuation is spaced (JLREQ 3.1), with the East Asian options.
if app.session.prefs.show_east_asian_options {
ui.horizontal(|ui| {
+24 -6
View File
@@ -523,13 +523,17 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
super::character::font_pickers(app, ui, &s, ("font", "font-style"), (w - 4.0, w - 4.0));
super::character::metrics_grid(app, ui, "props-char", &s, w, false);
section_header(ui, tl!("Paragraph"));
ui.horizontal(|ui| {
for (icon, j) in [("align-start-vertical", "left"), ("align-center-vertical", "center"), ("align-end-vertical", "right")] {
if widgets::icon_button(ui, icon, j, false, 24.0).clicked() {
app.run("text.setStyle", json!({"justify": j})).ok();
// As in the Paragraph panel: the seven alignments and Hyphenate (#775); the rest is there.
if let Some((_, para)) = super::character::text_style(app) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
super::paragraph::alignment_buttons(app, ui, &para, 24.0);
if widgets::icon_button(ui, "ellipsis", tl!("More Paragraph options"), false, 24.0).clicked() {
app.ui.open_panel = Some("paragraph".into());
}
}
});
});
super::paragraph::hyphenate_check(app, ui, &para);
}
}
#[cfg(test)]
@@ -592,6 +596,20 @@ mod tests {
}
/// #530: Edit Artboards shows the active artboard, with New Artboard, Delete Artboard and Exit.
/// #775: type's Paragraph section has Hyphenate, and its checkbox turns it on.
#[test]
fn type_paragraph_section_hyphenates() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("text.create", json!({"x": 10, "y": 50, "text": "Paragraph"})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Hyphenate");
let hyphenate = |app: &VectorcraftApp| crate::panels::character::text_style(app).unwrap().1.hyphenate;
assert!(hyphenate(&app));
}
#[test]
fn edit_artboards_adds_artboards_and_exits() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());