Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 79c81d754c Fix #835: a recent colour recolours the selected gradient stop
In the gradient stop popover (and the Color panel), clicking a recent
colour replaced the whole gradient with that colour, while the sliders,
spectrum and Black and White chips recoloured the selected stop. The
recent colours now go through the Color panel's target like the chips.
The Swatches panel's recent colours still paint the fill or stroke, as
its swatches do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:54 +07:00
bflatasticandClaude Opus 5.5 97318c188b Fix #862: the gradient stop popover opens beside the art
Opened from a stop on the Gradient tool's annotator, the colour popover
appeared just below and right of the stop, over the very art whose
colour it changes. It now opens beside the selected art, on the first
side with room for it (right, left, below, above), and beside the stop
only when there is none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:53 +07:00
bflatasticandClaude Opus 5.5 94141eca82 Fix #826: rulers cover their length in a rotated view
With Rotate View, each ruler measured document x (or y) along a line
that crossed both axes: its ticks covered only a short stretch and the
numbers bunched up. Each ruler now measures along its own edge in the
canvas's coordinates turned with the view, so it is ticked over its
whole length at every angle, and reads x and y as before at 0°.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:53 +07:00
5 changed files with 179 additions and 46 deletions

No files matched your search

+74 -35
View File
@@ -1081,47 +1081,46 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit,
let step = unit.ruler_step(xf.zoom);
let minor = step / 10.0;
let font = egui::FontId::proportional(9.5);
let a = xf.to_doc(top.left_top());
let b = xf.to_doc(top.right_top());
let clip_top = p.with_clip_rect(top);
let mut x = (a.x / per / minor).floor() * minor;
while x * per <= b.x {
let sx = xf.to_screen(Point::new(x * per, 0.0)).x;
let is_major = ((x / step).round() * step - x).abs() < minor * 0.01;
let is_mid = ((x / (step / 2.0)).round() * (step / 2.0) - x).abs() < minor * 0.01;
let len = if is_major {
let near = |v: f64, every: f64| ((v / every).round() * every - v).abs() < minor * 0.01;
// A tick's length: the labelled ones longest, the halves between them shorter.
let tick = |v: f64| {
if near(v, step) {
RULER
} else if is_mid {
} else if near(v, step / 2.0) {
7.0
} else {
4.0
};
clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick));
if is_major {
}
};
// Each ruler measures along its own edge, in the canvas's coordinates turned with the view
// (#826): at 0° the top one reads x and the left one y. `draw` gets each tick's distance
// (screen points) from `from` along the ruler, and its value in `unit`.
let ticks = |from: Pos2, to: Pos2, axis: vectorcraft_geom::Vec2, draw: &mut dyn FnMut(f32, f64)| {
let along = |s: Pos2| xf.to_doc(s).to_vec2().dot(axis);
let (a, b) = (along(from), along(to));
let mut v = (a / per / minor).floor() * minor;
while v * per <= b {
draw(((v * per - a) * xf.zoom) as f32, v);
v += minor;
}
};
// The canvas's directions along the screen's x and y.
let (sn, cs) = xf.rot.sin_cos();
let clip_top = p.with_clip_rect(top);
ticks(top.left_top(), top.right_top(), vectorcraft_geom::Vec2::new(cs, -sn), &mut |d, x| {
let sx = top.left() + d;
clip_top.line_segment([pos2(sx, top.bottom() - tick(x)), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick));
if near(x, step) {
clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, ruler_label(x, step), font.clone(), t.ruler_tick);
}
x += minor;
}
let a = xf.to_doc(left.left_top());
let b = xf.to_doc(left.left_bottom());
});
let clip_left = p.with_clip_rect(left);
let mut y = (a.y / per / minor).floor() * minor;
while y * per <= b.y {
let sy = xf.to_screen(Point::new(0.0, y * per)).y;
let is_major = ((y / step).round() * step - y).abs() < minor * 0.01;
let is_mid = ((y / (step / 2.0)).round() * (step / 2.0) - y).abs() < minor * 0.01;
let len = if is_major {
RULER
} else if is_mid {
7.0
} else {
4.0
};
clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick));
if is_major {
ticks(left.left_top(), left.left_bottom(), vectorcraft_geom::Vec2::new(sn, cs), &mut |d, y| {
let sy = left.top() + d;
clip_left.line_segment([pos2(left.right() - tick(y), sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick));
if near(y, step) {
// Vertical labels read top-to-bottom, one digit per line like Illustrator.
let s = ruler_label(y, step);
for (k, ch) in s.chars().enumerate() {
for (k, ch) in ruler_label(y, step).chars().enumerate() {
clip_left.text(
pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5),
egui::Align2::LEFT_TOP,
@@ -1131,8 +1130,7 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit,
);
}
}
y += minor;
}
});
if let Some(h) = hover {
let s = xf.to_screen(h);
clip_top.line_segment([pos2(s.x, top.top()), pos2(s.x, top.bottom())], Stroke::new(1.0, t.text));
@@ -2053,6 +2051,47 @@ mod tests {
use vectorcraft_engine::Session;
use vectorcraft_geom::Shape as _;
/// #826: in a turned view each ruler still has ticks along its whole length; at 0° the top one
/// reads x where the canvas has it.
#[test]
fn rulers_cover_their_length_at_any_rotation() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let full = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
let [top, left, _] = ruler_rects(full);
for deg in [0.0_f64, 30.0, 90.0, 135.0, 200.0] {
let xf = Xf { rect: full, zoom: 1.0, center: Point::new(300.0, 200.0), rot: deg.to_radians() };
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, Unit::Points, &Tokens::get(ui.ctx())));
out.textures_delta.clear();
// The ticks' positions along each ruler.
let along = |ruler: egui::Rect, x: bool| -> Vec<f32> {
let mut at: Vec<f32> = out
.shapes
.iter()
.filter(|c| c.clip_rect == ruler)
.filter_map(|c| match &c.shape {
Shape::LineSegment { points: [a, b], .. } if x && a.x == b.x => Some(a.x),
Shape::LineSegment { points: [a, b], .. } if !x && a.y == b.y => Some(a.y),
_ => None,
})
.collect();
at.sort_by(f32::total_cmp);
at
};
for (ruler, x) in [(top, true), (left, false)] {
let at = along(ruler, x);
let (start, end) = if x { (ruler.left(), ruler.right()) } else { (ruler.top(), ruler.bottom()) };
let (Some(first), Some(last)) = (at.first(), at.last()) else { panic!("{deg}°: no ticks") };
assert!(first - start < 20.0 && end - last < 20.0, "{deg}°: ticks from {first} to {last} on {start}..{end}");
assert!(at.windows(2).all(|w| w[1] - w[0] < 20.0), "{deg}°: a gap in the ticks");
}
if deg == 0.0 {
let origin = xf.to_screen(Point::ZERO).x;
assert!(along(top, true).iter().any(|x| (x - origin).abs() < 0.01), "a tick at x = 0");
}
}
}
#[test]
fn shaper_selection_highlights_only_the_visible_result() {
let mut s = Session::new();
+60 -3
View File
@@ -90,12 +90,19 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let screen =
d.fields.get("screen").and_then(Value::as_array).and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)));
let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0));
let pos = screen.unwrap_or_else(|| {
app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0))
let area = egui::Id::new("gradient-stop-popover");
let size = ctx.memory(|m| m.area_rect(area)).map_or(POPOVER_SIZE, |r| r.size());
let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.view().copied()) {
Some((canvas, v)) => {
let xf = Xf::new(canvas, &v);
let at = xf.to_screen(chip);
selection_on_screen(app, &xf).map_or(at + vec2(14.0, 14.0), |art| beside_art(at, art, size, canvas))
}
None => ctx.content_rect().center(),
});
let swatches = d.str("tab") == "swatches";
let mut tab = None;
let resp = egui::Area::new(egui::Id::new("gradient-stop-popover"))
let resp = egui::Area::new(area)
.order(egui::Order::Foreground)
.fixed_pos(pos)
.constrain(true)
@@ -150,6 +157,34 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
/// The popover's size before it has been drawn.
const POPOVER_SIZE: egui::Vec2 = vec2(272.0, 360.0);
/// The selected art's box on the screen.
fn selection_on_screen(app: &VectorcraftApp, xf: &Xf) -> Option<egui::Rect> {
let st = app.session.active()?;
let b = st.doc.bounds_of(&st.selection.objects, false)?;
Some(egui::Rect::from_points(&xf.quad(b)))
}
/// Where the popover opened from a stop's chip at `chip` goes: beside the selected `art` on the
/// first side of it with room for a popover of `size` in `room` (right, left, below, above), so
/// the art it recolours stays in view (#862); else beside the chip.
fn beside_art(chip: egui::Pos2, art: egui::Rect, size: egui::Vec2, room: egui::Rect) -> egui::Pos2 {
const GAP: f32 = 12.0;
let x = (chip.x - size.x / 2.0).clamp(room.left(), (room.right() - size.x).max(room.left()));
let y = (chip.y - size.y / 2.0).clamp(room.top(), (room.bottom() - size.y).max(room.top()));
[
egui::pos2(art.right() + GAP, y),
egui::pos2(art.left() - GAP - size.x, y),
egui::pos2(x, art.bottom() + GAP),
egui::pos2(x, art.top() - GAP - size.y),
]
.into_iter()
.find(|p| room.contains_rect(egui::Rect::from_min_size(*p, size)))
.unwrap_or(chip + vec2(14.0, 14.0))
}
/// Document swatches as tiles: a click gives the stop (or point) that colour.
fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, target: &Target) {
let colors = swatch_colors(app);
@@ -248,6 +283,28 @@ mod tests {
app
}
/// #862: the popover goes beside the art, on the first side with room, not over it.
#[test]
fn the_popover_goes_beside_the_art_it_edits() {
let room = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1200.0, 800.0));
let size = egui::vec2(272.0, 360.0);
let place = |art: egui::Rect| egui::Rect::from_min_size(beside_art(art.center(), art, size, room), size);
// Room on the right.
let art = egui::Rect::from_min_max(egui::pos2(300.0, 300.0), egui::pos2(500.0, 450.0));
assert!(place(art).left() > art.right() && room.contains_rect(place(art)));
// Against the right edge: on the left.
let art = egui::Rect::from_min_max(egui::pos2(900.0, 300.0), egui::pos2(1150.0, 450.0));
assert!(place(art).right() < art.left());
// As wide as the room: below it, or above it near the bottom.
let art = egui::Rect::from_min_max(egui::pos2(100.0, 20.0), egui::pos2(1100.0, 300.0));
assert!(place(art).top() > art.bottom());
let art = egui::Rect::from_min_max(egui::pos2(100.0, 420.0), egui::pos2(1100.0, 780.0));
assert!(place(art).bottom() < art.top());
// Nowhere to go: beside the chip, as before.
let art = room.shrink(10.0);
assert_eq!(beside_art(art.center(), art, size, room), art.center() + egui::vec2(14.0, 14.0));
}
fn stops(app: &VectorcraftApp) -> Vec<GradientStop> {
match selected(app) {
Some(Target::Stop(stops, _)) => stops,
+38 -2
View File
@@ -496,7 +496,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
(Some(c), _) => components(mode, &c),
(None, _) => vec![0.0; mode.labels().len()],
};
super::recent_colors_row(app, ui);
let recent = super::recent_colors_row(app, ui);
widgets::divider(ui);
// An edit of the active colour with its displayed components (`new`), or a colour clicked with
// Alt held for the inactive proxy (`behind`).
@@ -585,6 +585,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
*new = Some((c, phase, components(mode, &c)));
}
};
// A recent colour, like the chips, recolours the selected gradient stop (or point).
if let Some(c) = recent {
pick(c, Live::Released, &mut new);
}
// None / Black / White chips and the hex field.
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
@@ -691,12 +695,23 @@ mod tests {
/// Run the panel for one frame with `events` and `modifiers`; returns the spectrum's rect (when
/// the options are shown) and the panel's height.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, modifiers: egui::Modifiers) -> (Option<Rect>, f32) {
frame_with(app, ctx, events, modifiers, "color-spectrum")
}
/// [`frame`] returning the rect of the panel's widget `id` instead of the spectrum's.
fn frame_with(
app: &mut VectorcraftApp,
ctx: &egui::Context,
events: Vec<egui::Event>,
modifiers: egui::Modifiers,
id: impl std::hash::Hash + std::fmt::Debug,
) -> (Option<Rect>, f32) {
let mut out = (None, 0.0);
let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect();
let input = egui::RawInput { events, ..Default::default() };
let mut full = ctx.run_ui(input, |ui| {
show(app, ui);
out = (ctx.read_response(ui.id().with("color-spectrum")).map(|r| r.rect), ui.min_rect().height());
out = (ctx.read_response(ui.id().with(&id)).map(|r| r.rect), ui.min_rect().height());
});
full.textures_delta.clear();
out
@@ -712,6 +727,27 @@ mod tests {
frame(app, ctx, vec![], egui::Modifiers::NONE);
}
#[test]
fn a_recent_colour_recolours_the_selected_gradient_stop() {
// #835: it replaced the whole gradient with the colour.
let mut app = app();
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [10, 35], "end": [60, 35]}})).unwrap();
app.select_tool("gradient");
app.run("gradient.selectStop", json!({"index": 1})).unwrap();
let red = app.session.recent_colors.iter().position(|c| c.to_hex() == "#ff0000").expect("red is a recent colour");
let ctx = egui::Context::default();
let none = egui::Modifiers::NONE;
let chip = frame_with(&mut app, &ctx, vec![], none, ("recent", red)).0.expect("the chip is drawn").center();
let button = |pressed| egui::Event::PointerButton { pos: chip, button: egui::PointerButton::Primary, pressed, modifiers: none };
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(chip), button(true)], none);
frame(&mut app, &ctx, vec![button(false)], none);
let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("still a gradient") };
let hex: Vec<String> = g.gradient.stops.iter().map(|s| s.color.to_hex()).collect();
assert_eq!(hex, ["#ffffff", "#ff0000"]);
}
fn paints_hex(app: &VectorcraftApp) -> (String, String) {
let (f, s) = crate::panels::current_paints(app);
(f.color().map(|c| c.to_hex()).unwrap_or_default(), s.color().map(|c| c.to_hex()).unwrap_or_default())
+3 -5
View File
@@ -412,8 +412,8 @@ pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str {
}
/// "Recent Colors" header + a row of chips (the Session's recent colours, which every paint
/// command feeds); clicking one applies it to the active proxy (Alt: the inactive one).
pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) {
/// command feeds); returns the one clicked, for the caller to apply.
pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Color> {
let t = Tokens::get(ui.ctx());
crate::widgets::subheader(ui, "Recent Colors");
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
@@ -430,9 +430,7 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) {
chosen = Some(*c);
}
}
if let Some(c) = chosen {
apply_click(app, ui, json!({"color": color_json(&c)}));
}
chosen
}
/// A colour as command JSON, keeping its model.
+4 -1
View File
@@ -642,7 +642,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
}
});
ui.add_space(4.0);
super::recent_colors_row(app, ui);
// A recent colour paints the active proxy (Alt: the inactive one), as a swatch does.
if let Some(c) = super::recent_colors_row(app, ui) {
super::apply_click(app, ui, json!({"color": super::color_json(&c)}));
}
widgets::divider(ui);
widgets::subheader(ui, tl!("Swatch Tiles"));
let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), tl!("Find")) } else { String::new() };