vectorcraft/crates/ui-egui/src/panels/stroke.rs

511 lines
23 KiB
Rust

//! Stroke panel: weight spinner + presets, cap / corner / align toggles, miter limit, dashed line
//! with three dash/gap pairs, arrowheads with drawn previews, arrow scale, width profiles.
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile};
use vectorcraft_engine::inspect::StrokeMixed;
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
pub const WEIGHT_PRESETS: [f64; 22] =
[0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
/// The silhouette drawn for profile `id` (None draws the plain uniform bar).
fn profile_of(id: &str) -> Option<WidthProfile> {
WidthProfile::preset(id).filter(|_| id != "uniform")
}
/// The Arrowheads Align buttons: (alignment, `stroke.set` value, icon, tooltip).
const ARROW_ALIGN: [(ArrowAlign, &str, &str, &str); 2] = [
(ArrowAlign::Extend, "extend", "dc-arrow-extend", "Tip extends past the end point"),
(ArrowAlign::Tip, "tip", "dc-arrow-tip", "Tip on the end point"),
];
/// Which Align button is on, and whether they are usable (only a stroke with a head can be aligned).
fn arrow_align_state(st: Option<&StrokeLayer>) -> (ArrowAlign, bool) {
st.map_or((ArrowAlign::default(), false), |s| (s.arrow_align, s.start_arrow.is_some() || s.end_arrow.is_some()))
}
/// Dash pattern → the panel's six dash/gap fields (None = empty field).
pub fn dash_fields(pattern: &[f64]) -> [Option<f64>; 6] {
let mut out = [None; 6];
for (i, v) in pattern.iter().take(6).enumerate() {
out[i] = Some(*v);
}
out
}
/// The dash/gap fields and alignment the Dashed Line section shows: the stroke's dashes, or with
/// dashes off the last ones turned off (`last`), at first 12 pt dashes fitted to corners.
fn dash_state(dash: Option<&Dash>, last: Option<([Option<f64>; 6], bool)>) -> ([Option<f64>; 6], bool) {
match dash {
Some(d) => (dash_fields(&d.pattern), d.align_corners),
None => last.unwrap_or(([Some(12.0), None, None, None, None, None], true)),
}
}
/// Six dash/gap fields → a dash pattern: stops at the first empty dash; a dash without a gap
/// repeats its length as the gap (Illustrator's behaviour).
pub fn dash_pattern(fields: &[Option<f64>; 6]) -> Vec<f64> {
let mut out = vec![];
for pair in 0..3 {
let Some(d) = fields[pair * 2] else { break };
let g = fields[pair * 2 + 1].unwrap_or(d);
out.push(d.max(0.0));
out.push(g.max(0.0));
}
if out.iter().all(|v| *v == 0.0) {
out.clear();
}
out
}
fn arrow_label(a: Option<Arrowhead>) -> String {
match a {
None => "None".into(),
Some(a) => {
let s = format!("{a:?}");
s.replace("Open", " (open)")
}
}
}
/// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the
/// selected characters' stroke.
fn set(app: &mut VectorcraftApp, p: Value) {
if character::text_editing(app).is_some() {
character::range_style(app, json!({ "strokeOptions": p }));
} else {
app.run("stroke.set", p).ok();
}
}
/// The weight the Stroke panel, Control bar and Properties panel show: blank where the selected
/// objects differ, the default for new art without a stroke.
pub(crate) fn shown_weight(app: &VectorcraftApp, st: Option<&StrokeLayer>, mixed: &StrokeMixed) -> Option<f64> {
(!mixed.weight).then(|| st.map_or(app.session.paint.stroke_width, |s| s.width))
}
/// The weight spinner (Stroke panel, Control bar): Units > Stroke, presets, blank when mixed.
pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weight: Option<f64>, width: f32) {
if let Some(w) = widgets::spin_field(ui, id, weight, app.session.stroke_unit(), width, 1.0, 0.0, &WEIGHT_PRESETS) {
set(app, json!({"weight": w}));
}
}
/// The Stroke panel in a popover anchored to `resp` (the Control bar's and the Properties
/// panel's Stroke links), which a click on it toggles.
pub(crate) fn popover(app: &mut VectorcraftApp, resp: &egui::Response) {
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_width(260.0);
show(app, ui);
});
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let st = current_stroke(app);
let mixed = stroke_mixed(app, ui.ctx());
let unit = app.session.stroke_unit();
let weight = shown_weight(app, st.as_ref(), &mixed);
let hidden: bool = pstate(ui.ctx(), "stroke-hide-options");
let label_w = 64.0;
let row_label = |ui: &mut Ui, s: &str| {
ui.add_sized(vec2(label_w, 24.0), egui::Label::new(egui::RichText::new(s).size(12.5).color(t.text)).halign(egui::Align::RIGHT));
};
ui.horizontal(|ui| {
row_label(ui, "Weight:");
weight_field(app, ui, "stroke-weight", weight, 120.0);
});
if hidden {
return;
}
// Mixed values light no button.
let cap = st.as_ref().map(|s| s.cap).filter(|_| !mixed.cap);
let join = st.as_ref().map(|s| s.join).filter(|_| !mixed.join);
let align = st.as_ref().map(|s| s.align).filter(|_| !mixed.align);
ui.horizontal(|ui| {
row_label(ui, "Cap:");
for (v, icon, tip, name) in [
(LineCap::Butt, "dc-cap-butt", "Butt Cap", "butt"),
(LineCap::Round, "dc-cap-round", "Round Cap", "round"),
(LineCap::Square, "dc-cap-square", "Projecting Cap", "square"),
] {
if widgets::icon_button(ui, icon, tip, cap == Some(v), 24.0).clicked() {
set(app, json!({"cap": name}));
}
}
});
ui.horizontal(|ui| {
row_label(ui, "Corner:");
for (v, icon, tip, name) in [
(LineJoin::Miter, "dc-join-miter", "Miter Join", "miter"),
(LineJoin::Round, "dc-join-round", "Round Join", "round"),
(LineJoin::Bevel, "dc-join-bevel", "Bevel Join", "bevel"),
] {
if widgets::icon_button(ui, icon, tip, join == Some(v), 24.0).clicked() {
set(app, json!({"join": name}));
}
}
widgets::dim_label(ui, "Limit:");
let lim = st.as_ref().map(|s| s.miter_limit).unwrap_or(10.0);
if !matches!(join, Some(LineJoin::Round | LineJoin::Bevel)) {
if let Some(v) = widgets::mixed_field(ui, "stroke-limit", (!mixed.miter_limit).then_some(lim), " x", 0, 50.0) {
set(app, json!({"miterLimit": v.clamp(1.0, 500.0)}));
}
} else {
ui.label(egui::RichText::new(format!("{lim:.0} x")).color(t.text_disabled));
}
});
ui.horizontal(|ui| {
row_label(ui, "Align Stroke:");
for (v, icon, tip, name) in [
(StrokeAlign::Center, "dc-stroke-center", "Align Stroke to Center", "center"),
(StrokeAlign::Inside, "dc-stroke-inside", "Align Stroke to Inside", "inside"),
(StrokeAlign::Outside, "dc-stroke-outside", "Align Stroke to Outside", "outside"),
] {
// Only closed paths take a stroke inside or outside (not open paths, not type).
let enabled = v == StrokeAlign::Center || mixed.can_align;
if widgets::icon_button_enabled(ui, icon, tip, align == Some(v), enabled, 24.0).clicked() {
set(app, json!({"align": name}));
}
}
});
widgets::divider(ui);
// Dashed line.
let dash = st.as_ref().and_then(|s| s.dash.as_ref());
let (fields, align_corners) = dash_state(dash, pstate(ui.ctx(), "stroke-dash-last"));
let shown = if mixed.dash { [None; 6] } else { fields };
ui.horizontal(|ui| {
if widgets::check(ui, "Dashed Line", dash.is_some(), st.is_some()) {
if dash.is_some() {
set_pstate(ui.ctx(), "stroke-dash-last", Some((fields, align_corners)));
set(app, json!({"dash": null}));
} else {
set(app, json!({"dash": dash_pattern(&fields), "alignDashes": align_corners}));
}
}
ui.add_space((ui.available_width() - 56.0).max(0.0));
let on = dash.is_some();
if widgets::icon_button_enabled(ui, "dc-dash-exact", "Exact dash lengths", on && !align_corners, on, 24.0).clicked() {
set(app, json!({"alignDashes": false}));
}
if widgets::icon_button_enabled(ui, "dc-dash-align", "Fit dashes to corners and ends", on && align_corners, on, 24.0).clicked() {
set(app, json!({"alignDashes": true}));
}
});
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
let fw = ((ui.available_width() - 15.0) / 6.0).clamp(28.0, 40.0);
let mut nf = shown;
let mut changed = false;
for (i, f) in shown.iter().enumerate() {
let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field(ui, ("dash", i), *f, unit, fw)).inner;
if let Some(x) = r {
nf[i] = x.map(|v| v.max(0.0));
changed = true;
}
}
if changed && dash.is_some() {
// The command keeps the dash offset and alignment.
set(app, json!({"dash": dash_pattern(&nf)}));
}
});
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
let fw = ((ui.available_width() - 15.0) / 6.0).clamp(28.0, 40.0);
for lbl in ["dash", "gap", "dash", "gap", "dash", "gap"] {
ui.add_sized(vec2(fw, 12.0), egui::Label::new(egui::RichText::new(lbl).size(10.5).color(t.text_dim)));
}
});
widgets::divider(ui);
// Arrowheads.
let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow), st.as_ref().and_then(|s| s.end_arrow));
ui.horizontal(|ui| {
row_label(ui, "Arrowheads:");
if let Some(a) = arrow_dropdown(ui, "arrow-start", sa, true) {
set(app, json!({"startArrow": a.map(|a| format!("{a:?}"))}));
}
if let Some(a) = arrow_dropdown(ui, "arrow-end", ea, false) {
set(app, json!({"endArrow": a.map(|a| format!("{a:?}"))}));
}
if widgets::icon_button_enabled(ui, "arrow-left-right", "Swap start and end arrowheads", false, st.is_some(), 22.0).clicked() {
app.run("stroke.setAdvanced", json!({"swapArrows": true})).ok();
}
});
let scale = st.as_ref().map(|s| s.arrow_scale).unwrap_or((100.0, 100.0));
let linked: bool = pstate(ui.ctx(), "arrow-scale-link");
// Scale and Align only mean something for a stroke with a head.
let (arrow_align, has_head) = arrow_align_state(st.as_ref());
ui.horizontal(|ui| {
row_label(ui, "Scale:");
let mut ns = None;
ui.add_enabled_ui(has_head, |ui| {
if let Some(v) = widgets::plain_field(ui, "arrow-scale-s", scale.0, "%", 0, 56.0) {
ns = Some((v, if linked { v } else { scale.1 }));
}
if let Some(v) = widgets::plain_field(ui, "arrow-scale-e", scale.1, "%", 0, 56.0) {
ns = Some((if linked { v } else { scale.0 }, v));
}
});
if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, "Link start and end arrowhead scales", linked, 22.0).clicked() {
set_pstate(ui.ctx(), "arrow-scale-link", !linked);
}
if let Some((a, b)) = ns {
app.run("stroke.setAdvanced", json!({"arrowScale": [a, b]})).ok();
}
});
ui.horizontal(|ui| {
row_label(ui, "Align:");
for (v, name, icon, tip) in ARROW_ALIGN {
if widgets::icon_button_enabled(ui, icon, tip, arrow_align == v, has_head, 22.0).clicked() {
set(app, json!({"arrowAlign": name}));
}
}
});
widgets::divider(ui);
// Profile.
let pid = WidthProfile::id_of(st.as_ref().and_then(|s| s.profile.as_ref()));
ui.horizontal(|ui| {
row_label(ui, "Profile:");
if let Some(id) = profile_dropdown(ui, pid) {
set(app, json!({"profile": id}));
}
let can_flip = st.as_ref().is_some_and(|s| s.profile.is_some());
if widgets::icon_button_enabled(ui, "flip-horizontal-2", "Flip Along", false, can_flip, 22.0).clicked() {
app.run("stroke.setAdvanced", json!({"flipProfile": "along"})).ok();
}
if widgets::icon_button_enabled(ui, "flip-vertical-2", "Flip Across", false, can_flip, 22.0).clicked() {
app.run("stroke.setAdvanced", json!({"flipProfile": "across"})).ok();
}
});
}
/// Draw an arrowhead preview: a line with the head at the left (`start`) or right end.
fn paint_arrow(ui: &Ui, r: Rect, a: Option<Arrowhead>, start: bool, color: Color32) {
let y = r.center().y;
let (tip_x, back) = if start { (r.left() + 4.0, 1.0) } else { (r.right() - 4.0, -1.0) };
let s = Stroke::new(1.5, color);
let line_from = if a.is_some() { tip_x + back * 8.0 } else { tip_x };
let other = if start { r.right() - 4.0 } else { r.left() + 4.0 };
ui.painter().line_segment([pos2(line_from, y), pos2(other, y)], s);
let Some(a) = a else { return };
let p = |dx: f32, dy: f32| -> Pos2 { pos2(tip_x + back * dx, y + dy) };
let solid = |pts: Vec<Pos2>| egui::Shape::convex_polygon(pts, color, Stroke::NONE);
let open = |pts: Vec<Pos2>| egui::Shape::closed_line(pts, s);
let shape = match a {
Arrowhead::Triangle => solid(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]),
Arrowhead::TriangleOpen => open(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]),
Arrowhead::Arrow => solid(vec![p(0.0, 0.0), p(10.0, -5.0), p(7.0, 0.0), p(10.0, 5.0)]),
Arrowhead::ArrowOpen => egui::Shape::line(vec![p(9.0, -5.0), p(0.0, 0.0), p(9.0, 5.0)], s),
Arrowhead::Circle => egui::Shape::circle_filled(p(4.5, 0.0), 4.5, color),
Arrowhead::CircleOpen => egui::Shape::circle_stroke(p(4.5, 0.0), 4.0, s),
Arrowhead::Square => egui::Shape::rect_filled(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, color),
Arrowhead::SquareOpen => egui::Shape::rect_stroke(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, s, StrokeKind::Middle),
Arrowhead::Diamond => solid(vec![p(0.0, 0.0), p(5.0, -5.0), p(10.0, 0.0), p(5.0, 5.0)]),
Arrowhead::Bar => egui::Shape::line_segment([p(0.0, -6.0), p(0.0, 6.0)], Stroke::new(2.0, color)),
};
ui.painter().add(shape);
}
/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice).
fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<Arrowhead>, start: bool) -> Option<Option<Arrowhead>> {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(64.0, 24.0), Sense::click());
ui.painter().rect_filled(r, 2, t.input);
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside);
let body = Rect::from_min_max(r.min + vec2(3.0, 0.0), pos2(r.right() - 16.0, r.bottom()));
paint_arrow(ui, body, cur, start, t.text_strong);
icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(r.right() - 8.0, r.center().y), vec2(10.0, 10.0)), t.icon);
let resp = resp.on_hover_text(if start { "Start arrowhead" } else { "End arrowhead" });
let mut out = None;
egui::Popup::menu(&resp).id(egui::Id::new(("arrow-pop", id))).show(|ui| {
ui.set_min_width(140.0);
let opts: Vec<Option<Arrowhead>> = std::iter::once(None).chain(Arrowhead::ALL.iter().copied().map(Some)).collect();
for a in opts {
let (row, rr) = ui.allocate_exact_size(vec2(140.0, 22.0), Sense::click());
if a == cur {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if rr.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong);
ui.painter().text(
row.left_center() + vec2(66.0, 0.0),
egui::Align2::LEFT_CENTER,
arrow_label(a),
egui::FontId::proportional(11.5),
t.text,
);
if rr.clicked() {
out = Some(a);
ui.close();
}
}
});
out
}
/// Draw a width profile's silhouette into `r`.
pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color32) {
let n = 32;
let half = r.height() / 2.0 - 1.0;
let mut top = vec![];
let mut bot = vec![];
for i in 0..=n {
let tt = i as f64 / n as f64;
let (l, rr) = prof.map(|p| p.at(tt)).unwrap_or((0.35, 0.35));
let x = r.left() + tt as f32 * r.width();
top.push(pos2(x, r.center().y - l as f32 * half));
bot.push(pos2(x, r.center().y + rr as f32 * half));
}
let mut mesh = egui::Mesh::default();
for i in 0..=n {
mesh.colored_vertex(top[i], color);
mesh.colored_vertex(bot[i], color);
}
for i in 0..n as u32 {
let a = i * 2;
mesh.add_triangle(a, a + 1, a + 2);
mesh.add_triangle(a + 1, a + 3, a + 2);
}
ui.painter().add(egui::Shape::mesh(mesh));
}
pub(crate) fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click());
ui.painter().rect_filled(r, 2, t.input);
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside);
let body = Rect::from_min_max(r.min + vec2(6.0, 5.0), pos2(r.right() - 20.0, r.bottom() - 5.0));
paint_profile(ui, body, profile_of(cur).as_ref(), t.text_strong);
icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), t.icon);
let resp = resp.on_hover_text("Variable Width Profile");
let mut out = None;
egui::Popup::menu(&resp).show(|ui| {
for ProfilePreset { id, label, .. } in WidthProfile::PRESETS {
let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click());
if id == cur {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if rr.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
paint_profile(ui, Rect::from_min_size(row.min + vec2(6.0, 6.0), vec2(70.0, 14.0)), profile_of(id).as_ref(), t.text_strong);
ui.painter().text(row.left_center() + vec2(84.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(11.5), t.text);
if rr.clicked() {
out = Some(id);
ui.close();
}
}
});
out
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let hidden: bool = pstate(ui.ctx(), "stroke-hide-options");
if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) {
set_pstate(ui.ctx(), "stroke-hide-options", !hidden);
}
menu_item(ui, "Add to Profiles", false, false);
menu_item(ui, "Delete Profile", false, false);
if menu_item(ui, "Reset Profile", current_stroke(app).is_some_and(|s| s.profile.is_some()), false) {
set(app, json!({"profile": "uniform"}));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dash_fields_roundtrip() {
let f = dash_fields(&[12.0, 6.0, 2.0, 3.0]);
assert_eq!(f, [Some(12.0), Some(6.0), Some(2.0), Some(3.0), None, None]);
assert_eq!(dash_pattern(&f), vec![12.0, 6.0, 2.0, 3.0]);
}
#[test]
fn dash_without_gap_repeats_dash() {
assert_eq!(dash_pattern(&[Some(5.0), None, None, Some(9.0), None, None]), vec![5.0, 5.0]);
assert!(dash_pattern(&[None; 6]).is_empty());
assert!(dash_pattern(&[Some(0.0), Some(0.0), None, None, None, None]).is_empty());
}
#[test]
fn profile_silhouettes_and_arrow_labels() {
assert!(profile_of("uniform").is_none());
assert_eq!(profile_of("lens"), Some(WidthProfile::lens()));
assert!(profile_of("custom").is_none());
assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)");
}
#[test]
fn arrow_align_buttons_need_a_head() {
let mut st = StrokeLayer::new(vectorcraft_color::Paint::None, 1.0);
assert_eq!(arrow_align_state(None), (ArrowAlign::Extend, false));
assert_eq!(arrow_align_state(Some(&st)), (ArrowAlign::Extend, false));
st.start_arrow = Some(Arrowhead::Bar);
st.arrow_align = ArrowAlign::Tip;
assert_eq!(arrow_align_state(Some(&st)), (ArrowAlign::Tip, true));
for (_, _, icon, _) in ARROW_ALIGN {
assert!(icons::exists(icon), "{icon}");
}
}
/// Run the panel and its ≡ menu for one headless frame.
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
show(app, ui);
menu(app, ui);
});
out.textures_delta.clear();
}
#[test]
fn panel_draws_for_lines_with_and_without_heads() {
use vectorcraft_engine::Session;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let r = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
r(&mut app, "file.new", json!({"width": 100, "height": 100}));
frame(&mut app);
r(&mut app, "shape.line", json!({"x1": 10, "y1": 50, "x2": 90, "y2": 50}));
frame(&mut app);
for p in [json!({"endArrow": "ArrowOpen", "arrowAlign": "tip"}), json!({"profile": "lens"}), json!({"dash": [0, 6], "cap": "round"})] {
r(&mut app, "stroke.set", p);
frame(&mut app);
}
let st = current_stroke(&app).unwrap();
assert_eq!((st.end_arrow, st.arrow_align), (Some(Arrowhead::ArrowOpen), ArrowAlign::Tip));
assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "lens");
}
#[test]
fn while_typing_the_panel_strokes_the_selected_characters() {
use vectorcraft_doc::NodeKind;
use vectorcraft_engine::{Session, ViewInfo};
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let v = ViewInfo::default();
app.session.select_tool("type", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
app.session.pointer(&PointerEvent::new(k, 50.0, 200.0), v).unwrap();
}
app.session.tool_text("Make this bold", v).unwrap();
app.session.set_tool_option("select", &json!({"start": 5, "end": 9}));
set(&mut app, json!({"weight": 4, "join": "round"}));
let id = app.session.active().unwrap().selection.objects[0];
let NodeKind::Text(t) = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("type") };
let joins: Vec<(&str, LineJoin, f64)> = t.runs.iter().map(|r| (r.text.as_str(), r.style.stroke_join, r.style.stroke_width)).collect();
assert_eq!(joins, [("Make ", LineJoin::Miter, 0.0), ("this", LineJoin::Round, 4.0), (" bold", LineJoin::Miter, 0.0)]);
// The panel shows the selected characters' stroke.
assert_eq!(current_stroke(&app).map(|s| (s.join, s.width)), Some((LineJoin::Round, 4.0)));
frame(&mut app);
}
}