On-canvas gradient handles and auto layout padding/gap handles; compact sizing labels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-09 13:52:42 -04:00
co-authored by Claude Opus 5.5
parent 64516289a9
commit ae30f9ef21
3 changed files with 298 additions and 21 deletions
+4 -21
View File
@@ -123,6 +123,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
painter.image(tex.id(), Rect::from_min_size(tl, vec2(w, h)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
overlays(app, &painter, &t);
crate::handles::draw(app, &painter);
// Cursor.
if resp.hovered() {
let c = if app.canvas.space_pan || app.canvas.middle_pan.is_some() {
@@ -250,6 +251,9 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) {
}
return;
}
if !app.canvas.dragging && resp.hovered() && crate::handles::input(app, &ctx, pos) {
return;
}
if resp.secondary_clicked() {
// Right-click: select what's under the pointer, then the context menu.
let ev = pointer_event(app, PointerKind::Hover, pos, &mods);
@@ -602,27 +606,6 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE);
}
}
// Auto layout: show padding/gap hints on a single selected AL frame.
if sel.len() == 1
&& let Some(id) = sel.first()
&& let Some(n) = doc.node(*id)
&& let Some(al) = &n.layout
&& zoom > 0.25
{
let w = doc.world_transform(*id);
let [pt, pr, pb, pl] = al.padding;
let pink = Color32::from_rgba_unmultiplied(0xF2, 0x48, 0x8C, 30);
for (x0, y0, x1, y1) in
[(0.0, 0.0, n.width, pt), (n.width - pr, 0.0, n.width, n.height), (0.0, n.height - pb, n.width, n.height), (0.0, 0.0, pl, n.height)]
{
if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 {
continue;
}
let a = w * Point::new(x0, y0);
let b = w * Point::new(x1, y1);
painter.rect_filled(Rect::from_two_pos(app.to_screen(a.x, a.y), app.to_screen(b.x, b.y)), CornerRadius::ZERO, pink);
}
}
}
// Text editing: caret and selection.
if let Some(te) = editing
+293
View File
@@ -0,0 +1,293 @@
//! On-canvas editing handles that sit above the pointer tools: gradient handles (while a
//! gradient fill is open in the colour picker) and auto layout padding/gap handles.
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Shape, Stroke, StrokeKind, pos2, vec2};
use figcraft_color::{Paint, PaintKind};
use figcraft_doc::{LayoutMode, NodeId};
use figcraft_geom::Point;
use serde_json::json;
use crate::FigApp;
use crate::colorpicker::PickerTarget;
use crate::theme::{ACCENT, semibold};
const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C);
#[derive(Clone, Debug)]
enum Drag {
/// Gradient handle 0 = from, 1 = to, 2 = width.
Gradient {
id: NodeId,
fills: bool,
index: usize,
which: usize,
},
/// Padding side 0 top, 1 right, 2 bottom, 3 left.
Padding {
id: NodeId,
side: usize,
start: Pos2,
value: f64,
},
Gap {
id: NodeId,
start: Pos2,
value: f64,
vertical: bool,
},
}
fn drag_id() -> Id {
Id::new("figcraft_handle_drag")
}
/// The gradient being edited (single node, picker open on a gradient paint).
fn gradient_target(app: &FigApp) -> Option<(NodeId, bool, usize, figcraft_color::Gradient)> {
let Some(PickerTarget::Paint { fills, index, ids }) = &app.ui.picker else { return None };
let id = *ids.first()?;
if ids.len() != 1 {
return None;
}
let n = app.session.doc().ok()?.node(id)?;
let list = if *fills { &n.fills } else { &n.strokes };
match &list.get(*index)?.kind {
PaintKind::Gradient(g) => Some((id, *fills, *index, g.clone())),
_ => None,
}
}
fn unit_to_screen(app: &FigApp, id: NodeId, u: (f64, f64)) -> Option<Pos2> {
let doc = app.session.doc().ok()?;
let n = doc.node(id)?;
let p = doc.world_transform(id) * Point::new(u.0 * n.width, u.1 * n.height);
Some(app.to_screen(p.x, p.y))
}
fn screen_to_unit(app: &FigApp, id: NodeId, s: Pos2) -> Option<(f64, f64)> {
let doc = app.session.doc().ok()?;
let n = doc.node(id)?;
let p = app.to_page(s);
let l = doc.world_transform(id).inverse() * p;
Some((l.x / n.width.max(1e-9), l.y / n.height.max(1e-9)))
}
/// Auto layout zones of a single selected AL frame, in screen space: padding bands and gaps.
fn al_zones(app: &FigApp) -> Option<(NodeId, Vec<(usize, Rect, f64)>, Vec<Rect>, f64, bool)> {
let ds = app.session.ds().ok()?;
if ds.selection.len() != 1 || ds.text_edit.is_some() || ds.vector_edit.is_some() {
return None;
}
let id = *ds.selection.first()?;
let doc = &ds.doc;
let n = doc.node(id)?;
let al = n.layout.as_ref()?;
// Only for unrotated frames (axis-aligned bands).
let w = doc.world_transform(id);
if figcraft_geom::rotation_degrees(w).abs() > 0.01 {
return None;
}
let to = |x: f64, y: f64| {
let p = w * Point::new(x, y);
app.to_screen(p.x, p.y)
};
let [pt, pr, pb, pl] = al.padding;
let (fw, fh) = (n.width, n.height);
let bands = vec![
(0, Rect::from_two_pos(to(0.0, 0.0), to(fw, pt.max(4.0))), pt),
(1, Rect::from_two_pos(to(fw - pr.max(4.0), 0.0), to(fw, fh)), pr),
(2, Rect::from_two_pos(to(0.0, fh - pb.max(4.0)), to(fw, fh)), pb),
(3, Rect::from_two_pos(to(0.0, 0.0), to(pl.max(4.0), fh)), pl),
];
let vertical = al.mode == LayoutMode::Vertical;
let mut gaps = Vec::new();
if al.mode != LayoutMode::Grid && !al.wrap {
let kids: Vec<_> = n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible && !c.child_layout.absolute).collect();
for pair in kids.windows(2) {
let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
let ra = figcraft_geom::transform_rect_bbox(a.transform, a.local_rect());
let rb = figcraft_geom::transform_rect_bbox(b.transform, b.local_rect());
let r =
if vertical { Rect::from_two_pos(to(pl, ra.y1), to(fw - pr, rb.y0)) } else { Rect::from_two_pos(to(ra.x1, pt), to(rb.x0, fh - pb)) };
if r.width() > 0.5 && r.height() > 0.5 {
gaps.push(r);
}
}
}
Some((id, bands, gaps, al.gap, vertical))
}
/// Draw the handles.
pub fn draw(app: &FigApp, painter: &egui::Painter) {
let hover = painter.ctx().input(|i| i.pointer.hover_pos());
if let Some((id, _, _, g)) = gradient_target(app) {
let (Some(a), Some(b), Some(c)) = (unit_to_screen(app, id, g.from), unit_to_screen(app, id, g.to), unit_to_screen(app, id, g.width)) else {
return;
};
painter.line_segment([a, b], Stroke::new(2.0, Color32::WHITE));
painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120)));
if g.kind != figcraft_color::GradientKind::Linear {
painter.add(Shape::dashed_line(&[a, c], Stroke::new(1.0, Color32::WHITE), 4.0, 3.0));
}
for s in g.sorted_stops() {
let p = a + (b - a) * s.position;
let r = Rect::from_center_size(p, vec2(12.0, 12.0));
painter.rect_filled(r, CornerRadius::same(2), crate::theme::c32(s.color.with_alpha(1.0)));
painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
}
for (i, p) in [a, b, c].iter().enumerate() {
if i == 2 && g.kind == figcraft_color::GradientKind::Linear {
continue;
}
painter.circle_filled(*p, 5.0, Color32::WHITE);
painter.circle_stroke(*p, 5.0, Stroke::new(1.5, ACCENT));
}
return;
}
let tool_move = app.session.tool.tool == figcraft_engine::ToolId::Move && !app.session.tool.busy();
let dragging: Option<Drag> = painter.ctx().data(|d| d.get_temp(drag_id()));
if !tool_move && dragging.is_none() {
return;
}
let Some((_, bands, gaps, gap, _)) = al_zones(app) else { return };
let active_side = match &dragging {
Some(Drag::Padding { side, .. }) => Some(*side),
_ => None,
};
for (side, r, v) in &bands {
let hot = hover.is_some_and(|h| r.contains(h)) || active_side == Some(*side);
if hot {
painter.rect_filled(*r, CornerRadius::ZERO, PINK.gamma_multiply(0.25));
let label = crate::canvas::fmt(*v);
let galley = painter.layout_no_wrap(label, semibold(10.0), Color32::WHITE);
let lr = Rect::from_center_size(r.center(), galley.size() + vec2(8.0, 4.0));
painter.rect_filled(lr, CornerRadius::same(3), PINK);
painter.galley(lr.min + vec2(4.0, 2.0), galley, Color32::WHITE);
}
}
let gap_drag = matches!(dragging, Some(Drag::Gap { .. }));
for r in &gaps {
let hot = hover.is_some_and(|h| r.contains(h)) || gap_drag;
if hot {
painter.rect_filled(*r, CornerRadius::ZERO, PINK.gamma_multiply(0.3));
let c = r.center();
painter.line_segment([pos2(c.x - 3.0, c.y), pos2(c.x + 3.0, c.y)], Stroke::new(1.0, PINK));
let galley = painter.layout_no_wrap(crate::canvas::fmt(gap), semibold(10.0), Color32::WHITE);
let lr = Rect::from_center_size(c, galley.size() + vec2(8.0, 4.0));
painter.rect_filled(lr, CornerRadius::same(3), PINK);
painter.galley(lr.min + vec2(4.0, 2.0), galley, Color32::WHITE);
}
}
let _ = Align2::CENTER_CENTER;
}
/// Pointer handling; returns true when a handle owns the pointer this frame.
pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
let (pressed, down, released, shift) = ctx.input(|i| {
(
i.pointer.button_pressed(egui::PointerButton::Primary),
i.pointer.primary_down(),
i.pointer.button_released(egui::PointerButton::Primary),
i.modifiers.shift,
)
});
let dragging: Option<Drag> = ctx.data(|d| d.get_temp(drag_id()));
if let Some(drag) = dragging {
if down {
apply(app, &drag, pos, shift);
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
}
if released || !down {
let _ = app.session.commit_interaction();
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
}
return true;
}
// Hover cursors / press to start.
if let Some((id, fills, index, g)) = gradient_target(app) {
let pts = [unit_to_screen(app, id, g.from), unit_to_screen(app, id, g.to), unit_to_screen(app, id, g.width)];
for (which, p) in pts.iter().enumerate() {
if which == 2 && g.kind == figcraft_color::GradientKind::Linear {
continue;
}
if let Some(p) = p
&& (*p - pos).length() <= 8.0
{
ctx.set_cursor_icon(egui::CursorIcon::Grab);
if pressed {
let _ = app.session.begin_interaction("Edit gradient");
ctx.data_mut(|d| d.insert_temp(drag_id(), Drag::Gradient { id, fills, index, which }));
}
return true;
}
}
return false;
}
if app.session.tool.tool != figcraft_engine::ToolId::Move || app.session.tool.busy() {
return false;
}
let Some((id, bands, gaps, gap, vertical)) = al_zones(app) else { return false };
for (side, r, v) in bands {
if r.contains(pos) {
ctx.set_cursor_icon(if side % 2 == 0 { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
if pressed {
let _ = app.session.begin_interaction("Padding");
ctx.data_mut(|d| d.insert_temp(drag_id(), Drag::Padding { id, side, start: pos, value: v }));
return true;
}
return false;
}
}
for r in gaps {
if r.contains(pos) {
ctx.set_cursor_icon(if vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
if pressed {
let _ = app.session.begin_interaction("Gap");
ctx.data_mut(|d| d.insert_temp(drag_id(), Drag::Gap { id, start: pos, value: gap, vertical }));
return true;
}
return false;
}
}
false
}
fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0).max(1e-6);
match drag {
Drag::Gradient { id, fills, index, which } => {
let Some(u) = screen_to_unit(app, *id, pos) else { return };
let Some(n) = app.session.doc().ok().and_then(|d| d.node(*id)).cloned() else { return };
let list = if *fills { &n.fills } else { &n.strokes };
let Some(mut paint) = list.get(*index).cloned() else { return };
if let PaintKind::Gradient(g) = &mut paint.kind {
match which {
0 => g.from = u,
1 => g.to = u,
_ => g.width = u,
}
}
let cmd = if *fills { "fill.set" } else { "stroke.set" };
let _ = app.session.execute(cmd, &json!({"ids": [id], "index": index, "paint": paint}));
let _: Option<Paint> = None;
}
Drag::Padding { id, side, start, value } => {
let d = f64::from(match side {
0 => pos.y - start.y,
1 => start.x - pos.x,
2 => start.y - pos.y,
_ => pos.x - start.x,
}) / zoom;
let v = (value + d).round().max(0.0);
let key = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].get(*side).copied().unwrap_or("paddingTop");
// Shift drags both sides of the axis together.
let props =
if shift { if side % 2 == 0 { json!({"paddingVertical": v}) } else { json!({"paddingHorizontal": v}) } } else { json!({ key: v }) };
let _ = app.session.execute("node.set", &json!({"ids": [id], "props": props}));
}
Drag::Gap { id, start, value, vertical } => {
let d = f64::from(if *vertical { pos.y - start.y } else { pos.x - start.x }) / zoom;
let v = (value + d).round().max(0.0);
let _ = app.session.execute("node.set", &json!({"ids": [id], "props": {"gap": v}}));
}
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod component_panel;
pub mod control;
pub mod credits;
pub mod dialogs;
pub mod handles;
pub mod home;
pub mod icons;
pub mod layers;