mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
refactor(ui-egui): move canvas.rs items to canvas/{artboards,context,cursor,drag,grid,home,input,outline,overlays,raster,task_bar,tool_overlays,view}.rs
This commit is contained in:
+56
-3360
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
use super::*;
|
||||
|
||||
/// The width an artboard needs on screen for its name to show (px): narrower, the name is left
|
||||
/// out rather than spilling over its neighbours.
|
||||
const MIN_LABELLED_ARTBOARD: f32 = 24.0;
|
||||
|
||||
/// Temp-memory key recording an artboard-name press before its edit field opens.
|
||||
pub(super) fn name_press_id() -> egui::Id {
|
||||
egui::Id::unique("canvas-artboard-name-press")
|
||||
}
|
||||
|
||||
/// Temp-memory key for the artboard name field currently being edited.
|
||||
pub(super) fn name_edit_id() -> egui::Id {
|
||||
egui::Id::unique("canvas-artboard-name-edit")
|
||||
}
|
||||
|
||||
/// A name being typed above an artboard. `fresh`: this frame opened it, so a focus loss from the
|
||||
/// press that started it is not the user leaving the field.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct ArtboardNameEdit {
|
||||
pub(super) index: usize,
|
||||
text: String,
|
||||
fresh: bool,
|
||||
}
|
||||
|
||||
/// The canvas label for `name`, laid out as [`artboard_label`] draws it.
|
||||
fn canvas_name_galley(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) -> Option<(std::sync::Arc<egui::Galley>, Pos2)> {
|
||||
if !width.is_finite() || width < MIN_LABELLED_ARTBOARD {
|
||||
return None;
|
||||
}
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color);
|
||||
job.wrap = egui::text::TextWrapping { max_width: width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
let galley = painter.layout_job(job);
|
||||
let at = corner - vec2(0.0, 4.0 + galley.size().y);
|
||||
Some((galley, at))
|
||||
}
|
||||
|
||||
/// The length on screen, in points, of each arm of an artboard's center mark (a cross), at any zoom.
|
||||
const CENTER_MARK_ARM: f32 = 6.0;
|
||||
|
||||
/// Artboard `ab`'s display marks in `color`, each line 1 point wide on screen at any zoom and
|
||||
/// turned with the view: with Show Cross Hairs, a line from the middle of each side through the
|
||||
/// center to the middle of the opposite side; with Show Center Mark, a cross at the center whose
|
||||
/// arms run along the artboard's sides, [`CENTER_MARK_ARM`] long on screen
|
||||
/// (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/modify-display-settings-of-artboards.html);
|
||||
/// with Show Video Safe Areas, the outlines of its action-safe and title-safe areas
|
||||
/// ([`vectorcraft_doc::Artboard::video_safe_areas`],
|
||||
/// https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html).
|
||||
pub(super) fn artboard_marks(painter: &egui::Painter, xf: &Xf, ab: &vectorcraft_doc::Artboard, color: Color32) {
|
||||
let (r, stroke) = (ab.rect, Stroke::new(1.0, color));
|
||||
let c = r.center();
|
||||
let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y));
|
||||
if ab.show_cross_hairs {
|
||||
painter.line_segment([at(r.x0, c.y), at(r.x1, c.y)], stroke);
|
||||
painter.line_segment([at(c.x, r.y0), at(c.x, r.y1)], stroke);
|
||||
}
|
||||
if ab.show_center_mark {
|
||||
let mid = at(c.x, c.y);
|
||||
for side in [at(c.x + 1.0, c.y), at(c.x, c.y + 1.0)] {
|
||||
let arm = (side - mid).normalized() * CENTER_MARK_ARM;
|
||||
painter.line_segment([mid - arm, mid + arm], stroke);
|
||||
}
|
||||
}
|
||||
if ab.show_video_safe_areas {
|
||||
for area in ab.video_safe_areas() {
|
||||
painter.add(Shape::closed_line(xf.quad(area), stroke));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Artboard `name` above its top-left `corner` on screen, no wider than the artboard (`width` on
|
||||
/// screen): a longer name ends in "…", and an artboard too narrow shows none (#949).
|
||||
pub(super) fn artboard_label(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) {
|
||||
if let Some((galley, at)) = canvas_name_galley(painter, corner, width, name, color) {
|
||||
painter.galley(at, galley, color);
|
||||
}
|
||||
}
|
||||
|
||||
fn canvas_name_rect(painter: &egui::Painter, corner: Pos2, width: f32, name: &str) -> Option<egui::Rect> {
|
||||
canvas_name_galley(painter, corner, width, name, Color32::WHITE).map(|(galley, at)| egui::Rect::from_min_size(at, galley.size()).expand(2.0))
|
||||
}
|
||||
|
||||
/// Where the Artboard tool draws "01 - name" for its active artboard (see its overlay label).
|
||||
pub(super) fn tool_name_rect(painter: &egui::Painter, xf: &Xf, rect: Rect, index: usize, name: &str) -> egui::Rect {
|
||||
let z = xf.zoom.max(1e-9);
|
||||
let anchor = Point::new(rect.x0 - 8.0 / z, rect.y0 - 4.0 / z);
|
||||
let sp = xf.to_screen(anchor) + vec2(8.0, -14.0);
|
||||
let galley = painter.layout(format!("{:02} - {name}", index + 1), egui::FontId::proportional(11.0), Color32::WHITE, f32::INFINITY);
|
||||
egui::Rect::from_min_size(sp, galley.size()).expand(2.0)
|
||||
}
|
||||
|
||||
/// The artboard whose on-canvas name contains `screen`, if that name is being drawn.
|
||||
pub(super) fn artboard_name_at(app: &VectorcraftApp, ui: &Ui, xf: &Xf, screen: Pos2) -> Option<usize> {
|
||||
let st = app.session.active()?;
|
||||
let artboard_tool = app.session.tool_id() == "artboard";
|
||||
if !artboard_tool && st.doc.artboards.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
|
||||
let painter = ui.painter();
|
||||
let mut hit = None;
|
||||
for (i, ab) in st.doc.artboards.iter().enumerate() {
|
||||
if tool_labelled == u64::try_from(i).ok() {
|
||||
continue;
|
||||
}
|
||||
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
|
||||
let width = (ab.rect.width() * xf.zoom) as f32;
|
||||
let text = format!("{:02} - {}", i + 1, ab.name);
|
||||
if canvas_name_rect(painter, corner, width, &text).is_some_and(|r| r.contains(screen)) {
|
||||
hit = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = tool_labelled.and_then(|i| usize::try_from(i).ok())
|
||||
&& let Some(ab) = st.doc.artboards.get(i)
|
||||
&& tool_name_rect(painter, xf, ab.rect, i, &ab.name).contains(screen)
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
pub(super) fn select_named_artboard(app: &mut VectorcraftApp, index: usize) {
|
||||
let n = app.session.active().map(|st| st.doc.artboards.len()).unwrap_or(0);
|
||||
if index >= n {
|
||||
return;
|
||||
}
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.artboard = index;
|
||||
}
|
||||
if app.session.tool_id() == "artboard" {
|
||||
app.session.set_tool_option("active", &json!(index));
|
||||
}
|
||||
}
|
||||
|
||||
/// Start editing artboard `index`'s name on the canvas, unless the artboard is locked (the
|
||||
/// Artboards panel still renames it).
|
||||
pub(super) fn begin_artboard_name_edit(ui: &Ui, app: &VectorcraftApp, index: usize) {
|
||||
let Some(name) = app.session.active().and_then(|st| st.doc.artboards.get(index).filter(|a| !a.locked).map(|a| a.name.clone())) else { return };
|
||||
ui.data_mut(|d| d.insert_temp(name_edit_id(), ArtboardNameEdit { index, text: name, fresh: true }));
|
||||
}
|
||||
|
||||
pub(super) fn commit_artboard_name(app: &mut VectorcraftApp, index: usize, text: &str) {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current = app.session.active().and_then(|st| st.doc.artboards.get(index).map(|a| a.name.clone()));
|
||||
if current.as_deref() == Some(trimmed) {
|
||||
return;
|
||||
}
|
||||
// A refusal is reported by the command; the label just closes.
|
||||
app.run("artboard.setProps", json!({ "index": index, "name": trimmed })).ok();
|
||||
}
|
||||
|
||||
/// The text field over an artboard name a double-click opened.
|
||||
pub(super) fn artboard_name_editor(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
let Some(mut edit) = ui.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id())) else { return };
|
||||
let alive = app.session.active().is_some_and(|st| edit.index < st.doc.artboards.len());
|
||||
if !alive {
|
||||
ui.data_mut(|d| d.remove::<ArtboardNameEdit>(name_edit_id()));
|
||||
return;
|
||||
}
|
||||
let Some(rect) = artboard_name_field_rect(app, ui, xf, edit.index) else {
|
||||
ui.data_mut(|d| d.remove::<ArtboardNameEdit>(name_edit_id()));
|
||||
return;
|
||||
};
|
||||
let fresh = edit.fresh;
|
||||
edit.fresh = false;
|
||||
let te = ui.put(
|
||||
rect,
|
||||
egui::TextEdit::singleline(&mut edit.text)
|
||||
.id(name_edit_id().with("field"))
|
||||
.font(egui::FontId::proportional(11.0))
|
||||
// Keep the original inset so theme button padding cannot move the canvas label.
|
||||
.margin(egui::Margin::symmetric(4, 2)),
|
||||
);
|
||||
if fresh || (!te.has_focus() && !te.lost_focus()) {
|
||||
te.request_focus();
|
||||
}
|
||||
let done = !fresh && te.lost_focus();
|
||||
let escape = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
let (index, text) = (edit.index, edit.text.clone());
|
||||
if done {
|
||||
ui.data_mut(|d| d.remove::<ArtboardNameEdit>(name_edit_id()));
|
||||
if !escape {
|
||||
commit_artboard_name(app, index, &text);
|
||||
}
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(name_edit_id(), edit));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the rename field sits: the name label's rectangle, at least wide enough to type in.
|
||||
pub(super) fn artboard_name_field_rect(app: &VectorcraftApp, ui: &Ui, xf: &Xf, index: usize) -> Option<egui::Rect> {
|
||||
let st = app.session.active()?;
|
||||
let ab = st.doc.artboards.get(index)?;
|
||||
let artboard_tool = app.session.tool_id() == "artboard";
|
||||
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
|
||||
let painter = ui.painter();
|
||||
let rect = if tool_labelled == u64::try_from(index).ok() {
|
||||
tool_name_rect(painter, xf, ab.rect, index, &ab.name)
|
||||
} else {
|
||||
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
|
||||
let width = (ab.rect.width() * xf.zoom) as f32;
|
||||
canvas_name_rect(painter, corner, width, &format!("{:02} - {}", index + 1, ab.name))?
|
||||
};
|
||||
Some(egui::Rect::from_min_size(rect.min, vec2(rect.width().max(80.0), rect.height().max(16.0))))
|
||||
}
|
||||
|
||||
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? It holds a name, so it is
|
||||
/// shown as it is; the tools' other labels ("anchor", "path", Puppet Warp's warning) are ours.
|
||||
pub(super) fn names_an_artboard(text: &str) -> bool {
|
||||
text.split_once(" - ").is_some_and(|(n, _)| n.len() >= 2 && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::outline::{hit_at, hit_with, selection_hit_at};
|
||||
use super::*;
|
||||
|
||||
/// Right-click: the object under the pointer is selected first unless it already is, then the
|
||||
/// context menu lists what applies to the selection ([`crate::menus::context_items`]). The locked
|
||||
/// objects under the pointer ([`vectorcraft_doc::hit::locked_objects_at`]) are kept in
|
||||
/// `ui.context_locked` while the menu is open, for its Unlock submenu.
|
||||
pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) {
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let p = xf.to_doc(p);
|
||||
let locked = hit_with(app, p, xf.zoom, |doc, p, opt| Some(vectorcraft_doc::hit::locked_objects_at(doc, p, opt)));
|
||||
app.ui.context_locked = app.session.active().map(|st| st.uid).zip(locked);
|
||||
if let Some(st) = app.session.active()
|
||||
&& let Some(top) = selection_hit_at(app, p, xf.zoom).map(|h| h.top_object(st.isolation))
|
||||
&& !st.selection.contains(top)
|
||||
{
|
||||
// A locked or hidden object can't be selected; the menu is then for the selection as is.
|
||||
let _ = app.run("select.set", json!({ "ids": [top.0] }));
|
||||
}
|
||||
}
|
||||
let mut clicked = None;
|
||||
if resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked)).is_none() {
|
||||
app.ui.context_locked = None;
|
||||
}
|
||||
if let Some((id, p)) = clicked {
|
||||
crate::menus::invoke(app, &id, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer,
|
||||
/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes
|
||||
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
|
||||
/// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries
|
||||
/// (`appearance.copyFrom`), a graphic style is applied to it (`graphicStyle.apply`; with Alt, on
|
||||
/// top of its appearance). A chip follows the pointer meanwhile.
|
||||
pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) {
|
||||
crate::panels::swatches::drag_preview(app, ui.ctx());
|
||||
let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return };
|
||||
// Not through a floating panel over the canvas.
|
||||
if ui.ctx().layer_id_at(pos).is_some_and(|l| l != resp.layer_id) {
|
||||
return;
|
||||
}
|
||||
let Some(d) = resp.dnd_release_payload::<widgets::PanelDrag>() else { return };
|
||||
// A Libraries panel graphic: a copy centred where it is dropped.
|
||||
if let widgets::PanelDrag::LibraryGraphic { library, item } = &*d {
|
||||
let at = xf.to_doc(pos);
|
||||
if let Err(e) = app.run("library.use", json!({"library": library, "kind": "graphic", "item": item, "center": [at.x, at.y]})) {
|
||||
app.status(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A symbol from the Symbols panel: an instance centred where it is dropped, on art or not.
|
||||
if let widgets::PanelDrag::Symbol(name) = &*d {
|
||||
let at = xf.to_doc(pos);
|
||||
if let Err(e) = app.run("symbol.place", json!({"name": name, "x": at.x, "y": at.y})) {
|
||||
app.status(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A symbol from a symbol library panel: added to the document's Symbols and placed there.
|
||||
if let widgets::PanelDrag::LibrarySymbol { library, name } = &*d {
|
||||
let at = xf.to_doc(pos);
|
||||
if let Err(e) = app.run("symbol.addFromLibrary", json!({"library": library, "name": name, "place": true, "x": at.x, "y": at.y})) {
|
||||
app.status(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return };
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let (cmd, params) = match &*d {
|
||||
widgets::PanelDrag::Paint { params, .. } => {
|
||||
// Colour groups paint nothing.
|
||||
if params.is_null() {
|
||||
return;
|
||||
}
|
||||
let mut params = params.clone();
|
||||
params["ids"] = json!([vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]);
|
||||
params["focus"] = json!(false);
|
||||
(crate::panels::proxy_cmd(app, false), params)
|
||||
}
|
||||
widgets::PanelDrag::Appearance(source) => {
|
||||
let target = hit.top_object(st.isolation);
|
||||
if target == *source {
|
||||
return;
|
||||
}
|
||||
("appearance.copyFrom", json!({"source": source.0, "ids": [target.0]}))
|
||||
}
|
||||
widgets::PanelDrag::GraphicStyle(name) => {
|
||||
let add = ui.input(|i| i.modifiers.alt);
|
||||
("graphicStyle.apply", json!({"name": name, "ids": [hit.top_object(st.isolation).0], "add": add}))
|
||||
}
|
||||
// A brush from the Brushes panel: the path it lands on takes it (a compound path as a whole).
|
||||
widgets::PanelDrag::Brush { name, .. } => {
|
||||
("brush.apply", json!({"name": name, "ids": [vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]}))
|
||||
}
|
||||
// A brush from a brush library panel: added to the document's Brushes and applied to the
|
||||
// path it lands on, in one step.
|
||||
widgets::PanelDrag::LibraryBrush { library, name } => (
|
||||
"brush.addFromLibrary",
|
||||
json!({"library": library, "name": name, "apply": true, "ids": [vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]}),
|
||||
),
|
||||
// Art dragged back onto the canvas: its move was already dropped. (A symbol was placed
|
||||
// above.)
|
||||
widgets::PanelDrag::Art(_)
|
||||
| widgets::PanelDrag::Symbol(_)
|
||||
| widgets::PanelDrag::LibrarySymbol { .. }
|
||||
| widgets::PanelDrag::LibraryGraphic { .. } => return,
|
||||
// A Libraries panel style or group only moves within that panel.
|
||||
widgets::PanelDrag::LibraryItem { .. } | widgets::PanelDrag::LibraryGroup { .. } => return,
|
||||
};
|
||||
if let Err(e) = app.run(cmd, params) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use super::*;
|
||||
|
||||
/// The active tool's cursor at the pointer, as `mods` make it. While the view moves (`still`
|
||||
/// false), the cursor the tool last gave for the same tool and keys stays: asking the tool again
|
||||
/// would hit-test the art under the pointer at every frame of the move (#2452).
|
||||
pub(super) fn hover_cursor(app: &mut VectorcraftApp, mods: Mods, view: vectorcraft_engine::ViewInfo, still: bool) -> Option<Cursor> {
|
||||
let id = app.session.tool_id();
|
||||
if !still
|
||||
&& let Some((was, with, c)) = app.canvas.hover_cursor
|
||||
&& (was, with) == (id, mods)
|
||||
{
|
||||
return Some(c);
|
||||
}
|
||||
let c = app.session.cursor(app.hover_doc?, mods, view);
|
||||
app.canvas.hover_cursor = Some((id, mods, c));
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// How long (seconds) the view must stay still before a canvas with
|
||||
/// [`crate::CanvasCache::waits_for_still`] set renders a heavy document again.
|
||||
pub const STILL_SECS: f64 = 0.2;
|
||||
|
||||
/// How long (seconds) the view must stay still before the canvas hit-tests the art under the
|
||||
/// pointer again, for Object Highlighting and the tool's cursor. While the view pans, zooms or
|
||||
/// turns under a resting pointer, the document point under it changes every frame, and hit-testing
|
||||
/// detailed art each time slowed every frame of the move (#2452).
|
||||
pub const HOVER_STILL_SECS: f64 = 0.15;
|
||||
|
||||
/// The view [`hover_wait`] watches: the canvas's rectangle and the view's zoom, centre and rotation.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ViewKey {
|
||||
pub(super) rect: egui::Rect,
|
||||
pub(super) zoom: f64,
|
||||
pub(super) center: Point,
|
||||
pub(super) rotation: f64,
|
||||
}
|
||||
|
||||
/// Note the view `key` at time `now` (seconds, egui's input time) → how many more seconds the
|
||||
/// canvas leaves the art under the pointer alone: the view changed less than [`HOVER_STILL_SECS`]
|
||||
/// ago. The first view noted counts as still.
|
||||
pub(super) fn hover_wait(moved: &mut Option<(ViewKey, f64)>, key: ViewKey, now: f64) -> Option<f64> {
|
||||
match moved {
|
||||
Some((k, at)) if *k != key => (*k, *at) = (key, now),
|
||||
Some(_) => {}
|
||||
None => *moved = Some((key, f64::NEG_INFINITY)),
|
||||
}
|
||||
let waited = moved.map_or(f64::INFINITY, |(_, at)| now - at);
|
||||
// The clock went back (a new egui context): don't wait for it to catch up.
|
||||
(0.0..HOVER_STILL_SECS).contains(&waited).then_some(HOVER_STILL_SECS - waited)
|
||||
}
|
||||
|
||||
/// Note that the canvas asks for `key` at time `now` (seconds, egui's input time) → how many more
|
||||
/// seconds it draws the frame `shown` reprojected instead of rendering `key`: while the last
|
||||
/// render was `slow`, only the view differs between the two, and the canvas has asked for `key`
|
||||
/// for less than [`STILL_SECS`]. `still` holds the key last asked for and when the canvas first
|
||||
/// asked for it. None: the canvas renders `key` now, unless it is the frame shown.
|
||||
pub(super) fn still_wait(still: &mut Option<(CacheKey, f64)>, shown: Option<&CacheKey>, key: &CacheKey, slow: bool, now: f64) -> Option<f64> {
|
||||
if still.as_ref().is_none_or(|(k, _)| k != key) {
|
||||
*still = Some((key.clone(), now));
|
||||
}
|
||||
let waited = still.as_ref().map_or(0.0, |(_, at)| now - at);
|
||||
// The clock went back (a new egui context): don't wait for it to catch up.
|
||||
let waiting = (0.0..STILL_SECS).contains(&waited);
|
||||
(slow && waiting && shown.is_some_and(|s| s != key && only_view_differs(s, key))).then_some(STILL_SECS - waited)
|
||||
}
|
||||
|
||||
/// Whether two cache keys differ only in the view (zoom, center, rotation, canvas size, pixel
|
||||
/// density and Pixel Preview's document pixels), with Pixel Preview on in both or off in both.
|
||||
fn only_view_differs(a: &CacheKey, b: &CacheKey) -> bool {
|
||||
a.pixel.is_some() == b.pixel.is_some()
|
||||
&& *a == CacheKey { zoom: a.zoom, cx: a.cx, cy: a.cy, w: a.w, h: a.h, ppp: a.ppp, rot: a.rot, pixel: a.pixel, ..b.clone() }
|
||||
}
|
||||
|
||||
pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
use egui::CursorIcon as C;
|
||||
match c {
|
||||
Cursor::Arrow
|
||||
| Cursor::ArrowHollow
|
||||
| Cursor::DirectAnchor
|
||||
| Cursor::DirectAnchorSelected
|
||||
| Cursor::ReshapeSegment
|
||||
| Cursor::GroupSelection
|
||||
| Cursor::CornerRadius
|
||||
| Cursor::PathBracket
|
||||
| Cursor::TypeWidget
|
||||
| Cursor::LoadedText => C::Default,
|
||||
Cursor::SelectionCopy => C::Copy,
|
||||
Cursor::Move => C::Move,
|
||||
Cursor::Crosshair => C::Crosshair,
|
||||
Cursor::ResizeH | Cursor::ShearH => C::ResizeHorizontal,
|
||||
Cursor::ResizeV | Cursor::ShearV => C::ResizeVertical,
|
||||
Cursor::ResizeNwSe => C::ResizeNwSe,
|
||||
Cursor::ResizeNeSw => C::ResizeNeSw,
|
||||
Cursor::Rotate | Cursor::RotateToward(_) => C::Alias,
|
||||
Cursor::Pen
|
||||
| Cursor::PenDrawing
|
||||
| Cursor::PenAdd
|
||||
| Cursor::PenDelete
|
||||
| Cursor::PenClose
|
||||
| Cursor::PenContinue
|
||||
| Cursor::PenJoin
|
||||
| Cursor::PenConvert
|
||||
| Cursor::AnchorPoint
|
||||
| Cursor::Curvature => C::Crosshair,
|
||||
Cursor::Text => C::Text,
|
||||
Cursor::Hand => C::Grab,
|
||||
Cursor::HandGrab => C::Grabbing,
|
||||
Cursor::ZoomIn => C::ZoomIn,
|
||||
Cursor::ZoomOut => C::ZoomOut,
|
||||
Cursor::Eyedropper => C::Crosshair,
|
||||
Cursor::NotAllowed => C::NotAllowed,
|
||||
Cursor::Busy => C::Progress,
|
||||
Cursor::AddStop => C::Copy,
|
||||
Cursor::RemoveStop => C::NotAllowed,
|
||||
Cursor::Slice => C::Crosshair,
|
||||
Cursor::SliceSelect => C::Default,
|
||||
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
|
||||
Cursor::WidthPoint => C::Move,
|
||||
Cursor::PencilNew => C::Crosshair,
|
||||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair,
|
||||
Cursor::PencilClose | Cursor::PencilStraight | Cursor::PencilContinue | Cursor::PaintbrushClose => C::Crosshair,
|
||||
Cursor::TypeVertical => C::VerticalText,
|
||||
// Not drawn yet: the cursor each borrows.
|
||||
Cursor::Lasso
|
||||
| Cursor::MagicWand
|
||||
| Cursor::Pencil
|
||||
| Cursor::Paintbrush
|
||||
| Cursor::BlobBrush
|
||||
| Cursor::Eraser
|
||||
| Cursor::Smooth
|
||||
| Cursor::PathEraser
|
||||
| Cursor::Join
|
||||
| Cursor::Knife
|
||||
| Cursor::Shaper
|
||||
| Cursor::LivePaintBucket
|
||||
| Cursor::Measure
|
||||
| Cursor::PrintTiling
|
||||
| Cursor::SymbolSprayer
|
||||
| Cursor::SymbolShifter
|
||||
| Cursor::SymbolScruncher
|
||||
| Cursor::SymbolSizer
|
||||
| Cursor::SymbolSpinner
|
||||
| Cursor::SymbolStainer
|
||||
| Cursor::SymbolScreener
|
||||
| Cursor::SymbolStyler
|
||||
| Cursor::ObjectsOnPath
|
||||
| Cursor::Scissors
|
||||
| Cursor::ScissorsNotAllowed
|
||||
| Cursor::MeshAdd
|
||||
| Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
use super::*;
|
||||
|
||||
/// Whether drag `d` is a press the tool holds. Every other drag (panning, zooming, turning the
|
||||
/// view, a touch gesture, Presentation Mode) never reaches the tool, so its hover ends with it.
|
||||
pub(super) fn tool_holds(d: Drag) -> bool {
|
||||
matches!(d, Drag::Tool | Drag::Art)
|
||||
}
|
||||
|
||||
/// What a hover depends on besides the pointer's screen position.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct HoverContext {
|
||||
/// The active tool: a tool switched to under a still pointer is hovered at once.
|
||||
pub(super) tool: &'static str,
|
||||
/// The canvas, and the view's centre, zoom and rotation.
|
||||
pub(super) canvas: egui::Rect,
|
||||
pub(super) center: Point,
|
||||
pub(super) zoom: f64,
|
||||
pub(super) rotation: f64,
|
||||
/// The active document's id and revision (which moves with every edit, undo and selection
|
||||
/// change).
|
||||
pub(super) doc: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
/// Clear canvas interaction state before a tab transfer takes ownership of the artwork drag.
|
||||
pub(crate) fn end_document_drag(ctx: &egui::Context) {
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<Drag>(drag_id());
|
||||
d.remove::<Pos2>(drag_pos_id());
|
||||
});
|
||||
}
|
||||
|
||||
/// Temp-memory key for the active canvas drag state.
|
||||
pub(super) fn drag_id() -> egui::Id {
|
||||
egui::Id::unique("canvas-drag")
|
||||
}
|
||||
|
||||
/// Where the pointer was last seen during a press on the canvas.
|
||||
pub(super) fn drag_pos_id() -> egui::Id {
|
||||
egui::Id::unique("canvas-drag-pos")
|
||||
}
|
||||
|
||||
/// The layer the canvas was last drawn on: a release over another layer is over a panel (#2826).
|
||||
pub(super) fn drag_layer_id() -> egui::Id {
|
||||
egui::Id::unique("canvas-drag-layer")
|
||||
}
|
||||
|
||||
/// Whether `p` is over the canvas itself, not a panel or a floating window over it.
|
||||
pub(super) fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool {
|
||||
resp.rect.contains(p) && ui.clip_rect().contains(p) && ui.ctx().layer_id_at(p).is_none_or(|l| l == resp.layer_id)
|
||||
}
|
||||
|
||||
/// The document point last sent to the tool as a drag during [`Drag::Tool`]; none until the press
|
||||
/// has dragged.
|
||||
pub(super) fn tool_drag_doc_id() -> egui::Id {
|
||||
egui::Id::unique("canvas-tool-drag-doc")
|
||||
}
|
||||
|
||||
/// The document point of the press that began [`Drag::Tool`], sent to the tool as its Down.
|
||||
pub(super) fn tool_press_doc_id() -> egui::Id {
|
||||
egui::Id::unique("canvas-tool-press-doc")
|
||||
}
|
||||
|
||||
/// Whether the tool should get another drag event, and if so records `p` as the last position
|
||||
/// sent: the pointer moved this frame, or its document position changed without a delta (#834).
|
||||
/// egui's frame delta is zero when some Wayland compositors coalesce motion until release, and in
|
||||
/// the frame the pointer comes back into the window after leaving it (`PointerGone` forgot where
|
||||
/// it was), though it is somewhere new; judged on the delta alone, the preview stayed behind.
|
||||
pub(super) fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::PointerState) -> bool {
|
||||
let at = xf.to_doc(p);
|
||||
let id = tool_drag_doc_id();
|
||||
// Before the press has dragged, the last position the tool got is the press itself: a press
|
||||
// held still is not a drag.
|
||||
let prev = ui.data(|d| d.get_temp::<Point>(id).or_else(|| d.get_temp::<Point>(tool_press_doc_id())));
|
||||
if pointer.delta() == egui::Vec2::ZERO && prev == Some(at) {
|
||||
return false;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, at));
|
||||
true
|
||||
}
|
||||
|
||||
/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch
|
||||
/// input, else the pressure in the host's feed while a pen is the pointer that moved last
|
||||
/// ([`crate::Services::stylus`]: [`StylusFeed::press`] for a new press, [`StylusFeed::held`]
|
||||
/// after it), else the last one seen since the press (`pressed`: a new press, whose mouse has none
|
||||
/// until a pen reports it). A mouse presses fully (1).
|
||||
pub(super) fn pen_pressure(ui: &Ui, app: &VectorcraftApp, pressed: bool) -> f32 {
|
||||
let stylus = app.services.stylus.as_ref();
|
||||
let id = egui::Id::unique("canvas-pressure");
|
||||
let force = ui.input(|i| {
|
||||
i.events.iter().rev().find_map(|e| match e {
|
||||
egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let force = force.or_else(|| stylus.and_then(if pressed { StylusFeed::press } else { StylusFeed::held }));
|
||||
let p = force.or_else(|| if pressed { None } else { ui.data(|d| d.get_temp::<f32>(id)) }).unwrap_or(1.0);
|
||||
ui.data_mut(|d| d.insert_temp(id, p));
|
||||
p
|
||||
}
|
||||
|
||||
/// The positions this frame's pointer moves passed through before its last one, each with the
|
||||
/// pen force a touch event gave just before it, or the pressure the host's feed kept for that move
|
||||
/// ([`StylusFeed::take_moves`], macOS), if any. The last move is the pointer's position. A mouse's
|
||||
/// moves count too, with no pressure: a fast mouse stroke otherwise keeps one position a frame, a
|
||||
/// sample every few pixels, too few for a low Fidelity to follow the hand (#3328).
|
||||
pub(super) fn frame_moves(ui: &Ui, stylus: Option<&StylusFeed>) -> Vec<(Pos2, Option<f32>)> {
|
||||
let pressures = stylus.map(StylusFeed::take_moves).unwrap_or_default();
|
||||
ui.input(|i| {
|
||||
let mut moves = vec![];
|
||||
if i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. })) {
|
||||
let mut force = None;
|
||||
for e in &i.events {
|
||||
match e {
|
||||
egui::Event::Touch { force: Some(f), .. } if f.is_finite() && *f > 0.0 => force = Some(f.clamp(0.0, 1.0)),
|
||||
egui::Event::PointerMoved(p) => moves.push((*p, force)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else if !pressures.is_empty() {
|
||||
// The feed's last pressure is the last move's: pair them from the end. A lift's 0 is
|
||||
// no pressure (the press keeps its last touch).
|
||||
let points: Vec<Pos2> = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some(*p) } else { None }).collect();
|
||||
let forces = pressures.iter().rev().map(|f| (*f > 0.0).then_some(*f)).chain(std::iter::repeat(None));
|
||||
moves = points.into_iter().rev().zip(forces).collect();
|
||||
moves.reverse();
|
||||
} else {
|
||||
moves = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some((*p, None)) } else { None }).collect();
|
||||
}
|
||||
moves.pop();
|
||||
// A burst of events (a stalled frame) is thinned to a bounded count.
|
||||
const MAX: usize = 64;
|
||||
if moves.len() > MAX {
|
||||
let step = moves.len().div_ceil(MAX);
|
||||
moves = moves.into_iter().step_by(step).collect();
|
||||
}
|
||||
moves
|
||||
})
|
||||
}
|
||||
|
||||
/// The moves of this frame ([`frame_moves`]) the active tool gets before the pointer's position:
|
||||
/// all of them for a tool that builds a stroke from them ([`vectorcraft_engine::Session::tool_wants_samples`]), none
|
||||
/// for the others, which get one drag a frame. The moves are read either way, so the host's feed
|
||||
/// pairs the next frame's with its own.
|
||||
pub(super) fn tool_frame_moves(ui: &Ui, app: &VectorcraftApp) -> Vec<(Pos2, Option<f32>)> {
|
||||
let moves = frame_moves(ui, app.services.stylus.as_ref());
|
||||
if app.session.tool_wants_samples() { moves } else { vec![] }
|
||||
}
|
||||
|
||||
pub fn mods(m: egui::Modifiers, space: bool) -> Mods {
|
||||
Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space }
|
||||
}
|
||||
|
||||
/// Drop the tool press in progress, if any: nothing it began is kept, and the rest of the press
|
||||
/// does nothing ([`Drag::Gesture`]).
|
||||
pub(super) fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewInfo) {
|
||||
match ui.data(|d| d.get_temp::<Drag>(drag_id())) {
|
||||
Some(Drag::Tool | Drag::Art) => {
|
||||
// The tool's mouse-up returns it to rest. A move waiting over a panel is still live
|
||||
// and would otherwise outlive the drag that owns it (#2826).
|
||||
// Nothing to roll back without a document; the tool still gets its mouse-up.
|
||||
let _ = app.session.cancel_interaction();
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
|
||||
egui::DragAndDrop::clear_payload(ui.ctx());
|
||||
}
|
||||
Some(_) => {}
|
||||
None => return,
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture));
|
||||
}
|
||||
|
||||
/// A Selection tool move dragged off the canvas (to `p`, over a panel) becomes a panel drag of
|
||||
/// the selected art ([`widgets::PanelDrag::Art`]): the art waits where it last moved on the
|
||||
/// canvas, and a panel it is released on takes it (the Graphic Styles panel makes a style of it).
|
||||
/// Back on the canvas, [`Drag::Art`] takes the move up again where the pointer is (#2826).
|
||||
pub(super) fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2) -> bool {
|
||||
let Some(st) = app.session.active().filter(|_| app.session.tool_id() == "selection") else { return false };
|
||||
// The Selection tool's interaction while it moves (or Alt-copies) the selection.
|
||||
if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") {
|
||||
return false;
|
||||
}
|
||||
// A floating panel in an OS window of its own takes the art too: craft-ui hands that window
|
||||
// the pointer while it is over it, and this window sees the pointer go.
|
||||
craft_ui::docking::route_app_drag(ui.ctx(), crate::panel_docking::area_id());
|
||||
if on_canvas(ui, resp, p) && ui.input(|i| i.pointer.has_pointer()) {
|
||||
return false;
|
||||
}
|
||||
let ids = st.selection.objects.clone();
|
||||
egui::DragAndDrop::set_payload(ui.ctx(), widgets::PanelDrag::Art(ids));
|
||||
true
|
||||
}
|
||||
|
||||
/// A Selection tool move released over a panel: end it before the panels see the release, so a
|
||||
/// panel that takes the art ([`widgets::PanelDrag::Art`]) takes it where it was, and no move is
|
||||
/// left open (#2826). The art stays where it last moved while the pointer is over the panel.
|
||||
pub(crate) fn release_over_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
crate::document_drag::cleanup(app, ctx);
|
||||
let art = matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Art));
|
||||
// Over a floating panel's own OS window the pointer is gone from this window, and the release
|
||||
// comes here later, at a point of this window that may lie on the canvas: that panel takes it.
|
||||
let gone_id = drag_id().with("art-gone");
|
||||
if !ctx.input(|i| i.pointer.primary_released()) {
|
||||
if art {
|
||||
let gone = ctx.input(|i| !i.pointer.has_pointer());
|
||||
ctx.data_mut(|d| d.insert_temp(gone_id, gone));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let gone = ctx.data_mut(|d| d.remove_temp::<bool>(gone_id)).unwrap_or(false);
|
||||
let p = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos()));
|
||||
if !art {
|
||||
return;
|
||||
}
|
||||
let layer = ctx.data(|d| d.get_temp::<egui::LayerId>(drag_layer_id()));
|
||||
let on = !gone && p.is_some_and(|p| app.canvas_rect.is_some_and(|r| r.contains(p)) && ctx.layer_id_at(p).is_none_or(|l| Some(l) == layer));
|
||||
if on {
|
||||
// The canvas's release finishes the move.
|
||||
return;
|
||||
}
|
||||
end_art_move(app, ctx);
|
||||
}
|
||||
|
||||
/// A release in a floating panel's own OS window, during an art drag from the canvas: the move
|
||||
/// ends before that panel takes the art. The main window would only see it on its next pass.
|
||||
pub(crate) fn release_art_in_window(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let art = matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Art));
|
||||
if art && ctx.input(|i| i.pointer.primary_released()) {
|
||||
end_art_move(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Roll back the art move dragged off the canvas; a panel takes the art instead.
|
||||
fn end_art_move(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
// With no document open there is nothing to roll back; the tool still gets its mouse-up.
|
||||
let _ = app.session.cancel_interaction();
|
||||
let view = app.view_info();
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<Drag>(drag_id());
|
||||
d.remove::<bool>(drag_id().with("art-gone"));
|
||||
});
|
||||
}
|
||||
|
||||
/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches).
|
||||
pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) {
|
||||
let r = app.session.pointer(ev, view);
|
||||
// The artboard the Artboard tool makes active is the active one (the navigator's, the
|
||||
// Artboards panel's).
|
||||
if app.session.tool_id() == "artboard"
|
||||
&& let Some(i) = app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok())
|
||||
&& let Some(v) = app.view_mut()
|
||||
{
|
||||
v.artboard = i;
|
||||
}
|
||||
// A press with a selection tool inside an artboard makes it the active one (#693), as the
|
||||
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to
|
||||
// Artboard aligns to it, and All on Active Artboard selects on it.
|
||||
if ev.kind == PointerKind::Down
|
||||
&& vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id())
|
||||
&& let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos))
|
||||
&& let Some(v) = app.view_mut()
|
||||
{
|
||||
v.artboard = i;
|
||||
}
|
||||
apply_requests(app, r);
|
||||
}
|
||||
|
||||
/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error.
|
||||
pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Vec<vectorcraft_engine::UiRequest>>) {
|
||||
match r {
|
||||
Ok(reqs) => {
|
||||
for r in reqs {
|
||||
match r {
|
||||
vectorcraft_engine::UiRequest::Dialog(kind, p) => crate::dialogs::open_tool_dialog(app, &kind, p),
|
||||
vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t),
|
||||
vectorcraft_engine::UiRequest::Status(msg) => app.status(msg),
|
||||
vectorcraft_engine::UiRequest::Place(p) => crate::place::place_from_cursor(app, &p),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => app.status(e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
use super::drag::mods;
|
||||
use super::*;
|
||||
|
||||
/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per
|
||||
/// look, and its cell size in screen points.
|
||||
pub(crate) fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (egui::TextureHandle, f32) {
|
||||
let colors = setup.grid_colors.map(|c| crate::panels::c32(&c));
|
||||
let id = egui::Id::unique("transparency-grid-tile");
|
||||
let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors);
|
||||
let tex = cached.map(|(_, t)| t).unwrap_or_else(|| {
|
||||
let [a, b] = colors;
|
||||
let img = egui::ColorImage::new([2, 2], vec![a, b, b, a]);
|
||||
let opts = egui::TextureOptions { wrap_mode: egui::TextureWrapMode::Repeat, ..egui::TextureOptions::NEAREST };
|
||||
let t = ctx.load_texture(TRANSPARENCY_GRID, img, opts);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (colors, t.clone())));
|
||||
t
|
||||
});
|
||||
(tex, setup.grid_size.cell())
|
||||
}
|
||||
|
||||
/// Name of the transparency grid's texture.
|
||||
pub(crate) const TRANSPARENCY_GRID: &str = "transparency grid";
|
||||
|
||||
/// The transparency grid over the artboard `ab` (screen corners `quad`): one textured quad whose
|
||||
/// cells stay `cell` screen points at every zoom and turn with a rotated view.
|
||||
pub(super) fn checker(p: &egui::Painter, quad: &[Pos2], ab: Rect, zoom: f64, tex: egui::TextureId, cell: f32) {
|
||||
// Two cells per texture repeat.
|
||||
let per = (2.0 * cell as f64 / zoom).max(1e-9);
|
||||
textured_quad(p, tex, quad, ((ab.width() / per) as f32, (ab.height() / per) as f32));
|
||||
}
|
||||
|
||||
/// One quad drawn with `tex`: its corners `quad` (from the top left, clockwise) show the texture
|
||||
/// from (0, 0) to (`u`, `v`).
|
||||
pub(super) fn textured_quad(p: &egui::Painter, tex: egui::TextureId, quad: &[Pos2], (u, v): (f32, f32)) {
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
for (pos, uv) in quad.iter().zip([pos2(0.0, 0.0), pos2(u, 0.0), pos2(u, v), pos2(0.0, v)]) {
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: *pos, uv, color: Color32::WHITE });
|
||||
}
|
||||
mesh.indices.extend([0, 1, 2, 0, 2, 3]);
|
||||
p.add(Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
/// The zoom the pixel grid shows from in Pixel Preview (600%), and its colour: a translucent grey,
|
||||
/// so the art's pixels read through it.
|
||||
pub(super) const PIXEL_GRID_ZOOM: f64 = 6.0;
|
||||
|
||||
pub(super) const PIXEL_GRID: Color32 = Color32::from_rgba_premultiplied(64, 64, 64, 96);
|
||||
|
||||
/// The look of the grid or of the guides (Preferences › Guides & Grid › Color and Style).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct LineLook {
|
||||
pub(super) color: Color32,
|
||||
pub(super) dots: bool,
|
||||
}
|
||||
|
||||
impl LineLook {
|
||||
pub(super) fn grid(prefs: &vectorcraft_engine::Prefs) -> Self {
|
||||
Self { color: pref_color(&prefs.grid_color, Color32::from_gray(200)), dots: prefs.grid_style == "dots" }
|
||||
}
|
||||
|
||||
pub(super) fn guides(prefs: &vectorcraft_engine::Prefs, t: &Tokens) -> Self {
|
||||
Self { color: pref_color(&prefs.guide_color, t.guide), dots: prefs.guide_style == "dots" }
|
||||
}
|
||||
|
||||
/// A guide from `a` to `b`: a line, or dots.
|
||||
pub(super) fn guide(self, p: &egui::Painter, a: Pos2, b: Pos2) {
|
||||
if self.dots {
|
||||
p.extend(Shape::dotted_line(&[a, b], self.color, 4.0, 0.75));
|
||||
} else {
|
||||
p.line_segment([a, b], Stroke::new(1.0, self.color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `#rrggbb` colour preference, `fallback` when it doesn't parse.
|
||||
pub(super) fn pref_color(hex: &str, fallback: Color32) -> Color32 {
|
||||
vectorcraft_color::Color::from_hex(hex).map_or(fallback, |c| crate::panels::c32(&c))
|
||||
}
|
||||
|
||||
/// The view in document space: the box round the canvas's corners (the view may be rotated).
|
||||
pub(super) fn view_bounds(xf: &Xf) -> Rect {
|
||||
let r = xf.rect;
|
||||
let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|c| xf.to_doc(c));
|
||||
corners.iter().fold(Rect::from_points(corners[0], corners[0]), |b, c| b.union_pt(*c))
|
||||
}
|
||||
|
||||
/// The part of the segment from `a` to `b` inside `r`, none when the segment misses it.
|
||||
pub(super) fn clip_segment(a: Pos2, b: Pos2, r: egui::Rect) -> Option<(Pos2, Pos2)> {
|
||||
let d = b - a;
|
||||
let (mut t0, mut t1) = (0.0_f32, 1.0_f32);
|
||||
// Each edge as (the run toward it, the room before it).
|
||||
for (run, room) in [(-d.x, a.x - r.left()), (d.x, r.right() - a.x), (-d.y, a.y - r.top()), (d.y, r.bottom() - a.y)] {
|
||||
if run == 0.0 {
|
||||
if room < 0.0 {
|
||||
return None;
|
||||
}
|
||||
} else if run < 0.0 {
|
||||
t0 = t0.max(room / run);
|
||||
} else {
|
||||
t1 = t1.min(room / run);
|
||||
}
|
||||
}
|
||||
(t0 <= t1).then(|| (a + d * t0, a + d * t1))
|
||||
}
|
||||
|
||||
/// The grid (View → Show Grid): a gridline every `spacing` and `subdiv` lighter lines between
|
||||
/// (left out when they would crowd). In the Dots style, a dot where gridlines cross the
|
||||
/// subdivisions instead.
|
||||
pub(super) fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32, look: LineLook) {
|
||||
// The painter clips to the canvas.
|
||||
let view = view_bounds(xf);
|
||||
let sub = spacing / subdiv.max(1) as f64;
|
||||
let step = if sub * xf.zoom >= 6.0 { sub } else { spacing };
|
||||
// Dots crowd sooner than lines.
|
||||
if step * xf.zoom < 4.0 || (look.dots && spacing * xf.zoom < 8.0) {
|
||||
return;
|
||||
}
|
||||
let major = |v: f64| (v / spacing - (v / spacing).round()).abs() < 1e-6;
|
||||
// The lines across the view (where along their axis), at least 4 px apart (and capped).
|
||||
let lines = |from: f64, to: f64| {
|
||||
let first = (from / step).floor();
|
||||
(0..10_000u32).map(move |i| (first + f64::from(i)) * step).take_while(move |v| *v <= to)
|
||||
};
|
||||
if look.dots {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for x in lines(view.x0, view.x1) {
|
||||
for y in lines(view.y0, view.y1).filter(|y| major(x) || major(*y)) {
|
||||
mesh.add_colored_rect(egui::Rect::from_center_size(xf.to_screen(Point::new(x, y)), vec2(1.5, 1.5)), look.color);
|
||||
}
|
||||
}
|
||||
p.add(Shape::mesh(mesh));
|
||||
return;
|
||||
}
|
||||
let minor = look.color.gamma_multiply(0.4);
|
||||
let stroke = |major: bool| Stroke::new(1.0, if major { look.color } else { minor });
|
||||
for x in lines(view.x0, view.x1) {
|
||||
p.line_segment([xf.to_screen(Point::new(x, view.y0)), xf.to_screen(Point::new(x, view.y1))], stroke(major(x)));
|
||||
}
|
||||
for y in lines(view.y0, view.y1) {
|
||||
p.line_segment([xf.to_screen(Point::new(view.x0, y)), xf.to_screen(Point::new(view.x1, y))], stroke(major(y)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A ruler label for `v` (in the ruler's unit) on a ruler whose labels are `step` apart: whole
|
||||
/// numbers, or the fewest decimals that write `step` exactly, so a binary step keeps its value
|
||||
/// (an inch ruler's 1/16 reads `0.0625`, not the `0.06` that rounding the step's magnitude gave).
|
||||
/// How far down the left ruler a label of `chars` characters reaches, counting from its top: it
|
||||
/// is spelled one character per line, [`RULER_CHAR`] apart, and the last one is a line tall.
|
||||
pub(crate) fn vertical_label_height(chars: usize) -> f32 {
|
||||
chars.saturating_sub(1) as f32 * RULER_CHAR + RULER_LINE
|
||||
}
|
||||
|
||||
/// A ruler label's line spacing and line height on the left ruler.
|
||||
const RULER_CHAR: f32 = 8.5;
|
||||
|
||||
const RULER_LINE: f32 = 9.5;
|
||||
|
||||
pub(crate) fn ruler_label(v: f64, step: f64) -> String {
|
||||
let exact = |d: u32| (step * 10f64.powi(d as i32)).fract().abs() < 1e-9;
|
||||
let decimals = (0..=7).find(|d| exact(*d)).unwrap_or(7) as usize;
|
||||
let s = format!("{v:.decimals$}");
|
||||
// Trailing zeros go, as `Unit::number` drops them: a sixteenth-inch ruler labels the inch `1`,
|
||||
// not `1.0000`, which the vertical ruler would spell down six lines.
|
||||
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { &s };
|
||||
if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
/// The rulers, numbered in `unit` (the General unit).
|
||||
/// The top ruler, the left ruler and the box where they meet.
|
||||
pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
|
||||
let top = egui::Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER));
|
||||
let left = egui::Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom()));
|
||||
[top, left, egui::Rect::from_min_size(full.min, vec2(RULER, RULER))]
|
||||
}
|
||||
|
||||
/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's
|
||||
/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left
|
||||
/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else,
|
||||
/// it makes none. The corner drags the rulers' origin or resets it on a double-click.
|
||||
pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> {
|
||||
let [top, left, corner] = ruler_rects(full);
|
||||
// Right-click on a ruler or the origin box: the document units, to swap between them as
|
||||
// Preferences ▸ Units ▸ General does ([`crate::menus::ruler_menu_body`]).
|
||||
let mut unit_clicked = None;
|
||||
for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] {
|
||||
// Click-and-drag so the same widget drags out guides (left) and opens the unit menu (right).
|
||||
let resp = ui.interact(r, egui::Id::unique(id), Sense::click_and_drag());
|
||||
resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
|
||||
let kind = if resp.drag_stopped() {
|
||||
PointerKind::Up
|
||||
} else if resp.dragged() {
|
||||
PointerKind::Drag
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()));
|
||||
let on_canvas = p.is_some_and(|p| canvas.contains(p));
|
||||
if on_canvas {
|
||||
// The new guide shows even if guides were hidden.
|
||||
app.ui.view.guides = true;
|
||||
}
|
||||
let ev = PointerEvent { kind, pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)), mods: mods(ui.input(|i| i.modifiers), false), pressure: 1.0 };
|
||||
if let Err(e) = app.session.ruler_guide(vertical, &ev, on_canvas, app.view_info()) {
|
||||
app.status(e.to_string());
|
||||
}
|
||||
}
|
||||
let corner = ui.interact(corner, egui::Id::unique("ruler-corner"), Sense::click_and_drag());
|
||||
corner.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
|
||||
if let Some((id, p)) = unit_clicked {
|
||||
crate::menus::invoke(app, &id, p);
|
||||
}
|
||||
if corner.double_clicked_by(egui::PointerButton::Primary) {
|
||||
let artboard = app.view().map_or(0, |v| v.artboard);
|
||||
if let Err(e) = app.run("document.resetRulerOrigin", json!({"artboard": artboard})) {
|
||||
app.status(e.to_string());
|
||||
}
|
||||
}
|
||||
// Keep previews in the UI: only a release on the canvas edits the document, once. Cancelling
|
||||
// never adds an undo step or interferes with the drawing tool's interaction.
|
||||
let drag_id = ruler_origin_drag_id();
|
||||
let uid = app.session.active().map(|d| d.uid);
|
||||
if corner.drag_started_by(egui::PointerButton::Primary) {
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, (uid, false)));
|
||||
}
|
||||
let mut preview = None;
|
||||
if let Some((started_in, mut cancelled)) = ui.data(|d| d.get_temp::<(Option<u64>, bool)>(drag_id)) {
|
||||
cancelled |= started_in != uid || ui.input(|i| !i.focused || i.key_pressed(egui::Key::Escape));
|
||||
let at = corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p));
|
||||
if corner.drag_stopped_by(egui::PointerButton::Primary) {
|
||||
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
|
||||
if !cancelled
|
||||
&& let Some(at) = at
|
||||
&& let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)}))
|
||||
{
|
||||
app.status(e.to_string());
|
||||
}
|
||||
} else if !ui.input(|i| i.pointer.primary_down()) {
|
||||
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled)));
|
||||
if !cancelled {
|
||||
preview = at;
|
||||
}
|
||||
}
|
||||
}
|
||||
preview
|
||||
}
|
||||
|
||||
/// The key of the ruler origin drag's state in egui's temporary data.
|
||||
fn ruler_origin_drag_id() -> egui::Id {
|
||||
egui::Id::unique("ruler-origin-drag")
|
||||
}
|
||||
|
||||
/// Is a drag from the rulers' corner in progress? It stays true after Escape cancels the move, until
|
||||
/// the button is released.
|
||||
pub(crate) fn ruler_origin_dragging(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|d| d.get_temp::<(Option<u64>, bool)>(ruler_origin_drag_id()).is_some())
|
||||
}
|
||||
|
||||
/// What the rulers count, and from where: `unit`, from `origin` (document points), with each
|
||||
/// unit stretched `stretch` times along the document's x and y, which a video ruler's pixel aspect
|
||||
/// ratio makes differ.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct RulerScale {
|
||||
pub(crate) unit: Unit,
|
||||
pub(crate) origin: Point,
|
||||
pub(crate) stretch: [f64; 2],
|
||||
}
|
||||
|
||||
impl RulerScale {
|
||||
/// The rulers in `unit` (the General unit), from `origin` (the ruler origin).
|
||||
pub(crate) fn document(unit: Unit, origin: Point) -> Self {
|
||||
Self { unit, origin, stretch: [1.0, 1.0] }
|
||||
}
|
||||
|
||||
/// View › Rulers › Show Video Rulers on artboard `ab`: pixels from its top-left corner, each
|
||||
/// pixel as many points wide as its Video Ruler Pixel Aspect Ratio and one point tall, so the
|
||||
/// rulers read the artboard's size in video pixels
|
||||
/// (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html).
|
||||
pub(crate) fn video(ab: &vectorcraft_doc::Artboard) -> Self {
|
||||
Self { unit: Unit::Pixels, origin: Point::new(ab.rect.x0, ab.rect.y0), stretch: [ab.video_ruler_pixel_aspect_ratio, 1.0] }
|
||||
}
|
||||
|
||||
/// How far document point `p` lies from the origin along `axis` (a unit direction in the
|
||||
/// document), in points of this scale: each document coordinate divided by its stretch.
|
||||
/// Divided by the unit's points, it is the value the ruler reads there.
|
||||
pub(crate) fn along(&self, p: Point, axis: vectorcraft_geom::Vec2) -> f64 {
|
||||
let d = p - self.origin;
|
||||
d.x / self.stretch[0] * axis.x + d.y / self.stretch[1] * axis.y
|
||||
}
|
||||
|
||||
/// The screen points one point of this scale spans along `axis` at `zoom` (screen points per
|
||||
/// document point): `zoom` itself when nothing is stretched.
|
||||
pub(crate) fn zoom_along(&self, zoom: f64, axis: vectorcraft_geom::Vec2) -> f64 {
|
||||
zoom / (axis.x * axis.x / self.stretch[0] + axis.y * axis.y / self.stretch[1])
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, scale: RulerScale, t: &Tokens) {
|
||||
let p = ui.painter();
|
||||
let [top, left, corner] = ruler_rects(full);
|
||||
for r in [top, left, corner] {
|
||||
p.rect_filled(r, 0.0, t.ruler);
|
||||
}
|
||||
p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.border));
|
||||
p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border));
|
||||
// Crosshair in the origin box.
|
||||
p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick));
|
||||
p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick));
|
||||
let font = egui::FontId::proportional(9.5);
|
||||
// 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, its length, and the label of a labelled one.
|
||||
let ticks = |from: Pos2, to: Pos2, axis: vectorcraft_geom::Vec2, draw: &mut dyn FnMut(f32, f32, Option<String>)| {
|
||||
// Pick a label step (in the unit) that gives ≥ 50 px between labels on this ruler, at its
|
||||
// own scale; positions below are in the unit (`per` points of the scale each).
|
||||
let zoom = scale.zoom_along(xf.zoom, axis);
|
||||
let (per, step) = (scale.unit.points(), scale.unit.ruler_step(zoom));
|
||||
let minor = scale.unit.ruler_minor(step);
|
||||
let near = |v: f64, every: f64| ((v / every).round() * every - v).abs() < minor * 0.01;
|
||||
let (a, b) = (scale.along(xf.to_doc(from), axis), scale.along(xf.to_doc(to), axis));
|
||||
let mut v = (a / per / minor).floor() * minor;
|
||||
while v * per <= b {
|
||||
// A tick's length: the labelled ones longest, the halves between them shorter.
|
||||
let (len, label) = if near(v, step) {
|
||||
(RULER, Some(ruler_label(v, step)))
|
||||
} else if near(v, step / 2.0) {
|
||||
(7.0, None)
|
||||
} else {
|
||||
(4.0, None)
|
||||
};
|
||||
draw(((v * per - a) * zoom) as f32, len, label);
|
||||
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, len, label| {
|
||||
let sx = top.left() + d;
|
||||
clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick));
|
||||
if let Some(text) = label {
|
||||
clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, text, font.clone(), t.ruler_tick);
|
||||
}
|
||||
});
|
||||
let clip_left = p.with_clip_rect(left);
|
||||
// A label spelled down the ruler is tall, and a long one can reach the next label's place:
|
||||
// "-0.0625" is seven characters, 60 pt of ruler, where a sixteenth-inch step leaves 54 pt at
|
||||
// zoom 12. The ticks all stay; a label that would run into the one above it is left out.
|
||||
let mut filled_to = f32::NEG_INFINITY;
|
||||
ticks(left.left_top(), left.left_bottom(), vectorcraft_geom::Vec2::new(sn, cs), &mut |d, len, label| {
|
||||
let sy = left.top() + d;
|
||||
clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick));
|
||||
if let Some(text) = label {
|
||||
// Vertical labels read top-to-bottom, one digit per line like Illustrator.
|
||||
let top = sy + 2.0;
|
||||
if top < filled_to {
|
||||
return;
|
||||
}
|
||||
filled_to = top + vertical_label_height(text.chars().count());
|
||||
for (k, ch) in text.chars().enumerate() {
|
||||
clip_left.text(
|
||||
pos2(left.left() + 4.0, top + k as f32 * RULER_CHAR),
|
||||
egui::Align2::LEFT_TOP,
|
||||
ch.to_string(),
|
||||
font.clone(),
|
||||
t.ruler_tick,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
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));
|
||||
clip_left.line_segment([pos2(left.left(), s.y), pos2(left.right(), s.y)], Stroke::new(1.0, t.text));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use super::*;
|
||||
|
||||
/// The Home screen (`app.home`; with no document open, unless its preference is off).
|
||||
pub(super) fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(rect, 0.0, t.panel_darker);
|
||||
// Taller than the window (a small window, many recent files): it scrolls. Widgets scrolled out
|
||||
// of view are clipped, so they take no pointer.
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
egui::ScrollArea::vertical().id_salt("home-scroll").auto_shrink([false, false]).show(&mut child, |ui| {
|
||||
let side = (ui.available_width() - 820.0).max(40.0) / 2.0;
|
||||
let width = (ui.available_width() - 2.0 * side).max(0.0);
|
||||
ui.add_space(60.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.add_space(side);
|
||||
ui.allocate_ui_with_layout(vec2(width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
ui.set_width(width);
|
||||
home_content(app, ui);
|
||||
});
|
||||
});
|
||||
ui.add_space(60.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// The Home screen's column: welcome, New / Open, Recent Files, presets and community links.
|
||||
fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(tl!("Welcome to VectorCraft")).font(theme::semibold(26.0)).color(t.text));
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!("Vector illustration — fast, open, scriptable.")).size(14.0).color(t.text_dim));
|
||||
ui.add_space(22.0);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::primary_button(ui, tl!("New file")).clicked() {
|
||||
app.run("file.newDialog", json!({})).ok();
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, tl!("Open")).clicked() {
|
||||
app.run("file.open", json!({})).ok();
|
||||
}
|
||||
});
|
||||
recent(app, ui);
|
||||
ui.add_space(28.0);
|
||||
ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
// A few of New Document's presets (`file.newPresets`), as its cards.
|
||||
let presets = ["Letter", "A4", "Web 1920×1080", "Phone 390×844", "Postcard", "Social Square Post 1080×1080"];
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
|
||||
let cards: Vec<_> = presets.iter().filter_map(|name| vectorcraft_engine::cmd::newdoc::find(&app.session, name)).collect();
|
||||
for s in cards {
|
||||
if crate::dialogs::preset_card(ui, &s, false).clicked() {
|
||||
app.run("file.new", json!({ "preset": s.name })).ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(28.0);
|
||||
ui.label(egui::RichText::new(tl!("Community")).font(theme::semibold(14.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
crate::community::links(app, ui);
|
||||
}
|
||||
|
||||
/// The Home screen's Recent Files (#663): the first of File › Open Recent Files, each its name and
|
||||
/// folder; a click opens it. Nothing when there are none.
|
||||
fn recent(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
const SHOWN: usize = 6;
|
||||
let files: Vec<String> = crate::io::recent_files(app).iter().take(SHOWN).cloned().collect();
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(28.0);
|
||||
ui.label(egui::RichText::new(tl!("Recent Files")).font(theme::semibold(14.0)).color(t.text));
|
||||
ui.add_space(8.0);
|
||||
for path in files {
|
||||
if recent_row(ui, &path).clicked() {
|
||||
// A failure is reported to the user there.
|
||||
let _ = crate::io::open_recent_reporting(app, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One Recent Files row across the Home screen: history icon, name (accent) and folder (dim,
|
||||
/// truncated). The whole row is one button that lights up under the pointer; its tooltip is the
|
||||
/// full path.
|
||||
fn recent_row(ui: &mut Ui, path: &str) -> egui::Response {
|
||||
const PAD: f32 = 6.0;
|
||||
const ICON: f32 = 16.0;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = std::path::Path::new(path);
|
||||
let name = p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned());
|
||||
let folder = p.parent().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), egui::Sense::click());
|
||||
if ui.is_rect_visible(rect) {
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + PAD + ICON / 2.0, rect.center().y), vec2(ICON, ICON));
|
||||
crate::icons::paint(ui, "history", icon, t.icon);
|
||||
let x = icon.right() + 8.0;
|
||||
let name_galley = egui::WidgetText::from(egui::RichText::new(&name).size(13.0).color(t.accent)).into_galley(
|
||||
ui,
|
||||
Some(egui::TextWrapMode::Truncate),
|
||||
(rect.right() - PAD - x).max(0.0),
|
||||
egui::TextStyle::Body,
|
||||
);
|
||||
let folder_x = x + name_galley.size().x + 10.0;
|
||||
ui.painter().galley(egui::pos2(x, rect.center().y - name_galley.size().y / 2.0), name_galley, t.accent);
|
||||
let room = rect.right() - PAD - folder_x;
|
||||
if room > 0.0 && !folder.is_empty() {
|
||||
let folder_galley = egui::WidgetText::from(egui::RichText::new(&folder).size(12.0).color(t.text_dim)).into_galley(
|
||||
ui,
|
||||
Some(egui::TextWrapMode::Truncate),
|
||||
room,
|
||||
egui::TextStyle::Body,
|
||||
);
|
||||
ui.painter().galley(egui::pos2(folder_x, rect.center().y - folder_galley.size().y / 2.0), folder_galley, t.text_dim);
|
||||
}
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
|
||||
resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
use super::artboards::{artboard_name_at, begin_artboard_name_edit, name_press_id, select_named_artboard};
|
||||
use super::drag::{
|
||||
HoverContext, apply_requests, dispatch, drag_art_out, drag_id, drag_layer_id, drag_pos_id, drop_press, mods, on_canvas, pen_pressure,
|
||||
tool_drag_doc_id, tool_drag_moved, tool_frame_moves, tool_holds, tool_press_doc_id,
|
||||
};
|
||||
use super::view::{
|
||||
HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, rotate_about, scroll_view, touch_gestures, trackpad_turn,
|
||||
wheel, whole_pixels, zoom_about,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// Dispatch canvas pointer, keyboard and drop events through the active document view.
|
||||
pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
|
||||
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
|
||||
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
|
||||
let turn_id = egui::Id::unique("canvas-wheel-turn");
|
||||
let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default();
|
||||
let mut turn = turn_before;
|
||||
let (pointer, m, space, (factor, scroll)) =
|
||||
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn)));
|
||||
if turn != turn_before {
|
||||
ui.data_mut(|d| d.insert_temp(turn_id, turn));
|
||||
}
|
||||
if turn.glide != 0.0 {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let hover = pointer.hover_pos().filter(|p| rect.contains(*p));
|
||||
// Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also
|
||||
// slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The
|
||||
// tool's events below take their document points from the view this moves to.
|
||||
if resp.hovered() {
|
||||
if (factor - 1.0).abs() > 1e-6
|
||||
&& let (Some(p), Some(vm)) = (hover, app.view_mut())
|
||||
{
|
||||
zoom_about(vm, rect, p, vm.zoom * factor);
|
||||
}
|
||||
if scroll != egui::Vec2::ZERO {
|
||||
scroll_view(app, ui, scroll);
|
||||
}
|
||||
if let Some(p) = hover
|
||||
&& let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view)
|
||||
&& let Some(vm) = app.view_mut()
|
||||
{
|
||||
rotate_about(vm, rect, p, deg);
|
||||
}
|
||||
}
|
||||
let v = *app.view().unwrap_or(&View::default());
|
||||
let xf = Xf::new(rect, &v);
|
||||
// A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`),
|
||||
// so a tap whose press and lift come in one frame has no hover position: it presses (and
|
||||
// double-taps) where it was seen last (#491).
|
||||
let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p)));
|
||||
let view = app.view_info();
|
||||
touch_gestures(app, ui, rect, view);
|
||||
// Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the
|
||||
// tool's press is dropped, as a right-click presses nothing.
|
||||
if resp.long_touched() {
|
||||
drop_press(app, ui, view);
|
||||
}
|
||||
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
|
||||
// A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows
|
||||
// without moving the mouse (Alt switches the Shape Builder to erase mode).
|
||||
let mods_changed = ui.data_mut(|d| {
|
||||
let id = egui::Id::unique("canvas-mods");
|
||||
let prev = d.get_temp::<egui::Modifiers>(id);
|
||||
d.insert_temp(id, m);
|
||||
prev.is_some_and(|prev| prev != m)
|
||||
});
|
||||
// Where the tool hovers: the pointer over the canvas itself, not over a floating panel, a menu,
|
||||
// a popup or a dialog drawn on top of it ([`on_canvas`]).
|
||||
let hover_tool = hover.filter(|p| on_canvas(ui, resp, *p));
|
||||
// Whether the tool hovered last frame: coming onto the canvas re-hovers it, even after a single
|
||||
// jump egui doesn't count as moving, and leaving it ends the tool's hover (below).
|
||||
let was_over = ui.data_mut(|d| {
|
||||
let id = egui::Id::unique("canvas-hovered");
|
||||
let prev = d.get_temp::<bool>(id).unwrap_or(false);
|
||||
d.insert_temp(id, hover_tool.is_some());
|
||||
prev
|
||||
});
|
||||
// What a hover means besides where the pointer is on screen: the tool, the canvas, the view
|
||||
// (scrolled, zoomed or turned by the wheel, a gesture, keys or a command) and the document and
|
||||
// selection as they are (another tab, an edit, an undo). Under a still pointer, a tool switched
|
||||
// to is hovered at once, and when the rest changes, a tool that asks for it
|
||||
// ([`Session::tool_wants_rehover`]) is hovered again where the pointer is.
|
||||
let context = HoverContext {
|
||||
tool: app.session.tool_id(),
|
||||
canvas: rect,
|
||||
center: v.center,
|
||||
zoom: v.zoom,
|
||||
rotation: v.rotation,
|
||||
doc: app.session.active().map(|st| (st.uid, st.revision)),
|
||||
};
|
||||
let (tool_switched, context_changed) = ui.data_mut(|d| {
|
||||
let id = egui::Id::unique("canvas-hover-context");
|
||||
let prev = d.get_temp::<HoverContext>(id);
|
||||
d.insert_temp(id, context);
|
||||
prev.map_or((false, false), |prev| (prev.tool != context.tool, prev != context))
|
||||
});
|
||||
|
||||
// In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press,
|
||||
// drag or hover, and a drag does not pan the view. A drag that began before the mode was turned
|
||||
// on ends as usual.
|
||||
if app.ui.screen_mode == 3 && matches!(drag, None | Some(Drag::Present | Drag::Gesture)) {
|
||||
let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some();
|
||||
// `press` is this frame's new press, or else the press held so far. A quick tap can be
|
||||
// pressed and released in one frame.
|
||||
let press = if pressed { Some(Drag::Present) } else { drag };
|
||||
if press.is_some() && !pointer.primary_down() {
|
||||
ui.data_mut(|dd| dd.remove::<Drag>(drag_id()));
|
||||
// A tap of several fingers leaves `Drag::Gesture` (`touch_gestures`), and its release
|
||||
// does not move the view to the next artboard.
|
||||
if press == Some(Drag::Present) && resp.clicked() {
|
||||
crate::shortcuts::presentation_step(app, true);
|
||||
}
|
||||
} else if pressed {
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Present));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A pen turned to its eraser end selects the Eraser, and a pen turned back to its tip selects
|
||||
// the tool before it. The switch runs only while the pointer is in this window. It runs before
|
||||
// this frame's press, which then uses the new tool; while a press is held, it waits for the
|
||||
// release. A turn made over another window takes effect when the pointer comes in.
|
||||
if drag.is_none() && ui.input(|i| i.pointer.has_pointer()) {
|
||||
crate::stylus::sync_eraser_tool(app);
|
||||
}
|
||||
let tool = app.session.tool_id();
|
||||
// Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and
|
||||
// Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool.
|
||||
let zoom_mode = if space { m.command } else { tool == "zoom" };
|
||||
let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" };
|
||||
let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" };
|
||||
let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. }));
|
||||
// egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a
|
||||
// press on the canvas itself reaches the tools, else it would land at the canvas's centre.
|
||||
if pointer.primary_pressed()
|
||||
&& resp.hovered()
|
||||
&& !middle_pan
|
||||
&& let Some(p) = at
|
||||
{
|
||||
ui.ctx().memory_mut(|mem| mem.stop_text_input());
|
||||
app.ui.flyout = None;
|
||||
// The artboard's name sits above its corner. A press there renames on a double-click
|
||||
// and does not start a tool gesture (a fitted artboard, a new one, Artboard Options).
|
||||
if let Some(i) = artboard_name_at(app, ui, &xf, p) {
|
||||
ui.data_mut(|dd| dd.insert_temp(name_press_id(), i));
|
||||
} else {
|
||||
let d = if crate::touch::finger_navigates_only() {
|
||||
// A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw.
|
||||
Drag::Gesture
|
||||
} else if zoom_mode {
|
||||
if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } }
|
||||
} else if pan_mode {
|
||||
Drag::Pan { start: p, center: v.center, middle: false }
|
||||
} else if rotate_mode {
|
||||
let c = rect.center();
|
||||
Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space }
|
||||
} else {
|
||||
// Cmd with another tool drags with the selection tool used last (the session lends it).
|
||||
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) };
|
||||
dispatch(app, &ev, view);
|
||||
crate::document_drag::begin(app, ui.ctx(), ev.pos);
|
||||
Drag::Tool
|
||||
};
|
||||
// Panning, zooming or turning the view takes the pointer from the tool's hover.
|
||||
if !tool_holds(d) {
|
||||
app.session.tool_hover_ended(view);
|
||||
}
|
||||
ui.data_mut(|dd| {
|
||||
dd.insert_temp(drag_id(), d);
|
||||
dd.insert_temp(drag_pos_id(), p);
|
||||
dd.insert_temp(drag_layer_id(), resp.layer_id);
|
||||
// A new press has not dragged yet, whatever an interrupted one left behind.
|
||||
dd.remove::<Point>(tool_drag_doc_id());
|
||||
dd.insert_temp(tool_press_doc_id(), xf.to_doc(p));
|
||||
});
|
||||
}
|
||||
} else if drag.is_none()
|
||||
&& resp.hovered()
|
||||
&& pointer.button_pressed(egui::PointerButton::Middle)
|
||||
&& let Some(start) = hover
|
||||
{
|
||||
// Middle-button drag pans the view whatever the tool.
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Pan { start, center: v.center, middle: true }));
|
||||
app.session.tool_hover_ended(view);
|
||||
} else if let Some(d) = drag {
|
||||
// The pointer gone (a pen lifted with its press), the drag ends where it was last seen.
|
||||
let p = pointer.interact_pos().or_else(|| ui.data(|dd| dd.get_temp(drag_pos_id()))).unwrap_or(rect.center());
|
||||
let held = if middle_pan { pointer.button_down(egui::PointerButton::Middle) } else { pointer.primary_down() };
|
||||
if held {
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p));
|
||||
match d {
|
||||
Drag::Pan { start, center, .. } => {
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
if let Some(vm) = app.view_mut() {
|
||||
vm.center = center;
|
||||
pan_pixels(vm, ppp, whole_pixels(p - start, ppp));
|
||||
}
|
||||
}
|
||||
Drag::RotateView { start_angle, start_rot, snaps } => {
|
||||
let c = rect.center();
|
||||
let a = (p.y - c.y).atan2(p.x - c.x) as f64;
|
||||
let mut deg = start_rot + (a - start_angle).to_degrees();
|
||||
if snaps && m.shift {
|
||||
deg = (deg / 15.0).round() * 15.0;
|
||||
}
|
||||
if let Some(vm) = app.view_mut() {
|
||||
vm.rotation = vectorcraft_geom::normalize_deg(deg);
|
||||
}
|
||||
}
|
||||
Drag::ZoomBox { start } => marquee(ui.painter(), egui::Rect::from_two_pos(start, p)),
|
||||
Drag::ZoomScrub { start, held, moved } => {
|
||||
let dt = ui.input(|i| i.unstable_dt).min(0.25);
|
||||
let (held, moved) = (held + dt, moved || (p - start).length() > SCRUB_SLOP);
|
||||
// Dragged sideways it zooms with the pointer, held still it zooms on (Alt: out).
|
||||
let exponent = if moved {
|
||||
f64::from(pointer.delta().x) * SCRUB_ZOOM
|
||||
} else if held > HOLD_DELAY {
|
||||
f64::from(dt) * HOLD_ZOOM * if m.alt { -1.0 } else { 1.0 }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if exponent != 0.0
|
||||
&& let Some(vm) = app.view_mut()
|
||||
{
|
||||
zoom_about(vm, rect, start, vm.zoom * exponent.exp());
|
||||
}
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved }));
|
||||
}
|
||||
// Without a pointer it is over a floating panel's own OS window (`drag_art_out`).
|
||||
Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => {
|
||||
// Back on the canvas: the move goes on from where it left, with the pointer.
|
||||
egui::DragAndDrop::clear_payload(ui.ctx());
|
||||
let ev =
|
||||
PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
|
||||
dispatch(app, &ev, view);
|
||||
ui.data_mut(|dd| {
|
||||
dd.insert_temp(drag_id(), Drag::Tool);
|
||||
dd.insert_temp(drag_pos_id(), p);
|
||||
});
|
||||
}
|
||||
Drag::Art | Drag::Gesture | Drag::Present => {}
|
||||
Drag::Tool if drag_art_out(app, ui, resp, p) => {
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art));
|
||||
}
|
||||
Drag::Tool => {
|
||||
// Every position the pen or mouse passed this frame, not just the last, for a
|
||||
// tool that builds a stroke from them: a fast stroke keeps its curve (a pen or a
|
||||
// mouse reports far more often than frames come). Touch events (a pen on Windows
|
||||
// or the web, a finger) and a macOS pen's moves carry their own pressure; a
|
||||
// mouse's, a pen's on X11 among them, take the host's latest. Other tools get
|
||||
// the last position only, so a costly drag preview runs once a frame.
|
||||
let moves = tool_frame_moves(ui, app);
|
||||
// A frame whose pointer ends where the last one did still moved in between.
|
||||
if tool_drag_moved(ui, &xf, p, &pointer) || !moves.is_empty() {
|
||||
for (at, force) in moves {
|
||||
let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false));
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view);
|
||||
}
|
||||
let ev =
|
||||
PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
// Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts
|
||||
// a quarter second at most.
|
||||
if app.session.tool_wants_ticks() {
|
||||
let dt = f64::from(ui.input(|i| i.unstable_dt).min(0.25));
|
||||
let r = app.session.tool_tick(dt, view);
|
||||
apply_requests(app, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The last position the tool's drag got: none if the press never dragged.
|
||||
let last_drag: Option<Point> = ui.data(|dd| dd.get_temp(tool_drag_doc_id()));
|
||||
ui.data_mut(|dd| {
|
||||
dd.remove::<Drag>(drag_id());
|
||||
dd.remove::<Pos2>(drag_pos_id());
|
||||
dd.remove::<Point>(tool_drag_doc_id());
|
||||
dd.remove::<Point>(tool_press_doc_id());
|
||||
});
|
||||
match d {
|
||||
Drag::ZoomBox { start } => {
|
||||
let r = egui::Rect::from_two_pos(start, p);
|
||||
if let Some(vm) = app.view_mut() {
|
||||
if r.width() > 8.0 && r.height() > 8.0 {
|
||||
// The marquee's center and its size on screen set the view, at any rotation.
|
||||
vm.center = xf.to_doc(r.center());
|
||||
let fill = (rect.width() / r.width()).min(rect.height() / r.height());
|
||||
vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0);
|
||||
} else {
|
||||
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A click (not held, not dragged) steps the zoom as with the marquee.
|
||||
Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => {
|
||||
if let Some(vm) = app.view_mut() {
|
||||
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt));
|
||||
}
|
||||
}
|
||||
Drag::ZoomScrub { .. } => {}
|
||||
Drag::Tool => {
|
||||
// The second click of a double-click: don't fit another artboard on this release.
|
||||
// A double-click on an object does not open Artboard Options. Empty artboard
|
||||
// space still does.
|
||||
if resp.double_clicked() && app.session.tool_id() == "artboard" {
|
||||
app.session.set_tool_option("suppressFitClick", &json!(true));
|
||||
}
|
||||
// A move that came in the release's own frame shapes the preview first: the
|
||||
// tools commit the last preview, which should be where the pointer let go
|
||||
// (#834). Only once the press has dragged, so a click's jitter stays a click
|
||||
// (the Pen's corner anchor, no nudge with Direct Selection).
|
||||
if let Some(last) = last_drag
|
||||
&& last != xf.to_doc(p)
|
||||
{
|
||||
let ev =
|
||||
PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
Drag::Art if on_canvas(ui, resp, p) => {
|
||||
// Came back to the canvas and released in one frame: finish the move here.
|
||||
egui::DragAndDrop::clear_payload(ui.ctx());
|
||||
let pressure = pen_pressure(ui, app, false);
|
||||
let pos = xf.to_doc(p);
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view);
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view);
|
||||
}
|
||||
Drag::Art => {
|
||||
// Released over a panel: the move goes back. (The app ends it before the panels
|
||||
// see the release, so a panel can take the art; this is the headless path.)
|
||||
// With no document open there is nothing to roll back; the tool still gets its up.
|
||||
let _ = app.session.cancel_interaction();
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
|
||||
}
|
||||
Drag::Gesture | Drag::Present => {}
|
||||
Drag::Pan { .. } | Drag::RotateView { .. } => {}
|
||||
}
|
||||
}
|
||||
} else if let Some(p) = hover_tool
|
||||
&& (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover()))
|
||||
{
|
||||
let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
// The pointer left the canvas (for a panel, or out of the window) or went under a floating
|
||||
// panel, menu, popup or dialog, with no press the tool holds: the tool's hover ends, which no
|
||||
// hover event would clear (Alt's Distance Guides).
|
||||
if was_over && hover_tool.is_none() && !drag.is_some_and(tool_holds) {
|
||||
app.session.tool_hover_ended(view);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// The tool works on another thread (a large selection's planar map): it checks each frame
|
||||
// whether the work is done, and then shows the highlight under the pointer.
|
||||
if app.session.tool_waiting() {
|
||||
if let Err(e) = app.session.tool_poll(view, false) {
|
||||
app.status(e.to_string());
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
if pointer.primary_released()
|
||||
&& let Some(i) = ui.data_mut(|d| d.remove_temp::<usize>(name_press_id()))
|
||||
{
|
||||
select_named_artboard(app, i);
|
||||
}
|
||||
if resp.double_clicked()
|
||||
&& let Some(p) = at
|
||||
{
|
||||
if let Some(i) = artboard_name_at(app, ui, &xf, p) {
|
||||
begin_artboard_name_edit(ui, app, i);
|
||||
} else {
|
||||
let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
|
||||
let before = app.session.tool_id().to_string();
|
||||
dispatch(app, &ev, view);
|
||||
// A double-click on type switched a selection tool to the Type tool: the caret goes where
|
||||
// the type was clicked, so typing edits it at once.
|
||||
if before != "type" && app.session.tool_id() == "type" {
|
||||
app.session.set_tool_option("prevTool", &json!(before));
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
dispatch(app, &PointerEvent { kind, ..ev }, view);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if drag.is_some() || pointer.is_moving() || mods_changed {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// A marquee around screen rectangle `r`: dark dashes over a light line, so it shows on a white
|
||||
/// artboard and on the grey pasteboard alike (#725).
|
||||
fn marquee(p: &egui::Painter, r: egui::Rect) {
|
||||
marquee_through(p, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]);
|
||||
}
|
||||
|
||||
/// A [`marquee`] through the screen corners `q` in turn, back to the first: a document rectangle's
|
||||
/// [`Xf::quad`] in a rotated view.
|
||||
pub(super) fn marquee_through(p: &egui::Painter, q: &[Pos2]) {
|
||||
let pts: Vec<Pos2> = q.iter().chain(q.first()).copied().collect();
|
||||
p.add(Shape::line(pts.clone(), Stroke::new(1.0, Color32::from_white_alpha(230))));
|
||||
for w in pts.windows(2) {
|
||||
p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(40)), 3.0, 3.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn to_screen_path(bp: &BezPath, xf: &Xf) -> Vec<Vec<Pos2>> {
|
||||
// Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px).
|
||||
let mut out: Vec<Vec<Pos2>> = vec![];
|
||||
let a = xf.affine();
|
||||
let mut t = bp.clone();
|
||||
t.apply_affine(a);
|
||||
let mut cur: Vec<Pos2> = vec![];
|
||||
kurbo_flatten(&t, 0.25, &mut |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if cur.len() > 1 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur.clear();
|
||||
cur.push(pos2(p.x as f32, p.y as f32));
|
||||
}
|
||||
PathEl::LineTo(p) => cur.push(pos2(p.x as f32, p.y as f32)),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(f) = cur.first().copied() {
|
||||
cur.push(f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if cur.len() > 1 {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn stroke_path(p: &egui::Painter, bp: &BezPath, xf: &Xf, s: Stroke) {
|
||||
for line in to_screen_path(bp, xf) {
|
||||
p.add(Shape::line(line, s));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn c32(rgb: [u8; 3]) -> Color32 {
|
||||
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
|
||||
/// Outline of a node for highlighting (paths, compound children, area type frames, other
|
||||
/// text/image bounds, symbol instances' bounds measured by their art from `symbols`; a clip
|
||||
/// group's clipping path only, as [`walk_drawn`] visits it).
|
||||
pub(super) fn node_outline(n: &Node, symbols: &SymbolBoxes) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
walk_drawn(n, &mut |c| match &c.kind {
|
||||
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
|
||||
// Area type shows its frame: the type area Direct Selection reshapes; type on a path its
|
||||
// path, which the selection tools' brackets sit on; point type shows its baseline(s).
|
||||
NodeKind::Text(t) => {
|
||||
if c.extras().perspective.is_none() {
|
||||
if let Some(path) = t.area_frame().or_else(|| t.type_path()) {
|
||||
bp.extend(path.to_bezpath());
|
||||
} else if matches!(t.kind, vectorcraft_doc::TextKind::Point) {
|
||||
for (a, b) in t.baselines() {
|
||||
bp.move_to(t.xf * a);
|
||||
bp.line_to(t.xf * b);
|
||||
}
|
||||
}
|
||||
} else if let Some(b) = c.geometric_bounds_with(symbols) {
|
||||
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
|
||||
}
|
||||
}
|
||||
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
|
||||
if let Some(b) = c.geometric_bounds_with(symbols) {
|
||||
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
|
||||
}
|
||||
}
|
||||
// A placed document shows its box, turned with it.
|
||||
NodeKind::PlacedDocument(p) => {
|
||||
let [a, b, c, d] = p.corners();
|
||||
bp.move_to(a);
|
||||
[b, c, d].into_iter().for_each(|q| bp.line_to(q));
|
||||
bp.close_path();
|
||||
}
|
||||
// A gradient mesh shows its mesh lines (#1111).
|
||||
NodeKind::Mesh(m) => bp.extend(m.lines().to_bezpath()),
|
||||
// An envelope shows its mesh (or top object), not its content.
|
||||
NodeKind::Envelope { .. } => {
|
||||
if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) {
|
||||
bp.extend(lines.to_bezpath());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
bp
|
||||
}
|
||||
|
||||
/// The width of the key object's outline (Align to Key Object), thicker than the selection's.
|
||||
pub(super) const KEY_OUTLINE: f32 = 2.5;
|
||||
|
||||
/// The most points ([`overlay_points`]) the selection's outlines and anchors, and the hover
|
||||
/// highlight, are drawn for: an Image Trace of a photo selects hundreds of thousands of paths at
|
||||
/// once, and selecting all of an opened PDF can select as many type objects, images and gradient
|
||||
/// mesh pieces. Their outlines would make a mesh larger than the GPU takes in one buffer (#525,
|
||||
/// #2460).
|
||||
pub(super) const OVERLAY_MAX_ANCHORS: usize = 100_000;
|
||||
|
||||
/// Whether `nodes` have more than [`OVERLAY_MAX_ANCHORS`] points to outline ([`overlay_points`];
|
||||
/// counting stops there).
|
||||
pub(super) fn too_many_anchors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> bool {
|
||||
fn over(n: &Node, left: &mut usize) -> bool {
|
||||
match left.checked_sub(overlay_points(n)) {
|
||||
Some(l) => *left = l,
|
||||
None => return true,
|
||||
}
|
||||
// A clip group counts only its clipping path, the one child `walk_drawn` visits.
|
||||
if n.clips() {
|
||||
return n.children().and_then(|children| children.first()).is_some_and(|clip| over(clip, left));
|
||||
}
|
||||
!matches!(n.kind, NodeKind::Envelope { .. }) && n.editing_children().iter().any(|c| over(c, left))
|
||||
}
|
||||
let mut left = OVERLAY_MAX_ANCHORS;
|
||||
nodes.into_iter().any(|n| over(n, &mut left))
|
||||
}
|
||||
|
||||
/// The points counted for `n` itself, without its children, toward [`OVERLAY_MAX_ANCHORS`]: a
|
||||
/// path's anchors; the anchors of area type's frame or of type on a path's path; the four corners
|
||||
/// of the box that area type or type on a path in perspective, images, symbol instances and
|
||||
/// placed documents show; six for point type, its box's corners and the ends of the baseline
|
||||
/// marker a selected one shows; and the points and the ends of the mesh lines of a gradient mesh
|
||||
/// or an envelope.
|
||||
fn overlay_points(n: &Node) -> usize {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => path.anchor_count(),
|
||||
NodeKind::Text(t) => match &t.kind {
|
||||
vectorcraft_doc::TextKind::Point => 6,
|
||||
_ if n.extras().perspective.is_some() => 4,
|
||||
vectorcraft_doc::TextKind::Area { frame } => frame.anchor_count(),
|
||||
vectorcraft_doc::TextKind::OnPath { path, .. } => path.anchor_count(),
|
||||
},
|
||||
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::PlacedDocument(_) => 4,
|
||||
NodeKind::Mesh(m) => m.lines().anchor_count() + m.points.len(),
|
||||
NodeKind::Envelope { .. } => {
|
||||
vectorcraft_doc::live::envelope_overlay(n).map_or(0, |(lines, grid)| lines.anchor_count() + grid.map_or(0, |g| g.points.len()))
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Node::walk`] over what a selection highlight shows: an envelope's content is left out (the
|
||||
/// envelope shows its mesh instead), and a clip group or clipping layer shows only its clipping
|
||||
/// path, its first child, not the art the path clips (#2422).
|
||||
pub(super) fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||||
f(n);
|
||||
if matches!(n.kind, NodeKind::Envelope { .. }) {
|
||||
return;
|
||||
}
|
||||
if n.clips() {
|
||||
if let Some(clip) = n.children().and_then(|children| children.first()) {
|
||||
walk_drawn(clip, f);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in n.editing_children().iter() {
|
||||
walk_drawn(c, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// The topmost editable object under document point `p` at `zoom`, as what is drawn there
|
||||
/// ([`hit_with`]): a swatch or a symbol dropped on art.
|
||||
pub(super) fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
|
||||
hit_with(app, p, zoom, vectorcraft_doc::hit::hit_test)
|
||||
}
|
||||
|
||||
/// The topmost editable object under document point `p` at `zoom`, as the selection tools pick
|
||||
/// it: [`hit_at`], and a clipping path on its outline before the art it clips
|
||||
/// ([`vectorcraft_doc::hit::HitOptions::clip_outlines`]). Object Highlighting and a right-click's
|
||||
/// selection show and take what a click of those tools would.
|
||||
pub(super) fn selection_hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
|
||||
hit_with(app, p, zoom, |doc, p, opt| vectorcraft_doc::hit::hit_test(doc, p, vectorcraft_doc::hit::HitOptions { clip_outlines: true, ..opt }))
|
||||
}
|
||||
|
||||
/// `test` on the active document at document point `p`, with the options the selection tools hit
|
||||
/// objects with at `zoom` (Selection & Anchor Display › Tolerance and Object Selection by Path Only,
|
||||
/// Type › Type Object Selection by Path Only, isolation mode).
|
||||
pub(super) fn hit_with<T>(
|
||||
app: &VectorcraftApp,
|
||||
p: Point,
|
||||
zoom: f64,
|
||||
test: impl FnOnce(&vectorcraft_doc::Document, Point, vectorcraft_doc::hit::HitOptions) -> Option<T>,
|
||||
) -> Option<T> {
|
||||
let prefs = &app.session.prefs;
|
||||
let opt = vectorcraft_doc::hit::HitOptions {
|
||||
tol: prefs.selection_tolerance / zoom,
|
||||
outline: app.ui.view.outline,
|
||||
path_only: prefs.object_selection_by_path_only,
|
||||
type_path_only: prefs.type_selection_by_path_only,
|
||||
scope: app.session.active().and_then(|st| st.isolation),
|
||||
skip_guides: !app.ui.view.guides,
|
||||
..Default::default()
|
||||
};
|
||||
test(&app.session.active()?.doc, p, opt)
|
||||
}
|
||||
|
||||
fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) {
|
||||
// Bounded per curve: a document's far-out handles, zoomed onto the screen, made kurbo
|
||||
// reserve gigabytes for the selection outline.
|
||||
vectorcraft_geom::flatten(p.elements().iter().copied(), tol, f);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
use super::artboards::names_an_artboard;
|
||||
use super::input::marquee_through;
|
||||
use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors, walk_drawn};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(h) = app.hover_doc else { return };
|
||||
// Smart Guides › Object Highlighting: a Smart Guides display option, so it needs them on and Snap to Grid and Pixel Preview off.
|
||||
if !app.ui.view.smart_guides || !app.session.prefs.object_highlighting || app.ui.view.snap_to_grid || app.ui.view.pixel_preview {
|
||||
return;
|
||||
}
|
||||
if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let Some(hit) = selection_hit_at(app, h, xf.zoom) else { return };
|
||||
let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf };
|
||||
if st.selection.contains(id) {
|
||||
return;
|
||||
}
|
||||
if let Some(n) = st.doc.node(id).filter(|n| !too_many_anchors([*n])) {
|
||||
let color = c32(st.doc.layer_color(id));
|
||||
stroke_path(p, &node_outline(n, &st.doc.symbol_boxes()), xf, Stroke::new(1.5, color));
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected anchors are drawn slightly deeper than the layer colour (#4f80ff → #3d82ff for Layer 1).
|
||||
pub(super) fn selected_anchor(c: Color32) -> Color32 {
|
||||
if c == Color32::from_rgb(0x4f, 0x80, 0xff) { Color32::from_rgb(0x3d, 0x82, 0xff) } else { c }
|
||||
}
|
||||
|
||||
/// How anchors and handles look: Selection & Anchor Display › Size and Handles.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct HandleLook<'a> {
|
||||
/// Points bigger (or smaller) than the default size 3, per step of Size (1–7).
|
||||
grow: f32,
|
||||
/// `solid` (the default), `hollow` or `large`.
|
||||
style: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> HandleLook<'a> {
|
||||
pub(super) fn of(prefs: &'a vectorcraft_engine::Prefs) -> Self {
|
||||
Self { grow: prefs.anchor_size.clamp(1, 7) as f32 - 3.0, style: &prefs.handle_style }
|
||||
}
|
||||
|
||||
/// A direction handle's end at `c`: a solid dot, a hollow one or a larger solid one.
|
||||
fn draw(self, p: &egui::Painter, c: Pos2, color: Color32) {
|
||||
let r = 2.75 + self.grow / 2.0;
|
||||
let hollow = self.style == "hollow";
|
||||
p.circle_filled(c, if self.style == "large" { r + 1.5 } else { r }, if hollow { Color32::WHITE } else { color });
|
||||
if hollow {
|
||||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
|
||||
let r = egui::Rect::from_center_size(c, vec2(size, size));
|
||||
if filled {
|
||||
p.rect_filled(r, 0.0, color);
|
||||
} else {
|
||||
p.rect_filled(r, 0.0, Color32::WHITE);
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
|
||||
fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
|
||||
p.circle(c, size / 2.0, if filled { color } else { Color32::WHITE }, Stroke::new(1.0, color));
|
||||
}
|
||||
|
||||
pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) {
|
||||
// The Selection tool's bounding box (rotated with the objects after a rotation). It hides while
|
||||
// a drag moves or resizes the selection, so only the art is seen going with the pointer (#712).
|
||||
let show_box = app.session.tool_id() == "selection"
|
||||
&& !app.session.tool_transforming()
|
||||
&& app.ui.view.bounding_box
|
||||
&& app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
|
||||
let bbox = if show_box { app.selection_box() } else { None };
|
||||
let st = app.session.active();
|
||||
let big = st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id))));
|
||||
let big_bounds = if big { app.selection_bounds() } else { None };
|
||||
let app = &*app;
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
let tool = app.session.tool_id();
|
||||
let direct = vectorcraft_tools::catalog::edits_anchors(tool);
|
||||
let curvature = tool == "curvature";
|
||||
// Selection & Anchor Display › Size (1–7, 3 the default): anchors, handles and the bounding
|
||||
// box's handles a point bigger or smaller per step.
|
||||
let look = HandleLook::of(&app.session.prefs);
|
||||
let grow = look.grow;
|
||||
let anchor = |direct: bool| grow + if direct { 5.0 } else { 4.0 };
|
||||
// The selected anchors' handles (drawn once they are counted: Show handles when multiple
|
||||
// anchors are selected off shows them for a single one only).
|
||||
let (mut handles, mut with_handles) = (vec![], 0);
|
||||
// Too many paths to outline: the selection's bounds stand for them.
|
||||
if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) {
|
||||
stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id))));
|
||||
}
|
||||
// The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has
|
||||
// too many paths to outline.
|
||||
if let Some((k, n)) = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))) {
|
||||
let outline = if too_many_anchors([n]) {
|
||||
n.geometric_bounds_with(&symbols).map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath())
|
||||
} else {
|
||||
Some(node_outline(n, &symbols))
|
||||
};
|
||||
if let Some(bp) = outline {
|
||||
stroke_path(p, &bp, xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k))));
|
||||
}
|
||||
}
|
||||
for id in st.selection.objects.iter().filter(|_| !big) {
|
||||
let Some(n) = st.doc.node(*id) else { continue };
|
||||
let color = c32(st.doc.layer_color(*id));
|
||||
let partial = st.selection.partial(*id);
|
||||
// Path outlines (and area type / placed document / image / mesh / symbol outlines;
|
||||
// point type baselines and origin are drawn below).
|
||||
if !matches!(n.kind, NodeKind::Text(ref tx) if matches!(tx.kind, vectorcraft_doc::TextKind::Point)) {
|
||||
stroke_path(p, &node_outline(n, &symbols), xf, Stroke::new(1.0, color));
|
||||
}
|
||||
// Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh
|
||||
// envelope's points.
|
||||
walk_drawn(n, &mut |c| {
|
||||
let envelope = vectorcraft_doc::live::envelope_overlay(c).and_then(|(_, grid)| grid);
|
||||
let grid = match &c.kind {
|
||||
NodeKind::Mesh(m) => Some(m),
|
||||
_ => envelope.as_ref(),
|
||||
};
|
||||
if let Some(grid) = grid {
|
||||
for q in &grid.points {
|
||||
anchor_square(p, xf.to_screen(q.p), color, false, anchor(direct));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let NodeKind::Path { path, .. } = &c.kind else { return };
|
||||
// The Curvature tool owns its points while drawing; existing paths use this overlay.
|
||||
if curvature && curve_points_from_tool {
|
||||
return;
|
||||
}
|
||||
let selected_segments = st.selection.segments.get(&c.id);
|
||||
if let Some(segments) = selected_segments {
|
||||
for &(si, seg) in segments {
|
||||
let Some(sp) = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()) else { continue };
|
||||
let curve = sp.segment(seg);
|
||||
let mut outline = vectorcraft_geom::BezPath::new();
|
||||
outline.move_to(curve.p0);
|
||||
if sp.segment_is_line(seg) {
|
||||
outline.line_to(curve.p3);
|
||||
} else {
|
||||
outline.curve_to(curve.p1, curve.p2, curve.p3);
|
||||
}
|
||||
stroke_path(p, &outline, xf, Stroke::new(2.0, color));
|
||||
}
|
||||
}
|
||||
for (si, ai, a) in path.anchors() {
|
||||
// An object selected whole has all its anchors selected, and so do the paths inside a
|
||||
// group, compound path or clip group selected whole. Direction handles show only on a
|
||||
// path that is selected itself: those are the handles that `handle_anchors` in
|
||||
// `vectorcraft-tools` lets the tools drag.
|
||||
let (sel, handles_shown) = match partial {
|
||||
Some(set) => {
|
||||
let sel = set.contains(&(si, ai));
|
||||
(sel, sel)
|
||||
}
|
||||
None => (true, direct && c.id == *id),
|
||||
};
|
||||
let sp = xf.to_screen(a.p);
|
||||
if !curvature && handles_shown {
|
||||
with_handles += 1;
|
||||
let sides = path.subpaths.get(si).map(|subpath| st.selection.handle_sides(c.id, si, ai, subpath)).unwrap_or((true, true));
|
||||
handles.extend(
|
||||
[(a.h_in, sides.0), (a.h_out, sides.1)]
|
||||
.into_iter()
|
||||
.filter(|(h, show)| *show && h.distance(a.p) > 1e-6)
|
||||
.map(|(h, _)| (sp, xf.to_screen(h), color)),
|
||||
);
|
||||
}
|
||||
let anchor_selected = sel && selected_segments.is_none();
|
||||
let ink = if anchor_selected && partial.is_some() { selected_anchor(color) } else { color };
|
||||
if curvature {
|
||||
curve_point(p, sp, ink, anchor_selected && partial.is_some(), grow + 6.0);
|
||||
} else {
|
||||
anchor_square(p, sp, ink, anchor_selected, anchor(partial.is_some() || direct));
|
||||
}
|
||||
}
|
||||
});
|
||||
// Centre point (Attributes panel → Show Center).
|
||||
if n.shows_center()
|
||||
&& let Some(b) = n.geometric_bounds_with(&symbols)
|
||||
{
|
||||
anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false));
|
||||
}
|
||||
// Point type: each line's baseline, where a click selects it (`hit`), whatever its
|
||||
// alignment and rotation, and the anchor (area type shows its frame instead, which may
|
||||
// not be a rectangle, and type on a path its path).
|
||||
if let NodeKind::Text(tx) = &n.kind
|
||||
&& matches!(tx.kind, vectorcraft_doc::TextKind::Point)
|
||||
{
|
||||
for (a, b) in tx.baselines() {
|
||||
p.line_segment([xf.to_screen(tx.xf * a), xf.to_screen(tx.xf * b)], Stroke::new(1.0, color));
|
||||
}
|
||||
p.circle_filled(xf.to_screen(tx.xf * Point::ZERO), 2.5, color);
|
||||
}
|
||||
}
|
||||
if app.session.prefs.show_handles_multiple_anchors || with_handles <= 1 {
|
||||
for (sp, hp, color) in handles {
|
||||
p.line_segment([sp, hp], Stroke::new(1.0, color));
|
||||
look.draw(p, hp, color);
|
||||
}
|
||||
}
|
||||
// The spine of each selected blend (or of the blend a selected key object belongs to).
|
||||
let mut spines = vec![];
|
||||
for id in &st.selection.objects {
|
||||
let is_blend = |b: &vectorcraft_doc::NodeId| st.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. }));
|
||||
let Some(b) = [Some(*id), st.doc.parent_of(*id)].into_iter().flatten().find(is_blend).filter(|b| !spines.contains(b)) else { continue };
|
||||
spines.push(b);
|
||||
let Some(NodeKind::Blend { children, spec }) = st.doc.node(b).map(|n| &n.kind) else { continue };
|
||||
let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec) else { continue };
|
||||
let color = c32(st.doc.layer_color(b));
|
||||
stroke_path(p, &path.to_bezpath(), xf, Stroke::new(1.0, color));
|
||||
for (_, _, a) in path.anchors() {
|
||||
anchor_square(p, xf.to_screen(a.p), color, false, anchor(direct));
|
||||
}
|
||||
}
|
||||
// Live Corners widgets (Selection on a live rectangle or polygon, Direct Selection on any path).
|
||||
if matches!(tool, "selection" | "directSelection")
|
||||
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::showing(
|
||||
&st.doc,
|
||||
&st.selection,
|
||||
xf.zoom,
|
||||
tool == "directSelection",
|
||||
app.ui.view.corner_widgets,
|
||||
app.session.prefs.hide_corner_widget_above,
|
||||
)
|
||||
{
|
||||
for (id, q) in w.visible_on() {
|
||||
let (sp, color) = (xf.to_screen(q), c32(st.doc.layer_color(id)));
|
||||
p.circle_filled(sp, 3.0, Color32::WHITE);
|
||||
p.circle_stroke(sp, 3.0, Stroke::new(1.0, color));
|
||||
p.circle_filled(sp, 1.0, color);
|
||||
}
|
||||
}
|
||||
// Bounding box with handles (Selection tool).
|
||||
if let Some(b) = bbox {
|
||||
let color = c32(st.doc.layer_color(st.selection.objects[0]));
|
||||
p.add(Shape::closed_line(b.corners().iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.0, color)));
|
||||
for h in vectorcraft_tools::bbox::Handle::ALL {
|
||||
let c = xf.to_screen(b.to_doc() * h.pos(b.rect));
|
||||
let hr = egui::Rect::from_center_size(c, vec2(6.0 + grow, 6.0 + grow));
|
||||
p.rect_filled(hr, 0.0, Color32::WHITE);
|
||||
p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
|
||||
}
|
||||
// The type widget beside it: hollow on point type, filled on area type.
|
||||
if let Some(w) = vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &b, xf.zoom) {
|
||||
let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::typewidget::RADIUS_PX);
|
||||
p.circle_filled(c, r, if w.area { color } else { Color32::WHITE });
|
||||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||||
}
|
||||
}
|
||||
// Pie widgets (Selection tool, with View › Show Bounding Box, on live ellipses): each joined
|
||||
// by a line to the outline's point at its angle. They stay while one is dragged.
|
||||
if tool == "selection" && app.ui.view.bounding_box {
|
||||
for w in vectorcraft_tools::pie::PieWidget::of(&st.doc, &st.selection, xf.zoom) {
|
||||
let (on, c, r, color) = (xf.to_screen(w.on), xf.to_screen(w.at), vectorcraft_tools::pie::RADIUS_PX, c32(st.doc.layer_color(w.id)));
|
||||
p.line_segment([on, c], Stroke::new(1.0, color));
|
||||
p.circle_filled(c, r, Color32::WHITE);
|
||||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record where the Type tool's IME candidate window goes this pass, if it has an editor.
|
||||
///
|
||||
/// The descriptor itself is observed once at the end of the pass (`crate::ime::end_of_pass`),
|
||||
/// after dialogs, floating panels, progress windows and the palette draw: any of them can open
|
||||
/// over the canvas, take the keyboard or run a command that changes the edited text, so a
|
||||
/// descriptor written here would carry a stale owner, surrounding text or cause.
|
||||
pub(super) fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
|
||||
app.ime_caret_rect = None;
|
||||
// A hidden viewport cannot compose; the end-of-pass observation clears the owner.
|
||||
if !app.session.tool_wants_text() || app.viewport_hidden(ctx) {
|
||||
return;
|
||||
}
|
||||
let Some((a, b)) = app.session.tool_ime_caret(app.view_info()) else { return };
|
||||
let r = egui::Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand2(vec2(1.0, 0.0));
|
||||
app.ime_caret_rect = Some((r, r));
|
||||
}
|
||||
|
||||
pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) {
|
||||
for o in overlays {
|
||||
match o {
|
||||
// Where the tools select: the document rectangle, turned with the view.
|
||||
Overlay::Marquee(r) => marquee_through(p, &xf.quad(*r)),
|
||||
Overlay::Path { path, color, width, dashed } => {
|
||||
let s = Stroke::new(*width, c32(*color));
|
||||
if *dashed {
|
||||
for line in to_screen_path(path, xf) {
|
||||
p.extend(Shape::dashed_line(&line, s, 4.0, 3.0));
|
||||
}
|
||||
} else {
|
||||
stroke_path(p, path, xf, s);
|
||||
}
|
||||
}
|
||||
Overlay::Line { a, b, color, dashed } => {
|
||||
let s = Stroke::new(1.0, c32(*color));
|
||||
let (a, b) = (xf.to_screen(*a), xf.to_screen(*b));
|
||||
if *dashed {
|
||||
p.extend(Shape::dashed_line(&[a, b], s, 4.0, 3.0));
|
||||
} else {
|
||||
p.line_segment([a, b], s);
|
||||
}
|
||||
}
|
||||
Overlay::Anchor { p: pt, color, filled, size } => anchor_square(p, xf.to_screen(*pt), c32(*color), *filled, *size),
|
||||
Overlay::CurvePoint { p: pt, color, filled, size } => curve_point(p, xf.to_screen(*pt), c32(*color), *filled, *size + look.grow),
|
||||
Overlay::Handle { p: pt, color } => look.draw(p, xf.to_screen(*pt), c32(*color)),
|
||||
Overlay::Label { p: pt, text, color } => {
|
||||
let sp = xf.to_screen(*pt) + vec2(8.0, -14.0);
|
||||
p.text(
|
||||
sp,
|
||||
egui::Align2::LEFT_TOP,
|
||||
crate::panels::label_or_name(text, !names_an_artboard(text)),
|
||||
egui::FontId::proportional(11.0),
|
||||
c32(*color),
|
||||
);
|
||||
}
|
||||
Overlay::Highlight { quad, color } => {
|
||||
let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);
|
||||
p.add(Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), c, Stroke::NONE));
|
||||
}
|
||||
Overlay::Measure { p: pt, text } => {
|
||||
let sp = xf.to_screen(*pt) + vec2(14.0, 14.0);
|
||||
let galley = p.layout(text.clone(), egui::FontId::proportional(11.0), Color32::WHITE, 200.0);
|
||||
let r = egui::Rect::from_min_size(sp, galley.size() + vec2(12.0, 8.0));
|
||||
p.rect_filled(r, CornerRadius::same(3), t.measure_bg);
|
||||
p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE);
|
||||
}
|
||||
Overlay::Distance { a, b, text, color } => distance_guide(p, xf.to_screen(*a), xf.to_screen(*b), text, c32(*color)),
|
||||
Overlay::GridLine { a, b, color } => {
|
||||
let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);
|
||||
p.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, c));
|
||||
}
|
||||
Overlay::Swatch { p: pt, color, selected } => {
|
||||
// A white disc under the colour shows its opacity; a dark rim keeps it readable on
|
||||
// any art, and the accent ring marks the selected stop.
|
||||
let c = xf.to_screen(*pt);
|
||||
p.circle_filled(c, 6.0, Color32::WHITE);
|
||||
p.circle_filled(c, 5.0, Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]));
|
||||
p.circle_stroke(c, 6.0, Stroke::new(1.0, Color32::from_gray(32)));
|
||||
if *selected {
|
||||
p.circle_stroke(c, 8.0, Stroke::new(2.0, t.accent));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Distance Guides measurement on screen ([`Overlay::Distance`]): the line from `a` to `b` in the
|
||||
/// smart guide colour `color` with a 4 px tick across each end, and `text` in a pill of that colour
|
||||
/// on its middle, in white or black, whichever reads on it.
|
||||
fn distance_guide(p: &egui::Painter, a: Pos2, b: Pos2, text: &str, color: Color32) {
|
||||
let s = Stroke::new(1.0, color);
|
||||
p.line_segment([a, b], s);
|
||||
let along = b - a;
|
||||
let len = along.length();
|
||||
if len > 0.0 && len.is_finite() {
|
||||
let across = vec2(-along.y, along.x) / len * 4.0;
|
||||
for end in [a, b] {
|
||||
p.line_segment([end - across, end + across], s);
|
||||
}
|
||||
}
|
||||
let luma = 0.299 * f32::from(color.r()) + 0.587 * f32::from(color.g()) + 0.114 * f32::from(color.b());
|
||||
let ink = if luma > 150.0 { Color32::BLACK } else { Color32::WHITE };
|
||||
let galley = p.layout_no_wrap(text.to_owned(), egui::FontId::proportional(11.0), ink);
|
||||
let r = egui::Rect::from_center_size(a + along / 2.0, galley.size() + vec2(8.0, 4.0));
|
||||
p.rect_filled(r, CornerRadius::same(3), color);
|
||||
p.galley(r.min + vec2(4.0, 2.0), galley, ink);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use super::*;
|
||||
|
||||
/// The rendered raster `img` as an egui image. Both are premultiplied RGBA8, so its bytes are copied
|
||||
/// over whole instead of pixel by pixel, which costs milliseconds for a canvas on a large screen.
|
||||
/// A raster whose bytes don't match its size gives an empty image of that size.
|
||||
pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImage {
|
||||
let size = [img.width as usize, img.height as usize];
|
||||
let mut pixels = vec![egui::Color32::TRANSPARENT; size[0] * size[1]];
|
||||
let bytes: &mut [u8] = bytemuck::cast_slice_mut(&mut pixels);
|
||||
if bytes.len() == img.pixels.len() {
|
||||
bytes.copy_from_slice(&img.pixels);
|
||||
}
|
||||
egui::ColorImage::new(size, pixels)
|
||||
}
|
||||
|
||||
/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document
|
||||
/// changed, or placed documents' bitmaps were made, since the frame shown (its key `key` but for
|
||||
/// the revision and [`CacheKey::placed`], and its render options `opts`), which this thread
|
||||
/// rendered, and no frame is on its way from the worker: those it changed in, and those the
|
||||
/// objects showing those bitmaps' files cover (`Some(None)`: none), grown to whole eighths of the
|
||||
/// frame, as long as they are at most half of it. None: render the whole frame. Frames the worker
|
||||
/// renders stay whole: its renderer keeps the rasters of the art's effects, this thread's doesn't.
|
||||
pub(super) fn changed_pixels(
|
||||
app: &VectorcraftApp,
|
||||
key: &CacheKey,
|
||||
doc: &vectorcraft_doc::Document,
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
view: Affine,
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Option<Option<[u32; 4]>> {
|
||||
let (shown, _) = app.canvas.shown.as_ref().filter(|(_, o)| o == opts)?;
|
||||
// A viewed opacity mask shows its coverage wherever its art reaches, past its object's bounds.
|
||||
if opts.mask_view.is_some() {
|
||||
return None;
|
||||
}
|
||||
let shown_key = app.canvas.key.as_ref()?;
|
||||
let same_view = *shown_key == CacheKey { revision: shown_key.revision, placed: shown_key.placed, ..key.clone() };
|
||||
if !same_view || app.canvas.texture.is_none() || app.canvas.from_worker || app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) {
|
||||
return None;
|
||||
}
|
||||
// The placed documents' files whose bitmaps were made since the frame shown.
|
||||
let placed = if shown_key.placed == key.placed {
|
||||
vectorcraft_render::Changed::Nothing
|
||||
} else {
|
||||
vectorcraft_render::placed_area(doc, &vectorcraft_render::placed_document::made_between(shown_key.placed, key.placed)?)
|
||||
};
|
||||
match vectorcraft_render::changed(shown, doc).and(placed) {
|
||||
vectorcraft_render::Changed::Nothing => Some(None),
|
||||
vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) {
|
||||
None => Some(None),
|
||||
Some([x0, y0, x1, y1]) => {
|
||||
let ((x0, x1), (y0, y1)) = (in_steps(x0, x1, w), in_steps(y0, y1, h));
|
||||
(u64::from(x1 - x0) * u64::from(y1 - y0) * 2 <= u64::from(w) * u64::from(h)).then_some(Some([x0, y0, x1, y1]))
|
||||
}
|
||||
},
|
||||
vectorcraft_render::Changed::Everything => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `a..b` of `0..n`, grown to whole eighths of `n` and moved back into `0..n`: the parts of a
|
||||
/// frame rendered again come in few sizes, which keep the contexts the renderer keeps.
|
||||
fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) {
|
||||
let step = n.div_ceil(8).max(1);
|
||||
let len = (b - a).max(1).div_ceil(step).saturating_mul(step).min(n);
|
||||
let a = a.min(n - len);
|
||||
(a, a + len)
|
||||
}
|
||||
|
||||
/// Upload the art's raster; Pixel Preview's document pixels keep hard edges when magnified.
|
||||
pub(super) fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered, pixel: bool) {
|
||||
let color = color_image(img);
|
||||
let options = if pixel { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR };
|
||||
match &mut app.canvas.texture {
|
||||
Some(tex) => tex.set(color, options),
|
||||
None => app.canvas.texture = Some(ctx.load_texture("canvas", color, options)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel Preview: the whole document pixels (x0, y0, x1, y1) under the canvas, rotated view
|
||||
/// included. None when the view is degenerate.
|
||||
pub(super) fn pixel_region(xf: &Xf) -> Option<[i64; 4]> {
|
||||
let r = xf.rect;
|
||||
let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| xf.to_doc(p));
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in corners {
|
||||
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
|
||||
}
|
||||
let (x0, y0, x1, y1) = (x0.floor(), y0.floor(), x1.ceil(), y1.ceil());
|
||||
// A document pixel is bigger than a screen pixel here, so the region is about canvas-sized.
|
||||
let ok = [x0, y0, x1, y1].iter().all(|c| c.is_finite() && c.abs() < 1e9) && x1 > x0 && y1 > y0 && x1 - x0 <= 16384.0 && y1 - y0 <= 16384.0;
|
||||
ok.then_some([x0 as i64, y0 as i64, x1 as i64, y1 as i64])
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
use super::*;
|
||||
|
||||
/// The Contextual Task Bar's area.
|
||||
fn task_bar_id() -> egui::Id {
|
||||
egui::Id::unique("task-bar")
|
||||
}
|
||||
|
||||
/// Where the Contextual Task Bar was last drawn (none while it isn't shown).
|
||||
pub(crate) fn task_bar_rect(ctx: &egui::Context) -> Option<egui::Rect> {
|
||||
let id = task_bar_id();
|
||||
ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten())
|
||||
}
|
||||
|
||||
/// Whether the objects `ids` (or anything inside them) are painted with a colour: a solid colour,
|
||||
/// a gradient's stops or a mesh's points, which Recolor Artwork can change.
|
||||
pub(crate) fn has_colors(doc: &vectorcraft_doc::Document, ids: &[vectorcraft_doc::NodeId]) -> bool {
|
||||
ids.iter().filter_map(|id| doc.node(*id)).any(|n| {
|
||||
let mut found = false;
|
||||
vectorcraft_doc::swatches::node_colors(n, &mut |_, _| found = true);
|
||||
found
|
||||
})
|
||||
}
|
||||
|
||||
/// The bounds of the artboards the Artboard tool has selected ([`crate::panels::artboards::picked`]).
|
||||
fn picked_artboards_bounds(app: &VectorcraftApp) -> Option<Rect> {
|
||||
let st = app.session.active()?;
|
||||
let picked = crate::panels::artboards::picked(app, st.doc.artboards.len());
|
||||
picked.iter().filter_map(|i| st.doc.artboards.get(*i)).map(|a| a.rect).reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// The Contextual Task Bar: a floating pill under the selection with the most likely next actions.
|
||||
/// With the Artboard tool it sits under the selected artboards and offers Resize (Artboard
|
||||
/// Options), Duplicate, Rearrange All (with two or more artboards) and Export (Export for Screens).
|
||||
/// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the
|
||||
/// selection, pinned it stays put ([`crate::state::TaskBarPlace`]).
|
||||
pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let on_artboards = app.session.tool_id() == "artboard";
|
||||
if !on_artboards
|
||||
&& (app.session.active().is_none_or(|st| st.selection.is_empty())
|
||||
|| !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return };
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let n = st.selection.len();
|
||||
let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned();
|
||||
let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. }));
|
||||
let is_image = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Image(_)));
|
||||
let is_clip_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { clip: true, .. }));
|
||||
let is_compound = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Compound { .. }));
|
||||
let open_path = first.as_ref().is_some_and(|f| matches!(&f.kind, NodeKind::Path { path, .. } if !path.is_closed()));
|
||||
let text = first.as_ref().and_then(|f| match &f.kind {
|
||||
NodeKind::Text(t) => Some(&t.kind),
|
||||
_ => None,
|
||||
});
|
||||
// Images and vector art selected together: a clipping mask is the likely next step.
|
||||
let mixed = n > 1 && {
|
||||
let images = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).filter(|o| matches!(o.kind, NodeKind::Image(_))).count();
|
||||
images > 0 && images < n
|
||||
};
|
||||
// An Image Trace object: the group the engine names "Image Trace", its source image first.
|
||||
let is_trace = is_group
|
||||
&& first.as_ref().is_some_and(|f| {
|
||||
f.name.as_deref() == Some("Image Trace") && f.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
|
||||
});
|
||||
let mut items: Vec<(&str, &str, &str, serde_json::Value)> = vec![]; // (label, icon, command, params)
|
||||
let none = || json!({});
|
||||
if on_artboards {
|
||||
// As in the reference app, the Artboard tool offers to resize, duplicate and export the
|
||||
// artboards (<https://community.adobe.com/questions-652/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>),
|
||||
// and to rearrange them (<https://creativepro.com/managing-artboards-in-illustrator/>).
|
||||
items.push((tl!("Resize"), "move-diagonal-2", TASK_BAR_ARTBOARD_OPTIONS, none()));
|
||||
items.push((tl!("Duplicate"), "copy", TASK_BAR_DUPLICATE_ARTBOARDS, none()));
|
||||
if st.doc.artboards.len() > 1 {
|
||||
items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none()));
|
||||
}
|
||||
items.push((tl!("Export"), "share-2", "file.exportForScreens", none()));
|
||||
} else {
|
||||
// Edit Path: the Direct Selection tool, to edit the path's anchors.
|
||||
let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"}));
|
||||
// As in the reference app, an image offers Image Trace and Crop Image, and traced art offers
|
||||
// Expand (<https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>).
|
||||
if n == 1 && is_image {
|
||||
items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE, none()));
|
||||
items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage", none()));
|
||||
}
|
||||
// Direct-selected anchors take the place of a path's Offset Path and Simplify. For them the bar
|
||||
// offers Remove, Connect, Cut and Convert to corner or smooth, as the reference app does
|
||||
// (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>).
|
||||
// These run the same commands as the Control bar's anchor buttons.
|
||||
let anchors = !st.selection.anchors.is_empty();
|
||||
if anchors {
|
||||
items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors", none()));
|
||||
items.push((tl!("Connect"), "dc-join", "path.join", none()));
|
||||
items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors", none()));
|
||||
items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none()));
|
||||
items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none()));
|
||||
}
|
||||
// As in the reference app, open paths offer Join and Edit Path, compound paths and clipping
|
||||
// masks their Release, images selected with vector art Make Clipping Mask, and type converts
|
||||
// between point and area type (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>).
|
||||
if n > 1 {
|
||||
items.push((tl!("Group"), "group", "object.group", none()));
|
||||
if mixed {
|
||||
items.push((tl!("Make Clipping Mask"), "dc-pf-crop", "object.clippingMask.make", none()));
|
||||
} else {
|
||||
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite", none()));
|
||||
}
|
||||
} else if is_trace {
|
||||
items.push((tl!("Expand"), "maximize-2", "imageTrace.expand", none()));
|
||||
} else if is_clip_group {
|
||||
items.push((tl!("Edit Clipping Path"), "dc-direct", "object.clippingMask.editMask", none()));
|
||||
items.push((tl!("Release Clipping Mask"), "ungroup", "object.clippingMask.release", none()));
|
||||
} else if is_group {
|
||||
items.push((tl!("Ungroup"), "ungroup", "object.ungroup", none()));
|
||||
items.push((tl!("Isolate"), "square-dashed", "object.isolate", none()));
|
||||
} else if is_compound {
|
||||
items.push(edit_path());
|
||||
items.push((tl!("Release Compound Path"), "ungroup", "object.compoundPath.release", none()));
|
||||
} else if let Some(kind) = text {
|
||||
items.push((tl!("Create Outlines"), "type", "type.createOutlines", none()));
|
||||
// Point type converts to area type and back; type on a path doesn't.
|
||||
match kind {
|
||||
TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())),
|
||||
TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())),
|
||||
TextKind::OnPath { .. } => {}
|
||||
}
|
||||
} else if is_image {
|
||||
// Offset Path and Simplify don't apply to an image.
|
||||
} else if !anchors {
|
||||
if open_path {
|
||||
items.push((tl!("Join"), "dc-join", "path.join", none()));
|
||||
}
|
||||
items.push(edit_path());
|
||||
items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath", none()));
|
||||
items.push((tl!("Simplify"), "spline", "object.path.simplify", none()));
|
||||
}
|
||||
items.push((tl!("Duplicate"), "copy", "edit.duplicate", none()));
|
||||
}
|
||||
let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default();
|
||||
let colored = has_colors(&st.doc, &st.selection.objects);
|
||||
let doc = st.uid;
|
||||
// Under the selection's lowest corner on screen, centered on the selection (the view may be
|
||||
// rotated).
|
||||
let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
|
||||
let anchor = pos2(xf.to_screen(b.center()).x, lowest);
|
||||
let est_w = 118.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
|
||||
// Its place under the selection, then where the handle moved it, kept on the canvas (with the
|
||||
// bar's size last frame, the estimate before it first shows).
|
||||
let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0);
|
||||
let id = task_bar_id();
|
||||
let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(vec2(est_w, 44.0));
|
||||
let bounds = xf.rect.shrink(8.0);
|
||||
let keep_in = |p: Pos2| p.clamp(bounds.min, (bounds.max - size).max(bounds.min));
|
||||
let place = &mut app.ui.task_bar_place;
|
||||
// Just unpinned: follow the selection from the pinned spot.
|
||||
if !place.pinned
|
||||
&& let Some(at) = place.pin_at.take()
|
||||
{
|
||||
place.offset = Some((doc, xf.rect.min + at - under));
|
||||
}
|
||||
let pos = keep_in(match place.pin_at {
|
||||
Some(at) => xf.rect.min + at,
|
||||
None => under + place.offset.filter(|(d, _)| *d == doc).map_or(Vec2::ZERO, |(_, o)| o),
|
||||
});
|
||||
place.shown_at = Some(pos - xf.rect.min);
|
||||
// Pinned before it ever showed: hold it here.
|
||||
if place.pinned && place.pin_at.is_none() {
|
||||
place.pin_at = place.shown_at;
|
||||
}
|
||||
let pinned = place.pinned;
|
||||
let mut moved = Vec2::ZERO;
|
||||
let mut run: Option<(&str, serde_json::Value)> = None;
|
||||
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
.stroke(Stroke::new(1.0, t.tool_active))
|
||||
.corner_radius(CornerRadius::same(5))
|
||||
.inner_margin(egui::Margin::symmetric(8, 6))
|
||||
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(70) })
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
let (g, grip) = ui.allocate_exact_size(vec2(4.0, 28.0), Sense::drag());
|
||||
let active = grip.hovered() || grip.dragged();
|
||||
ui.painter().rect_filled(g.shrink2(vec2(0.5, 4.0)), CornerRadius::same(2), if active { t.text_dim } else { t.button_border });
|
||||
if grip.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
moved = grip.drag_delta();
|
||||
} else if grip.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
for (label, icon, cmd, params) in &items {
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.button_border), StrokeKind::Inside);
|
||||
crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon);
|
||||
ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong);
|
||||
if resp.clicked() {
|
||||
run = Some((*cmd, params.clone()));
|
||||
}
|
||||
}
|
||||
// The selected objects' Fill, Recolor Artwork and Lock, left out with the Artboard tool.
|
||||
if !on_artboards {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click());
|
||||
widgets::paint_chip(ui, egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), &fill);
|
||||
ui.painter().rect_stroke(
|
||||
egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)),
|
||||
0.0,
|
||||
Stroke::new(1.0, t.button_border),
|
||||
StrokeKind::Outside,
|
||||
);
|
||||
if resp.on_hover_text(tl!("Fill")).clicked() {
|
||||
app.session.fill_active = true;
|
||||
app.ui.open_panel = Some("swatches".into());
|
||||
}
|
||||
// Recolor Artwork, when the selection has colours to recolour.
|
||||
if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 30.0).clicked() {
|
||||
run = Some(("ui.recolorDialog", json!({})));
|
||||
}
|
||||
if widgets::icon_button(ui, "lock", tl!("Lock (⌘2)"), false, 30.0).clicked() {
|
||||
run = Some(("object.lock", json!({})));
|
||||
}
|
||||
}
|
||||
let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, 30.0);
|
||||
egui::Popup::menu(&more).show(|ui| {
|
||||
ui.set_min_width(170.0);
|
||||
let bar = [
|
||||
(tl!("Hide Bar"), "window.taskBar", false),
|
||||
(tl!("Pin Bar Position"), "window.taskBar.pin", pinned),
|
||||
(tl!("Reset Bar Position"), "window.taskBar.reset", false),
|
||||
];
|
||||
for (label, cmd, checked) in bar {
|
||||
if widgets::menu_item(ui, label, true, checked) {
|
||||
run = Some((cmd, json!({})));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if widgets::menu_item(ui, tl!("Show Properties Panel"), true, false) {
|
||||
run = Some(("window.panel", json!({"panel": "properties"})));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
if moved != Vec2::ZERO {
|
||||
let to = keep_in(pos + moved);
|
||||
let place = &mut app.ui.task_bar_place;
|
||||
if place.pinned {
|
||||
place.pin_at = Some(to - xf.rect.min);
|
||||
} else {
|
||||
place.offset = Some((doc, to - under));
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
match run {
|
||||
// Image Trace traces the image at once with the Default preset. As a quick trace from
|
||||
// Properties or the Control bar, it leaves the Image Trace panel as it was.
|
||||
Some((TASK_BAR_IMAGE_TRACE, _)) => crate::menus::invoke(app, "imageTrace.make", json!({"preset": "Default"})),
|
||||
// The Artboard tool's buttons: Artboard Options for the active artboard, as Properties'
|
||||
// icon opens it; Duplicate for every selected artboard; Rearrange All Artboards.
|
||||
Some((TASK_BAR_ARTBOARD_OPTIONS, _)) => crate::menus::invoke(app, "tool.options", json!({"tool": "artboard"})),
|
||||
Some((TASK_BAR_DUPLICATE_ARTBOARDS, _)) => crate::panels::artboards::duplicate_picked(app),
|
||||
Some((TASK_BAR_REARRANGE_ARTBOARDS, _)) => crate::panels::artboards::open_rearrange(app),
|
||||
// Corner and Smooth convert the direct-selected anchors, as the Control bar's buttons do.
|
||||
Some((TASK_BAR_TO_CORNER, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "corner"})),
|
||||
Some((TASK_BAR_TO_SMOOTH, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "smooth"})),
|
||||
Some((c, p)) => crate::menus::invoke(app, c, p),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Contextual Task Bar's Image Trace button: a trace with the Default preset.
|
||||
const TASK_BAR_IMAGE_TRACE: &str = "taskBar.imageTrace";
|
||||
|
||||
/// The Contextual Task Bar's Resize button with the Artboard tool: Artboard Options.
|
||||
const TASK_BAR_ARTBOARD_OPTIONS: &str = "taskBar.artboardOptions";
|
||||
|
||||
/// The Contextual Task Bar's Duplicate button with the Artboard tool.
|
||||
const TASK_BAR_DUPLICATE_ARTBOARDS: &str = "taskBar.duplicateArtboards";
|
||||
|
||||
/// The Contextual Task Bar's Rearrange All button with the Artboard tool.
|
||||
const TASK_BAR_REARRANGE_ARTBOARDS: &str = "taskBar.rearrangeArtboards";
|
||||
|
||||
/// The Contextual Task Bar's Corner button for direct-selected anchors.
|
||||
const TASK_BAR_TO_CORNER: &str = "taskBar.toCorner";
|
||||
|
||||
/// The Contextual Task Bar's Smooth button for direct-selected anchors.
|
||||
const TASK_BAR_TO_SMOOTH: &str = "taskBar.toSmooth";
|
||||
@@ -0,0 +1,338 @@
|
||||
use super::grid::{clip_segment, pref_color};
|
||||
use super::outline::{OVERLAY_MAX_ANCHORS, c32, stroke_path};
|
||||
use super::*;
|
||||
|
||||
/// Type → Show Hidden Characters: spaces as dots, ¶ at paragraph ends, # at the end of a story.
|
||||
/// The marks of each text object are kept from one frame to the next ([`HiddenChars`]).
|
||||
pub(super) fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let clip = p.clip_rect();
|
||||
let color = Color32::from_rgb(0x4f, 0x9d, 0xff);
|
||||
let font = egui::FontId::proportional(((10.0 * xf.zoom) as f32).clamp(7.0, 18.0));
|
||||
let kept = &mut app.canvas.hidden_chars;
|
||||
// `last` holds the marks of the last frame. The marks of the objects this frame doesn't draw
|
||||
// are dropped at the end.
|
||||
let mut last = std::mem::take(&mut kept.marks);
|
||||
// The inputs are read before any layout: when fonts load during this frame, the next frame
|
||||
// lays all type out again.
|
||||
let inputs = (vectorcraft_text::FontDb::global().generation(), vectorcraft_text::hyphenation_exceptions());
|
||||
if kept.inputs != inputs {
|
||||
last.clear();
|
||||
kept.inputs = inputs;
|
||||
}
|
||||
// `walk` visits each node's `Arc` in the order `Document::walk` visits the nodes (depth first,
|
||||
// in paint order).
|
||||
fn walk(nodes: &[std::sync::Arc<Node>], f: &mut impl FnMut(&std::sync::Arc<Node>)) {
|
||||
for n in nodes {
|
||||
f(n);
|
||||
if let Some(children) = n.children() {
|
||||
walk(children, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(&st.doc.layers, &mut |n| {
|
||||
let NodeKind::Text(tx) = &n.kind else { return };
|
||||
// The box round the object's corners on screen, turned with the view.
|
||||
if !n.visible || n.geometric_bounds().is_none_or(|b| !egui::Rect::from_points(&xf.quad(b)).intersects(clip)) {
|
||||
return;
|
||||
}
|
||||
let marks = match last.remove(&n.id) {
|
||||
Some((was, marks)) if std::sync::Arc::ptr_eq(&was, n) => marks,
|
||||
_ => std::sync::Arc::new(HiddenMarks::of(tx)),
|
||||
};
|
||||
for &at in &marks.spaces {
|
||||
p.circle_filled(xf.to_screen(at), 1.2, color);
|
||||
}
|
||||
for &(at, glyph) in &marks.ends {
|
||||
p.text(xf.to_screen(at), egui::Align2::LEFT_BOTTOM, glyph, font.clone(), color);
|
||||
}
|
||||
kept.marks.insert(n.id, (n.clone(), marks));
|
||||
});
|
||||
}
|
||||
|
||||
/// The Show Hidden Characters marks of the text objects drawn in the last frame
|
||||
/// ([`crate::CanvasCache::hidden_chars`]). Each is kept with the object it was laid out from: an
|
||||
/// edited object is a new `Arc` and is laid out again, and all type is laid out again when the
|
||||
/// fonts or the hyphenation exceptions change.
|
||||
#[derive(Default)]
|
||||
pub struct HiddenChars {
|
||||
/// The fonts' generation ([`vectorcraft_text::FontDb::generation`]) and the hyphenation
|
||||
/// exceptions the marks were laid out with.
|
||||
pub(super) inputs: (u64, String),
|
||||
/// Each text object drawn, by id: the node it was laid out from and its marks.
|
||||
pub(super) marks: std::collections::HashMap<vectorcraft_doc::NodeId, (std::sync::Arc<Node>, std::sync::Arc<HiddenMarks>)>,
|
||||
}
|
||||
|
||||
/// One text object's marks in document space: a dot over each space, and ¶ or # after the lines
|
||||
/// that end a paragraph or the story. Type on a path has none.
|
||||
#[derive(Default)]
|
||||
pub(super) struct HiddenMarks {
|
||||
pub(super) spaces: Vec<Point>,
|
||||
ends: Vec<(Point, &'static str)>,
|
||||
}
|
||||
|
||||
impl HiddenMarks {
|
||||
/// Lay out `tx` and place its marks.
|
||||
pub(super) fn of(tx: &vectorcraft_doc::TextObject) -> Self {
|
||||
let mut marks = Self::default();
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx);
|
||||
if lay.on_path {
|
||||
return marks;
|
||||
}
|
||||
let text = tx.plain_text();
|
||||
let to = |q: Point| tx.xf * q;
|
||||
// A list marker's glyphs (length 0) stand for no character.
|
||||
for g in lay.glyphs.iter().filter(|g| g.len > 0) {
|
||||
if text.get(g.byte..).is_some_and(|s| s.starts_with(' ')) {
|
||||
marks.spaces.push(to(Point::new(g.origin.x + g.advance / 2.0, g.origin.y - 3.0)));
|
||||
}
|
||||
}
|
||||
for (i, line) in lay.lines.iter().enumerate() {
|
||||
let last = i + 1 == lay.lines.len();
|
||||
let breaks = text.get(line.end..).is_some_and(|s| s.starts_with('\n'));
|
||||
let (glyph, at) = if last && line.end >= text.len() {
|
||||
("#", true)
|
||||
} else if breaks {
|
||||
("¶", true)
|
||||
} else {
|
||||
("", false)
|
||||
};
|
||||
if at {
|
||||
marks.ends.push((to(Point::new(line.x1 + 1.0, line.baseline)), glyph));
|
||||
}
|
||||
}
|
||||
marks
|
||||
}
|
||||
}
|
||||
|
||||
/// View › Show Text Threads: for each thread with a selected frame, a line in its first frame's
|
||||
/// layer colour from each frame's out port to the next frame's in port, both showing the arrow of a
|
||||
/// threaded port, where the tools draw the ports of selected type ([`vectorcraft_tools::text`]).
|
||||
/// Only frames with ports ([`vectorcraft_tools::text::ported`]) are joined: a link to or from a
|
||||
/// frame in perspective, which has none, gets no line. With more than
|
||||
/// [`vectorcraft_tools::text::PORTS_MAX`] such frames selected the tools draw no ports, and only
|
||||
/// the lines are drawn here. `zoom`: screen pixels to the document unit.
|
||||
pub(super) fn thread_overlays(app: &VectorcraftApp, zoom: f64) -> Vec<Overlay> {
|
||||
use vectorcraft_tools::text::{Port, PortState, port_centre, ported, ports_shown, push_port};
|
||||
let Some(st) = app.session.active() else { return vec![] };
|
||||
let px = 1.0 / zoom.max(1e-9);
|
||||
let boxes = ports_shown(&st.doc, &st.selection.objects);
|
||||
let mut o = vec![];
|
||||
for thread in &st.doc.text_threads {
|
||||
if !thread.iter().any(|id| st.selection.objects.contains(id)) {
|
||||
continue;
|
||||
}
|
||||
let color = st.doc.layer_color(thread[0]);
|
||||
for w in thread.windows(2) {
|
||||
let [a, b] = [w[0], w[1]];
|
||||
let (Some(from), Some(to)) = (ported(&st.doc, a), ported(&st.doc, b)) else { continue };
|
||||
let (Some(out), Some(input)) = (port_centre(from, Port::Out, px), port_centre(to, Port::In, px)) else { continue };
|
||||
o.push(Overlay::Line { a: out, b: input, color, dashed: false });
|
||||
if boxes {
|
||||
push_port(&mut o, out, PortState::Threaded, st.doc.layer_color(a), px);
|
||||
push_port(&mut o, input, PortState::Threaded, st.doc.layer_color(b), px);
|
||||
}
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// View → Show Live Paint Gaps: the gaps of the selected Live Paint groups in each group's Gap
|
||||
/// Preview Color ([`crate::dialogs::live_paint_gaps::selected_gaps`]). Cached per revision.
|
||||
pub(super) fn live_paint_gap_overlays(app: &mut VectorcraftApp) -> Vec<Overlay> {
|
||||
let Some(st) = app.session.active() else { return vec![] };
|
||||
let key = (st.uid, st.revision);
|
||||
let gaps = match &app.canvas.live_paint_gaps {
|
||||
Some((k, gaps)) if *k == key => gaps.clone(),
|
||||
_ => {
|
||||
let gaps = std::sync::Arc::new(crate::dialogs::live_paint_gaps::selected_gaps(app));
|
||||
app.canvas.live_paint_gaps = Some((key, gaps.clone()));
|
||||
gaps
|
||||
}
|
||||
};
|
||||
gaps.to_vec()
|
||||
}
|
||||
|
||||
/// Simplify's Show Original Path: the paths its preview ran on, as they were before it, in a band
|
||||
/// 3 screen points wide of their layer's color at 40%, under the selection's outlines. Nothing is drawn
|
||||
/// when they have more than [`OVERLAY_MAX_ANCHORS`] anchor points.
|
||||
pub(super) fn original_path_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let paths = crate::dialogs::path_ops::original_paths(app);
|
||||
let Some(st) = app.session.active().filter(|_| paths.iter().map(|(_, path)| path.anchor_count()).sum::<usize>() <= OVERLAY_MAX_ANCHORS) else {
|
||||
return;
|
||||
};
|
||||
for (id, path) in paths {
|
||||
stroke_path(p, &path.to_bezpath(), xf, Stroke::new(3.0, c32(st.doc.layer_color(id)).gamma_multiply(0.4)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The slices (unless View → Hide Slices): user and object slices outlined in the Slices
|
||||
/// preferences' line colour, auto slices dashed and dimmer, each numbered at its top left (Show
|
||||
/// Slice Numbers); selected slices have a bolder outline. The layout is cached per revision.
|
||||
pub(super) fn slice_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let key = (st.uid, st.revision);
|
||||
let slices = match &app.canvas.slices {
|
||||
Some((k, s)) if *k == key => s.clone(),
|
||||
_ => {
|
||||
let s = std::sync::Arc::new(st.doc.slice_layout());
|
||||
app.canvas.slices = Some((key, s.clone()));
|
||||
s
|
||||
}
|
||||
};
|
||||
if slices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let prefs = &app.session.prefs;
|
||||
let line = pref_color(&prefs.slice_line_color, Color32::from_rgb(0xff, 0x3f, 0x3f));
|
||||
let dim = line.gamma_multiply(0.55);
|
||||
let selected = &st.selection.slices;
|
||||
let objects = &st.selection.objects;
|
||||
for a in slices.iter() {
|
||||
let auto = a.source == vectorcraft_doc::SliceSource::Auto;
|
||||
let on = a.id.is_some_and(|id| selected.contains(&id) || objects.contains(&id));
|
||||
let q = xf.quad(a.rect);
|
||||
let ring: Vec<Pos2> = q.iter().chain(q.first()).copied().collect();
|
||||
if auto {
|
||||
p.extend(Shape::dashed_line(&ring, Stroke::new(1.0, dim), 3.0, 3.0));
|
||||
} else {
|
||||
p.add(Shape::line(ring, Stroke::new(if on { 2.0 } else { 1.0 }, line)));
|
||||
}
|
||||
if prefs.show_slice_numbers
|
||||
&& let Some(tl) = q.first()
|
||||
{
|
||||
let font = egui::FontId::proportional(9.0);
|
||||
let galley = p.layout_no_wrap(format!("{:02}", a.number), font, Color32::WHITE);
|
||||
let badge = egui::Rect::from_min_size(*tl + vec2(1.0, 1.0), galley.size() + vec2(6.0, 2.0));
|
||||
p.rect_filled(badge, CornerRadius::ZERO, if auto { dim } else { line });
|
||||
p.galley(badge.min + vec2(3.0, 1.0), galley, Color32::WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The print tiling (View → Show Print Tiling, or the Print Tiling tool): each page of the
|
||||
/// document's print settings as `print.preview` lays it out, its paper edge and its imageable
|
||||
/// area dashed, numbered at its top left; tiles outside the tile range dimmer. Cached per revision.
|
||||
pub(super) fn print_tiling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let key = (st.uid, st.revision);
|
||||
let pages = match &app.canvas.print_tiling {
|
||||
Some((k, pages)) if *k == key => pages.clone(),
|
||||
_ => {
|
||||
// Settings that can't print have no pages to show.
|
||||
let pages = std::sync::Arc::new(vectorcraft_engine::cmd::printtiling::pages(&st.doc).unwrap_or_default());
|
||||
app.canvas.print_tiling = Some((key, pages.clone()));
|
||||
pages
|
||||
}
|
||||
};
|
||||
let font = egui::FontId::proportional(10.0);
|
||||
let ring = |r: [f64; 4]| {
|
||||
let q = xf.quad(Rect::new(r[0], r[1], r[2], r[3]));
|
||||
q.iter().chain(q.first()).copied().collect::<Vec<Pos2>>()
|
||||
};
|
||||
for page in pages.iter() {
|
||||
let color = if page.printed { PRINT_TILING } else { PRINT_TILING.gamma_multiply(0.45) };
|
||||
p.add(Shape::line(ring(page.page), Stroke::new(0.75, color)));
|
||||
let inner = ring(page.imageable);
|
||||
p.extend(Shape::dashed_line(&inner, Stroke::new(1.0, color), 4.0, 3.0));
|
||||
if let Some(corner) = inner.iter().copied().reduce(|a, b| pos2(a.x.min(b.x), a.y.min(b.y))) {
|
||||
p.text(corner + vec2(3.0, 2.0), egui::Align2::LEFT_TOP, page.number.to_string(), font.clone(), color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The print tiling's lines: a neutral grey that reads on the paper and on the pasteboard.
|
||||
const PRINT_TILING: Color32 = Color32::from_gray(96);
|
||||
|
||||
/// A Shaper Group's mode widget (Shaper and Selection tools), drawn with selection edges shown or
|
||||
/// hidden: an arrow pointing right in Face Selection mode and left in Construction mode.
|
||||
pub(super) fn shaper_widget_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
if matches!(app.session.tool_id(), "shaper" | "selection")
|
||||
&& let Some(w) = vectorcraft_tools::shaperwidget::ShaperWidget::of(&st.doc, &st.selection, st.isolation, xf.zoom)
|
||||
{
|
||||
let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::shaperwidget::RADIUS_PX);
|
||||
let color = c32(st.doc.layer_color(w.group));
|
||||
p.circle_filled(c, r, Color32::WHITE);
|
||||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||||
let d = if w.construction { -1.0 } else { 1.0 };
|
||||
let arrow = vec![c + vec2(3.0 * d, 0.0), c + vec2(-2.0 * d, -3.0), c + vec2(-2.0 * d, 3.0)];
|
||||
p.add(Shape::convex_polygon(arrow, color, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
|
||||
/// High-contrast live controls, using the same geometry as the Selection tool's hit testing.
|
||||
pub(super) fn mirror_axis_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
if app.session.tool_id() != "selection" || !app.ui.view.bounding_box {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let Some(axis) = vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, xf.zoom) else { return };
|
||||
let t = Tokens::get(p.ctx());
|
||||
let ends = axis.ends.map(|q| xf.to_screen(q));
|
||||
let center = xf.to_screen(axis.center);
|
||||
if !center.is_finite() || !ends.iter().all(|p| p.is_finite()) {
|
||||
return;
|
||||
}
|
||||
if let Some((a, b)) = clip_segment(ends[0], ends[1], xf.rect) {
|
||||
p.line_segment([a, b], Stroke::new(5.0, t.measure_bg));
|
||||
p.line_segment([a, b], Stroke::new(2.0, t.selection));
|
||||
}
|
||||
for end in ends {
|
||||
p.circle_filled(end, vectorcraft_tools::mirror::HANDLE_RADIUS, t.measure_bg);
|
||||
p.circle_stroke(end, vectorcraft_tools::mirror::HANDLE_RADIUS, Stroke::new(2.0, t.selection));
|
||||
}
|
||||
let handle = egui::Rect::from_center_size(center, vec2(12.0, 12.0));
|
||||
p.rect_filled(handle, 2.0, t.selection);
|
||||
p.rect_stroke(handle, 2.0, Stroke::new(2.0, t.measure_bg), StrokeKind::Outside);
|
||||
}
|
||||
|
||||
/// A selected grid repeat's row, column and corner handles and its spacing sliders (#2691), at
|
||||
/// the places the Selection tool's hit testing finds them.
|
||||
pub(super) fn grid_repeat_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
use vectorcraft_tools::repeat_grid::{GridHandles, GridPart, HANDLE_RADIUS, SLIDER_HALF};
|
||||
if app.session.tool_id() != "selection" || !app.ui.view.bounding_box {
|
||||
return;
|
||||
}
|
||||
let frame = app.selection_box();
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let Some(grid) = GridHandles::of(&st.doc, &st.selection, frame, xf.zoom) else { return };
|
||||
let t = Tokens::get(p.ctx());
|
||||
let half = f64::from(SLIDER_HALF) / xf.zoom;
|
||||
for (part, at) in grid.parts() {
|
||||
let c = xf.to_screen(at);
|
||||
if !c.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if part.is_slider() {
|
||||
// A bar across the gap's middle, square to the spacing it sets.
|
||||
let along = if part == GridPart::HSpacing { vectorcraft_geom::Vec2::new(0.0, half) } else { vectorcraft_geom::Vec2::new(half, 0.0) };
|
||||
let ends = [xf.to_screen(at - along), xf.to_screen(at + along)];
|
||||
p.line_segment(ends, Stroke::new(6.0, t.measure_bg));
|
||||
p.line_segment(ends, Stroke::new(2.5, t.selection));
|
||||
} else {
|
||||
p.circle_filled(c, HANDLE_RADIUS, t.measure_bg);
|
||||
p.circle_stroke(c, HANDLE_RADIUS, Stroke::new(2.0, t.selection));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The radius of the Rotate View dial, in screen points.
|
||||
pub(super) const DIAL_RADIUS: f32 = 40.0;
|
||||
|
||||
/// The dial a Rotate View drag shows, centered on `c`: a tick every 15° (longer every 90°), a
|
||||
/// needle pointing where the document's top points in a view turned by `rotation` degrees, and the
|
||||
/// angle under the center, rounded to whole degrees as in the status bar.
|
||||
pub(super) fn rotate_view_dial(p: &egui::Painter, c: Pos2, rotation: f64, t: &Tokens) {
|
||||
// `dir(deg)` is the screen direction `deg` degrees clockwise from straight up.
|
||||
let dir = |deg: f32| vec2(deg.to_radians().sin(), -deg.to_radians().cos());
|
||||
p.circle(c, DIAL_RADIUS, t.panel.gamma_multiply(0.85), Stroke::new(1.0, t.border));
|
||||
for i in 0..24u8 {
|
||||
let len = if i % 6 == 0 { 8.0 } else { 4.0 };
|
||||
let d = dir(f32::from(i) * 15.0);
|
||||
p.line_segment([c + d * (DIAL_RADIUS - len), c + d * DIAL_RADIUS], Stroke::new(1.0, t.text_dim));
|
||||
}
|
||||
p.line_segment([c, c + dir(rotation as f32) * DIAL_RADIUS], Stroke::new(2.0, t.accent));
|
||||
p.circle_filled(c, 2.5, t.accent);
|
||||
p.text(c + vec2(0.0, 8.0), egui::Align2::CENTER_TOP, format!("{rotation:.0}°"), egui::FontId::proportional(11.0), t.text);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
use super::drag::{drag_id, drop_press};
|
||||
use super::*;
|
||||
|
||||
/// Fit the view (View → Fit Artboard / Fit All / Actual Size).
|
||||
pub fn fit(app: &mut VectorcraftApp, how: &str) {
|
||||
let Some(rect) = app.canvas_rect else {
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.fitted = false;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let current = app.view().map_or(0, |v| v.artboard);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let target = match how {
|
||||
"view.fitAll" => {
|
||||
st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect))
|
||||
}
|
||||
// The navigator's artboard (the first if it has gone).
|
||||
_ => st.doc.artboards.get(current).or(st.doc.artboards.first()).map(|a| a.rect),
|
||||
};
|
||||
let Some(target) = target else { return };
|
||||
let zoom = if how == "view.actualSize" {
|
||||
actual_size_zoom(app.session.prefs.display_print_size)
|
||||
} else {
|
||||
// The width and height of the box round the target turned with the view, at zoom 1.
|
||||
let (sn, cs) = app.view().map_or(0.0, |v| v.rotation.to_radians()).sin_cos();
|
||||
let (w, h) = (target.width(), target.height());
|
||||
let zx = (rect.width() as f64 - 60.0) / (w * cs.abs() + h * sn.abs()).max(1.0);
|
||||
let zy = (rect.height() as f64 - 60.0) / (w * sn.abs() + h * cs.abs()).max(1.0);
|
||||
zx.min(zy).clamp(0.0313, 640.0)
|
||||
};
|
||||
let Some(v) = app.view_mut() else { return };
|
||||
v.center = target.center();
|
||||
v.fitted = true;
|
||||
v.zoom = zoom;
|
||||
}
|
||||
|
||||
/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`).
|
||||
const WHEEL_ZOOM: f64 = 0.01;
|
||||
|
||||
/// How fast a wheel notch's zoom glides in with Zoom with Mouse Wheel: the time constant (s) of
|
||||
/// the part still to come.
|
||||
const WHEEL_GLIDE: f64 = 0.05;
|
||||
|
||||
/// What the wheel did across frames ([`wheel`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct WheelTurn {
|
||||
/// The last wheel turn was an Alt-wheel one.
|
||||
alt: bool,
|
||||
/// The zoom (natural log) wheel notches still have to glide in.
|
||||
pub(super) glide: f64,
|
||||
}
|
||||
|
||||
/// Animated Zoom: how much one point of sideways drag zooms (100 points to the right double it).
|
||||
pub(super) const SCRUB_ZOOM: f64 = std::f64::consts::LN_2 / 100.0;
|
||||
|
||||
/// Animated Zoom: how far the pointer moves before a press is a drag, in points.
|
||||
pub(super) const SCRUB_SLOP: f32 = 3.0;
|
||||
|
||||
/// Animated Zoom: how long a press is held still before it zooms on, in seconds…
|
||||
pub(super) const HOLD_DELAY: f32 = 0.3;
|
||||
|
||||
/// …and how fast it zooms then (as `exp(seconds × HOLD_ZOOM)`: twice as close each second).
|
||||
pub(super) const HOLD_ZOOM: f64 = std::f64::consts::LN_2;
|
||||
|
||||
/// Performance › Animated Zoom, which needs GPU Performance as in Illustrator: the Zoom tool zooms
|
||||
/// as it is dragged sideways or held, instead of zooming to the area dragged across.
|
||||
pub(crate) fn animated_zoom(p: &vectorcraft_engine::Prefs) -> bool {
|
||||
p.animated_zoom && p.gpu_performance
|
||||
}
|
||||
|
||||
/// Zoom for View → Actual Size (`view.actualSize`). With Preferences › General › Display Print
|
||||
/// Size at 100% Zoom off, one document point is one screen point. On, one document inch (72 pt)
|
||||
/// fills an inch of the screen as the system counts it: 96 screen points, the reference density
|
||||
/// that display scaling is set against (a CSS inch), whatever the scale factor.
|
||||
pub(crate) fn actual_size_zoom(display_print_size: bool) -> f64 {
|
||||
if display_print_size { 96.0 / 72.0 } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Zoom view `vm` of the canvas `rect` to `zoom` (clamped to the zoom range), keeping the document
|
||||
/// point under screen point `p` where it is.
|
||||
pub(super) fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) {
|
||||
let before = Xf::new(rect, vm).to_doc(p);
|
||||
vm.zoom = zoom.clamp(0.0313, 640.0);
|
||||
vm.center += before - Xf::new(rect, vm).to_doc(p);
|
||||
}
|
||||
|
||||
/// Turn view `vm` of the canvas `rect` by `degrees` clockwise, keeping the document point under
|
||||
/// screen point `p` where it is.
|
||||
pub(super) fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64) {
|
||||
let before = Xf::new(rect, vm).to_doc(p);
|
||||
vm.rotation = vectorcraft_geom::normalize_deg(vm.rotation + degrees);
|
||||
vm.center += before - Xf::new(rect, vm).to_doc(p);
|
||||
}
|
||||
|
||||
/// Move view `vm`'s content by `(dx, dy)` device pixels along the screen's axes, at `ppp` device
|
||||
/// pixels a point: the canvas's frame moves by exactly those pixels.
|
||||
pub(super) fn pan_pixels(vm: &mut View, ppp: f32, (dx, dy): (f64, f64)) {
|
||||
let (sn, cs) = vm.rotation.to_radians().sin_cos();
|
||||
let k = vm.zoom * f64::from(ppp);
|
||||
vm.center -= vectorcraft_geom::Vec2::new((dx * cs + dy * sn) / k, (-dx * sn + dy * cs) / k);
|
||||
}
|
||||
|
||||
/// Screen points `d` in the nearest whole device pixels, at `ppp` device pixels a point.
|
||||
pub(super) fn whole_pixels(d: egui::Vec2, ppp: f32) -> (f64, f64) {
|
||||
((f64::from(d.x) * f64::from(ppp)).round(), (f64::from(d.y) * f64::from(ppp)).round())
|
||||
}
|
||||
|
||||
/// What the scrolls of the view have left to move ([`scroll_view`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct ScrollRest {
|
||||
/// Device pixels along the screen's axes, at most half a pixel each.
|
||||
pub(super) x: f64,
|
||||
pub(super) y: f64,
|
||||
/// The document, the view's center, zoom and rotation, and the device pixels a point that the
|
||||
/// last scroll left.
|
||||
after: Option<(u64, Point, f64, f64, f32)>,
|
||||
}
|
||||
|
||||
/// Scroll the active view so that its content moves `by` screen points (the wheel, a trackpad, a
|
||||
/// touch screen, a scroll bar), in whole device pixels: the part of a pixel left over is added to
|
||||
/// the next scroll. It is dropped when the view changed in between (a zoom, a turn, Fit, a pan
|
||||
/// of another kind, another document) or the device pixels a point did.
|
||||
pub(super) fn scroll_view(app: &mut VectorcraftApp, ui: &Ui, by: egui::Vec2) {
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let Some(doc) = app.session.active().map(|st| st.uid) else { return };
|
||||
let Some(vm) = app.view_mut() else { return };
|
||||
let id = egui::Id::unique("canvas-scroll-rest");
|
||||
let rest = scroll_by(vm, doc, ppp, by, ui.data(|d| d.get_temp(id)).unwrap_or_default());
|
||||
ui.data_mut(|d| d.insert_temp(id, rest));
|
||||
}
|
||||
|
||||
/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point, after the
|
||||
/// scrolls that left `rest`: what this scroll leaves.
|
||||
pub(super) fn scroll_by(vm: &mut View, doc: u64, ppp: f32, by: egui::Vec2, rest: ScrollRest) -> ScrollRest {
|
||||
let (x, y) = if rest.after == Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) { (rest.x, rest.y) } else { (0.0, 0.0) };
|
||||
let (x, y) = (x + f64::from(by.x) * f64::from(ppp), y + f64::from(by.y) * f64::from(ppp));
|
||||
let (dx, dy) = (x.round(), y.round());
|
||||
pan_pixels(vm, ppp, (dx, dy));
|
||||
ScrollRest { x: x - dx, y: y - dy, after: Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) }
|
||||
}
|
||||
|
||||
/// How far a trackpad rotation turns before it turns the view, in degrees.
|
||||
const ROTATE_DEAD_ZONE: f64 = 5.0;
|
||||
|
||||
/// How long after its last rotation event a trackpad rotation ends, in seconds.
|
||||
const ROTATE_GESTURE_END: f64 = 0.25;
|
||||
|
||||
/// A trackpad rotation under way ([`trackpad_turn`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct TrackpadTurn {
|
||||
/// Degrees it has turned so far, clockwise.
|
||||
total: f64,
|
||||
/// When its last rotation event came (egui's input time, in seconds).
|
||||
last: f64,
|
||||
/// It has reached [`ROTATE_DEAD_ZONE`] and turns the view.
|
||||
turning: bool,
|
||||
}
|
||||
|
||||
/// General › Trackpad Gesture to Rotate View (`on`): the degrees, clockwise, that a two-finger
|
||||
/// rotation on a Mac trackpad turns the view this frame. egui sends the rotation as
|
||||
/// [`egui::Event::Rotate`], from winit on macOS and from Safari's gesture events in the web build.
|
||||
/// A rotation does not turn the view until it reaches [`ROTATE_DEAD_ZONE`]; it then turns the view
|
||||
/// by all of it, and by each later event as it comes. It ends [`ROTATE_GESTURE_END`] after its last
|
||||
/// event. Rotation events that come while fingers are on a touch screen do not turn the view.
|
||||
pub(super) fn trackpad_turn(ui: &Ui, on: bool) -> Option<f64> {
|
||||
if !on {
|
||||
return None;
|
||||
}
|
||||
let (deg, now) = ui.input(|i| {
|
||||
let rad: f32 =
|
||||
if i.multi_touch().is_some() { 0.0 } else { i.events.iter().map(|e| if let egui::Event::Rotate(r) = e { *r } else { 0.0 }).sum() };
|
||||
(f64::from(rad).to_degrees(), i.time)
|
||||
});
|
||||
if deg == 0.0 || !deg.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let id = egui::Id::unique("canvas-trackpad-turn");
|
||||
let kept: Option<TrackpadTurn> = ui.data(|d| d.get_temp(id));
|
||||
let mut t = kept.filter(|t| now - t.last <= ROTATE_GESTURE_END).unwrap_or(TrackpadTurn { total: 0.0, last: now, turning: false });
|
||||
t.total += deg;
|
||||
t.last = now;
|
||||
let turn = if t.turning {
|
||||
Some(deg)
|
||||
} else if t.total.abs() >= ROTATE_DEAD_ZONE {
|
||||
t.turning = true;
|
||||
Some(t.total)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(id, t));
|
||||
turn
|
||||
}
|
||||
|
||||
/// One scroll bar along its axis, in screen points: the span it scrolls over (`lo..hi`) and the
|
||||
/// part of it the canvas shows (`v0..v1`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Span {
|
||||
pub(super) lo: f32,
|
||||
pub(super) hi: f32,
|
||||
pub(super) v0: f32,
|
||||
pub(super) v1: f32,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
/// The thumb on a track `len` long: where it starts and how long it is.
|
||||
pub(super) fn thumb(self, len: f32) -> (f32, f32) {
|
||||
let total = (self.hi - self.lo).max(1.0);
|
||||
let size = (len * (self.v1 - self.v0) / total).clamp(MIN_THUMB.min(len), len);
|
||||
let room = total - (self.v1 - self.v0);
|
||||
let at = if room > 0.0 { (self.v0 - self.lo) / room * (len - size) } else { 0.0 };
|
||||
(at.clamp(0.0, (len - size).max(0.0)), size)
|
||||
}
|
||||
/// Screen points the view moves for each point the thumb moves on a track `len` long.
|
||||
pub(super) fn ratio(self, len: f32) -> f32 {
|
||||
let room = (self.hi - self.lo) - (self.v1 - self.v0);
|
||||
let free = len - self.thumb(len).1;
|
||||
if room > 0.0 && free > 0.0 { room / free } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_THUMB: f32 = 20.0;
|
||||
|
||||
/// The scroll bars' spans, horizontal then vertical: the art and the artboards (`content`) as the
|
||||
/// view turns them on screen, half a window past each side, and the view itself, so the thumb is
|
||||
/// always on its track. The canvas has no edge of its own (no drawing area), so the content sets it.
|
||||
pub(super) fn scroll_spans(xf: &Xf, content: Option<Rect>) -> [Span; 2] {
|
||||
let r = xf.rect;
|
||||
let around = content.map(|c| egui::Rect::from_points(&xf.quad(c)).expand2(r.size() / 2.0)).filter(|b| b.is_finite());
|
||||
let b = around.map_or(r, |b| b.union(r));
|
||||
[Span { lo: b.left(), hi: b.right(), v0: r.left(), v1: r.right() }, Span { lo: b.top(), hi: b.bottom(), v0: r.top(), v1: r.bottom() }]
|
||||
}
|
||||
|
||||
/// The tracks of the horizontal and the vertical scroll bar, along the bottom and the right edge
|
||||
/// of the window (`outer`), as long as the canvas (`rect`) beside them.
|
||||
pub(super) fn scroll_tracks(rect: egui::Rect, outer: egui::Rect) -> [egui::Rect; 2] {
|
||||
let corner = outer.max - vec2(SCROLL_BAR, SCROLL_BAR);
|
||||
[
|
||||
egui::Rect::from_min_max(pos2(rect.left(), corner.y), pos2(corner.x, outer.bottom())),
|
||||
egui::Rect::from_min_max(pos2(corner.x, rect.top()), pos2(outer.right(), corner.y)),
|
||||
]
|
||||
}
|
||||
|
||||
/// The window's scroll bars ([`scroll_spans`]): a thumb dragged scrolls the view along the
|
||||
/// screen's axis (a turned view too), a click on the track pages towards it, and the wheel over a
|
||||
/// bar scrolls along it.
|
||||
pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui::Rect, t: &Tokens) {
|
||||
let content = app.content_bounds();
|
||||
let Some(v) = app.view().copied() else { return };
|
||||
let xf = Xf::new(rect, &v);
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(egui::Rect::from_min_max(outer.max - vec2(SCROLL_BAR, SCROLL_BAR), outer.max), 0.0, t.ruler);
|
||||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||||
// Screen points the view moves this frame, or the center a dragged thumb's drag began at and
|
||||
// the screen points it has moved the view since.
|
||||
let mut moved = egui::Vec2::ZERO;
|
||||
let mut center = None;
|
||||
for (k, (track, span)) in scroll_tracks(rect, outer).into_iter().zip(scroll_spans(&xf, content)).enumerate() {
|
||||
let horizontal = k == 0;
|
||||
let along = |p: Pos2| if horizontal { p.x - track.left() } else { p.y - track.top() };
|
||||
let axis = if horizontal { vec2(1.0, 0.0) } else { vec2(0.0, 1.0) };
|
||||
let len = if horizontal { track.width() } else { track.height() };
|
||||
let (at, size) = span.thumb(len);
|
||||
let on_thumb = |p: Pos2| (at..=at + size).contains(&along(p));
|
||||
let id = egui::Id::unique(("canvas-scroll-bar", k));
|
||||
let resp = ui.interact(track, id, Sense::click_and_drag());
|
||||
if resp.drag_started()
|
||||
&& let Some(p) = ui.input(|i| i.pointer.press_origin())
|
||||
&& on_thumb(p)
|
||||
{
|
||||
// The span moves with the view: the drag keeps the one it started with.
|
||||
let d = ThumbDrag { from: along(p), center: v.center, ratio: span.ratio(len), min: span.lo - span.v0, max: span.hi - span.v1 };
|
||||
ui.data_mut(|dd| dd.insert_temp(id, d));
|
||||
}
|
||||
let drag: Option<ThumbDrag> = ui.data(|dd| dd.get_temp(id));
|
||||
if let Some(d) = drag {
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let by = ((along(p) - d.from) * d.ratio).clamp(d.min, d.max);
|
||||
center = Some((d.center, axis * by));
|
||||
}
|
||||
if !resp.dragged() || resp.drag_stopped() {
|
||||
ui.data_mut(|dd| dd.remove::<ThumbDrag>(id));
|
||||
}
|
||||
} else if resp.clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
// A page is most of the window, and never past the end of the span.
|
||||
let page = 0.9 * (span.v1 - span.v0);
|
||||
if along(p) < at {
|
||||
moved -= axis * page.min(span.v0 - span.lo);
|
||||
} else if along(p) > at + size {
|
||||
moved += axis * page.min(span.hi - span.v1);
|
||||
}
|
||||
}
|
||||
if resp.hovered() {
|
||||
let d = ui.input(|i| i.smooth_scroll_delta);
|
||||
moved -= axis * if horizontal { d.x + d.y } else { d.y };
|
||||
}
|
||||
painter.rect_filled(track, 0.0, t.ruler);
|
||||
let edge = if horizontal { [track.left_top(), track.right_top()] } else { [track.left_top(), track.left_bottom()] };
|
||||
painter.line_segment(edge, Stroke::new(1.0, t.border));
|
||||
let thumb = if horizontal {
|
||||
egui::Rect::from_min_size(pos2(track.left() + at, track.top()), vec2(size, track.height())).shrink2(vec2(1.0, 3.0))
|
||||
} else {
|
||||
egui::Rect::from_min_size(pos2(track.left(), track.top() + at), vec2(track.width(), size)).shrink2(vec2(3.0, 1.0))
|
||||
};
|
||||
let hot = drag.is_some() || hover.is_some_and(|p| thumb.contains(p));
|
||||
painter.rect_filled(thumb, CornerRadius::same(3), if hot { t.text_dim } else { t.text_disabled });
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
if let Some((c, by)) = center
|
||||
&& let Some(vm) = app.view_mut()
|
||||
{
|
||||
vm.center = c;
|
||||
pan_pixels(vm, ppp, whole_pixels(-by, ppp));
|
||||
}
|
||||
if moved != egui::Vec2::ZERO {
|
||||
scroll_view(app, ui, -moved);
|
||||
}
|
||||
}
|
||||
|
||||
/// A scroll bar thumb being dragged: where along the track it was pressed, and the view's centre,
|
||||
/// the span's travel ratio ([`Span::ratio`]) and how far the view could move either way then.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ThumbDrag {
|
||||
from: f32,
|
||||
center: Point,
|
||||
ratio: f32,
|
||||
min: f32,
|
||||
max: f32,
|
||||
}
|
||||
|
||||
/// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame:
|
||||
/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
|
||||
/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel
|
||||
/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
|
||||
/// per wheel line and page. `turn`: kept by the caller across frames. A mouse wheel's notches zoom
|
||||
/// in a glide over a few frames, as scrolling does (#888); a trackpad's fine steps at once.
|
||||
pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) {
|
||||
// Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead).
|
||||
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta);
|
||||
if !wheel_zooms {
|
||||
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few
|
||||
// frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go.
|
||||
if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) {
|
||||
turn.alt = m.alt && !m.command;
|
||||
}
|
||||
let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
|
||||
return if turn.alt && scroll != egui::Vec2::ZERO {
|
||||
(zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), pan)
|
||||
} else {
|
||||
(zoom, scroll + pan)
|
||||
};
|
||||
}
|
||||
// The wheel events themselves: egui's own handling turns Cmd-wheel into a zoom.
|
||||
let mut zoom = i.multi_touch().map_or(1.0, |t| f64::from(t.zoom_delta));
|
||||
let mut scroll = pan;
|
||||
for e in &i.events {
|
||||
match e {
|
||||
egui::Event::MouseWheel { unit, delta, modifiers, .. } => {
|
||||
let d = match unit {
|
||||
egui::MouseWheelUnit::Point => *delta,
|
||||
egui::MouseWheelUnit::Line => *delta * line,
|
||||
egui::MouseWheelUnit::Page => *delta * page,
|
||||
};
|
||||
if modifiers.command {
|
||||
scroll.x += d.x + d.y;
|
||||
} else if modifiers.shift {
|
||||
scroll.y += d.x + d.y;
|
||||
} else if *unit == egui::MouseWheelUnit::Point {
|
||||
zoom *= (f64::from(d.y) * WHEEL_ZOOM).exp();
|
||||
scroll.x += d.x;
|
||||
} else {
|
||||
turn.glide += f64::from(d.y) * WHEEL_ZOOM;
|
||||
scroll.x += d.x;
|
||||
}
|
||||
}
|
||||
egui::Event::Zoom(f) => zoom *= f64::from(*f),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// This frame's part of the glide: what is left shrinks by e every WHEEL_GLIDE seconds.
|
||||
let part = if turn.glide.abs() < 1e-3 { 1.0 } else { 1.0 - (-f64::from(i.stable_dt.min(0.1)) / WHEEL_GLIDE).exp() };
|
||||
let step = turn.glide * part;
|
||||
turn.glide -= step;
|
||||
(zoom * step.exp(), scroll)
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second
|
||||
/// finger drops the press the first one began (its tool interaction rolled back; the rest of the
|
||||
/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes.
|
||||
pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) {
|
||||
if !app.session.prefs.touch_gestures {
|
||||
return;
|
||||
}
|
||||
let (touches, now): (Vec<_>, f64) = ui.input(|i| {
|
||||
let t = i.events.iter().filter_map(|e| match e {
|
||||
egui::Event::Touch { id, phase, pos, .. } => Some((*id, *phase, *pos)),
|
||||
_ => None,
|
||||
});
|
||||
(t.collect(), i.time)
|
||||
});
|
||||
if touches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = egui::Id::unique("canvas-touch");
|
||||
let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default();
|
||||
let mut tap = None;
|
||||
for (id, phase, pos) in touches {
|
||||
// A gesture begins on the canvas.
|
||||
if phase == egui::TouchPhase::Start && !taps.is_down() && !rect.contains(pos) {
|
||||
continue;
|
||||
}
|
||||
// A cancelled contact (a palm the shell drops when a pen lands) keeps nothing it began.
|
||||
if phase == egui::TouchPhase::Cancel {
|
||||
drop_press(app, ui, view);
|
||||
}
|
||||
match taps.feed(id, phase, pos, now) {
|
||||
Some(crate::touch::Touch::Fingers) => {
|
||||
drop_press(app, ui, view);
|
||||
ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture));
|
||||
}
|
||||
Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(key, taps));
|
||||
// In Presentation Mode a tap of two or three fingers undoes or redoes nothing.
|
||||
if let Some(cmd) = tap
|
||||
&& app.ui.screen_mode < 3
|
||||
{
|
||||
crate::menus::invoke(app, cmd, serde_json::json!({}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user