M3: line-end previews in the stroke menus; scroll events over the control channel (#76)

The Start point and End point menus draw each line end beside its name (none, round, square, line
arrow, triangle, reversed triangle, circle, diamond; mirrored for start points). `ui.input` takes
`{scroll: [dx, dy]}` mouse-wheel events, so scrolled panels can be driven and checked.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 13:53:00 +07:00 committed by GitHub
parent 50517a6431
commit a344b7a9a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 58 additions and 5 deletions

View File

@ -239,7 +239,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Layers panel | 5 | 83% | Tab to rename the next layer, visual QA of long lists | 1 |
| Shapes & vector | 8 | 74% | live boolean editing, arc handles on canvas, shape builder on strokes and open paths, one-sided widths | 4–8 |
| Paint | 7 | 78% | a wide-gamut canvas (egui draws sRGB, so Display P3 colours outside sRGB clip on screen; exports keep them), images' own embedded colour profiles (images are taken as sRGB) | 1–3 |
| Strokes | 3 | 80% | stroke styles, arrowhead previews in the menus | 2–3 |
| Strokes | 3 | 82% | stroke styles | 1–2 |
| Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 |
| Frames, groups, sections, masks | 5 | 75% | hiding section contents UI, section-level prototype/dev affordances | 2–4 |
| Auto layout & constraints | 9 | 72% | visual QA of nested edge cases, arrow-key moves across nested auto layout frames | 2–4 |

View File

@ -223,6 +223,14 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
}
} else if let Some(t) = e.get("text").and_then(Value::as_str) {
events.push(egui::Event::Text(t.to_string()));
} else if let Some(d) = e.get("scroll").and_then(pos) {
// Content moves by `d` points (negative y scrolls down, like a wheel turned towards you).
events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: d.to_vec2(),
phase: egui::TouchPhase::Move,
modifiers: mods,
});
} else if b(e, "leave") {
events.push(egui::Event::PointerGone);
} else {

View File

@ -1,6 +1,6 @@
//! The right panel: Design, Prototype and Inspect tabs.
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, Ui, pos2, vec2};
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use figcraft_color::{BlendMode, Paint, PaintKind};
use figcraft_doc::*;
use serde_json::{Value, json};
@ -1080,7 +1080,7 @@ fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
let resp = widgets::dropdown(ui, cap.map(StrokeCap::label).unwrap_or("Mixed"), half, None);
egui::Popup::menu(&resp).show(|ui| {
for c in StrokeCap::ALL {
if menu_item(ui, c.label(), "") {
if menu_item_with(ui, c.label(), "", |p, r, col| cap_art(p, r, col, c, key == "startCap")) {
app.set_props(&ids, json!({key: format!("{c:?}")}));
ui.close();
}
@ -1092,6 +1092,50 @@ fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
ui.add_space(4.0);
}
/// A short stroke ending in `cap` (on its left end for a start point), drawn at the right of a menu
/// row.
fn cap_art(p: &egui::Painter, r: Rect, col: Color32, cap: StrokeCap, start: bool) {
let y = r.center().y;
let (left, right) = (r.max.x - 58.0, r.max.x - 14.0);
// The capped end, the plain end, and the direction from the stroke into its cap.
let (tip, tail, d) = if start { (left, right, -1.0) } else { (right, left, 1.0) };
let at = |back: f32, dy: f32| pos2(tip - d * back, y + dy);
let line = |to: Pos2, w: f32| {
p.line_segment([pos2(tail, y), to], Stroke::new(w, col));
};
let fill = |pts: Vec<Pos2>| {
p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
};
match cap {
StrokeCap::None => line(at(0.0, 0.0), 4.0),
StrokeCap::Round => {
line(at(2.0, 0.0), 4.0);
p.circle_filled(at(2.0, 0.0), 2.0, col);
}
StrokeCap::Square => line(at(-2.0, 0.0), 4.0),
StrokeCap::LineArrow => {
line(at(0.0, 0.0), 1.5);
p.add(egui::Shape::line(vec![at(5.5, -5.5), at(0.0, 0.0), at(5.5, 5.5)], Stroke::new(1.5, col)));
}
StrokeCap::TriangleArrow => {
line(at(7.0, 0.0), 1.5);
fill(vec![at(0.0, 0.0), at(8.0, -4.5), at(8.0, 4.5)]);
}
StrokeCap::ReversedTriangle => {
line(at(8.0, 0.0), 1.5);
fill(vec![at(8.0, 0.0), at(0.0, -4.5), at(0.0, 4.5)]);
}
StrokeCap::CircleFilled => {
line(at(4.0, 0.0), 1.5);
p.circle_filled(at(4.0, 0.0), 4.0, col);
}
StrokeCap::DiamondFilled => {
line(at(5.0, 0.0), 1.5);
fill(vec![at(0.0, 0.0), at(5.0, -5.0), at(10.0, 0.0), at(5.0, 5.0)]);
}
}
}
fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool) {
let t = Tokens::get(ui.ctx());
let ids = ids.to_vec();

View File

@ -105,8 +105,9 @@ Raw window input for the next frame, as if from the mouse and keyboard: it reach
popovers and fields (`ui.pointer` and `ui.key` drive the canvas tools and shortcuts instead).
`events` is a list of `{move: [x, y]}`, `{down: [x, y], button?: "secondary"}`,
`{up: [x, y], button?}`, `{key, shift?, alt?, cmd?}` (pressed and released; egui key names such as
`ArrowLeft`, `Escape`, `A`), `{text}` (typed text) and `{leave: true}` (the pointer left the
window); positions are window points. Events of one call arrive in the same frame, so send a press
`ArrowLeft`, `Escape`, `A`), `{text}` (typed text), `{scroll: [dx, dy], shift?, alt?, cmd?}` (a
mouse wheel at the pointer: the content moves by that many points, so a negative `dy` scrolls down)
and `{leave: true}` (the pointer left the window); positions are window points. Events of one call arrive in the same frame, so send a press
and its release (or a double-click's two clicks) as separate calls. Returns `{queued}`.
```json