mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
refactor(ui-egui): move panels/layers.rs items to panels/layers/{controls,model,rows,thumbs}.rs
This commit is contained in:
+17
-1354
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
use super::model::{ObjectKind, RowState, names, parse_names, toggled};
|
||||
use super::*;
|
||||
|
||||
/// The Filter button, lit while a filter is on, and its popup: Clear All, the object kinds under
|
||||
/// By Object and the row states under By Layer, each ticked or not. A tick, or Clear All, runs
|
||||
/// `ui.layersFilter`; the popup stays open until a click outside it or Escape.
|
||||
pub(super) fn filter_button(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let f = app.ui.layers_filter.clone();
|
||||
let button = widgets::icon_button(ui, "dc-filter", tl!("Filter"), f.is_on(), 24.0);
|
||||
let mut set = None;
|
||||
egui::Popup::menu(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::subheader(ui, tl!("Filter"));
|
||||
ui.add_space(12.0);
|
||||
if ui.add_enabled(f.is_on(), egui::Button::new(egui::RichText::new(tl!("Clear All")).size(12.5)).frame(false)).clicked() {
|
||||
set = Some(json!({"objects": [], "layers": []}));
|
||||
}
|
||||
});
|
||||
widgets::subheader(ui, tl!("By Object"));
|
||||
for (k, _) in ObjectKind::ALL {
|
||||
if widgets::check(ui, k.label(), f.objects.contains(&k), true) {
|
||||
set = Some(json!({"objects": toggled(&ObjectKind::ALL, &f.objects, k)}));
|
||||
}
|
||||
}
|
||||
widgets::subheader(ui, tl!("By Layer"));
|
||||
for (s, _) in RowState::ALL {
|
||||
if widgets::check(ui, s.label(), f.layers.contains(&s), true) {
|
||||
set = Some(json!({"layers": toggled(&RowState::ALL, &f.layers, s)}));
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(p) = set {
|
||||
app.run("ui.layersFilter", p).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// The bottom bar: the layer count and the panel's buttons.
|
||||
pub(super) fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc: &Document, actions: &mut Vec<(String, Value)>) {
|
||||
let t = &view.t;
|
||||
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());
|
||||
ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider));
|
||||
let n = doc.layers.len();
|
||||
ui.painter().text(
|
||||
bar.left_center() + vec2(4.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
crate::i18n::tn(n as u64, "{n} Layer", "{n} Layers"),
|
||||
egui::FontId::proportional(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let trash = widgets::icon_button(&mut child, "trash-2", tl!("Delete Selection"), false, 24.0);
|
||||
// A target circle dropped on the trash clears that appearance (dragged rows: below). Peek at
|
||||
// the payload's type before taking it: egui's take drops a payload of another type.
|
||||
if trash.dnd_hover_payload::<PanelDrag>().is_some()
|
||||
&& let Some(d) = trash.dnd_release_payload::<PanelDrag>()
|
||||
&& let PanelDrag::Appearance(id) = *d
|
||||
{
|
||||
actions.push(("appearance.clear".into(), json!({"ids": [id.0]})));
|
||||
} else if trash.clicked() {
|
||||
if view.rows.is_empty() && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
|
||||
actions.push(("edit.clear".into(), json!({})));
|
||||
} else {
|
||||
actions.push(("layer.delete".into(), json!({})));
|
||||
}
|
||||
}
|
||||
// Create New Layer: Alt asks for its options first, Ctrl/Cmd puts it on top of every layer.
|
||||
let new_layer = widgets::icon_button(&mut child, "file-plus", tl!("Create New Layer"), false, 24.0);
|
||||
if new_layer.clicked() {
|
||||
if m.alt {
|
||||
actions.push(("ui.newLayer".into(), json!({})));
|
||||
} else {
|
||||
actions.push(("layer.new".into(), json!({"top": m.command})));
|
||||
}
|
||||
}
|
||||
// Rows dropped on the trash are deleted, on New Layer duplicated, as in Illustrator. Both are
|
||||
// outlined while rows (or, on the trash, a target circle) hang over them.
|
||||
for (button, cmd) in [(&trash, "layer.delete"), (&new_layer, "layer.duplicate")] {
|
||||
if let Some(d) = button.dnd_release_payload::<LayersDrag>()
|
||||
&& let LayersDrag::Rows(ids) = &*d
|
||||
{
|
||||
actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})));
|
||||
}
|
||||
let over = button.dnd_hover_payload::<LayersDrag>().is_some_and(|d| matches!(*d, LayersDrag::Rows(_)))
|
||||
|| (cmd == "layer.delete" && button.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_))));
|
||||
if over {
|
||||
child.painter().rect_stroke(button.rect, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
if widgets::icon_button(&mut child, "plus", tl!("Create New Sublayer"), false, 24.0).clicked() {
|
||||
actions.push(if m.alt { ("ui.newLayer".into(), json!({"sublayer": true})) } else { ("layer.newSublayer".into(), json!({})) });
|
||||
}
|
||||
if widgets::icon_button(&mut child, "frame", tl!("Make/Release Clipping Mask"), false, 24.0).clicked() {
|
||||
actions.push(("layer.clippingMask.toggle".into(), json!({})));
|
||||
}
|
||||
if widgets::icon_button(&mut child, "search", tl!("Locate Object"), false, 24.0).clicked() {
|
||||
actions.push(("layer.locate".into(), json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel menu item: label, command, params, enabled, checked.
|
||||
type MenuItem<'a> = (&'a str, &'a str, Value, bool, bool);
|
||||
|
||||
/// The Layers panel's (≡) menu.
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let doc = st.doc.clone();
|
||||
let rows = st.highlighted_rows();
|
||||
let current = st.current_layer();
|
||||
let has_sel = !st.selection.is_empty();
|
||||
let isolated = st.isolation.is_some();
|
||||
let targets: Vec<NodeId> = if rows.is_empty() { current.into_iter().collect() } else { rows.clone() };
|
||||
let one_container = match targets.as_slice() {
|
||||
[id] => doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. })),
|
||||
_ => false,
|
||||
};
|
||||
let layers_in: Vec<&Node> = targets.iter().filter_map(|id| doc.node(*id)).filter(|n| n.is_layer()).collect();
|
||||
let template = layers_in.first().is_some_and(|l| l.is_template());
|
||||
// The top-level layers holding the rows, and the others.
|
||||
let kept: HashSet<NodeId> = targets.iter().filter_map(|id| doc.layer_of(*id)).collect();
|
||||
let others: Vec<&Node> = doc.layers.iter().map(|l| &**l).filter(|l| !kept.contains(&l.id)).collect();
|
||||
let mut all_layers = vec![];
|
||||
doc.walk(|n| {
|
||||
if n.is_layer() {
|
||||
all_layers.push(n);
|
||||
}
|
||||
});
|
||||
let mut items: Vec<Option<MenuItem>> = vec![
|
||||
Some(("New Layer…", "ui.newLayer", json!({}), true, false)),
|
||||
Some(("New Sublayer…", "ui.newLayer", json!({"sublayer": true}), current.is_some(), false)),
|
||||
Some(("Duplicate Selection", "layer.duplicate", json!({}), !targets.is_empty(), false)),
|
||||
Some(("Delete Selection", "layer.delete", json!({}), !targets.is_empty(), false)),
|
||||
Some(("Options for Selection…", "ui.layerOptions", json!({}), !targets.is_empty(), false)),
|
||||
None,
|
||||
Some(("Make/Release Clipping Mask", "layer.clippingMask.toggle", json!({}), true, false)),
|
||||
None,
|
||||
];
|
||||
items.push(if isolated {
|
||||
Some(("Exit Isolation Mode", "object.exitIsolation", json!({}), true, false))
|
||||
} else {
|
||||
let id = targets.first().map(|i| i.0);
|
||||
Some(("Enter Isolation Mode", "object.isolate", json!({ "id": id }), one_container, false))
|
||||
});
|
||||
items.extend([
|
||||
None,
|
||||
Some(("Locate Object", "layer.locate", json!({}), has_sel, false)),
|
||||
// Sentinel: rendered as the Save Selection submenu (the Select menu's saved-selection items).
|
||||
Some(("Save Selection", "", json!({}), true, false)),
|
||||
None,
|
||||
Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)),
|
||||
Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)),
|
||||
Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)),
|
||||
// Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add).
|
||||
Some(("Collect for Export", "", json!({}), !targets.is_empty() || has_sel, false)),
|
||||
Some(("Release to Layers (Sequence)", "layer.releaseToLayers", json!({}), one_container, false)),
|
||||
Some(("Release to Layers (Build)", "layer.releaseToLayersBuild", json!({}), one_container, false)),
|
||||
Some(("Reverse Order", "layer.reverse", json!({}), rows.len() >= 2, false)),
|
||||
None,
|
||||
Some(("Template", "layer.template", json!({}), !layers_in.is_empty(), template)),
|
||||
]);
|
||||
// Each pair reads by state: Hide Others while another layer shows, else Show All Layers.
|
||||
let any_other = |f: &dyn Fn(&Node) -> bool| others.iter().any(|l| f(l));
|
||||
items.push(Some(if any_other(&|l| l.visible) {
|
||||
("Hide Others", "layer.hideOthers", json!({}), true, false)
|
||||
} else {
|
||||
("Show All Layers", "layer.showAll", json!({}), all_layers.iter().any(|l| !l.visible), false)
|
||||
}));
|
||||
items.push(Some(if any_other(&|l| !matches!(l.kind, NodeKind::Layer { preview: false, .. })) {
|
||||
("Outline Others", "layer.outlineOthers", json!({}), true, false)
|
||||
} else {
|
||||
(
|
||||
"Preview All Layers",
|
||||
"layer.previewAll",
|
||||
json!({}),
|
||||
all_layers.iter().any(|l| matches!(l.kind, NodeKind::Layer { preview: false, .. })),
|
||||
false,
|
||||
)
|
||||
}));
|
||||
items.push(Some(if any_other(&|l| !l.locked) {
|
||||
("Lock Others", "layer.lockOthers", json!({}), true, false)
|
||||
} else {
|
||||
("Unlock All Layers", "layer.unlockAll", json!({}), all_layers.iter().any(|l| l.locked), false)
|
||||
}));
|
||||
items.push(None);
|
||||
let remembers = doc.paste_remembers_layers;
|
||||
items.push(Some(("Paste Remembers Layers", "layer.pasteRemembersLayers", json!({"on": !remembers}), true, remembers)));
|
||||
items.push(None);
|
||||
items.push(Some(("Panel Options…", "ui.layersPanelOptions", json!({}), true, false)));
|
||||
for item in items {
|
||||
match item {
|
||||
None => {
|
||||
ui.separator();
|
||||
}
|
||||
Some(("Collect for Export", _, _, enabled, _)) => {
|
||||
collect_for_export_menu(ui, enabled, |multiple| {
|
||||
let mut p = json!({ "multiple": multiple });
|
||||
if !targets.is_empty() {
|
||||
p["ids"] = json!(targets.iter().map(|id| id.0).collect::<Vec<_>>());
|
||||
}
|
||||
app.run("assets.add", p).ok();
|
||||
});
|
||||
}
|
||||
Some(("Save Selection", "", ..)) => save_selection_menu(app, ui),
|
||||
Some((label, cmd, p, enabled, checked)) => {
|
||||
if widgets::menu_item(ui, label, enabled, checked) {
|
||||
let selected = last_selected(app);
|
||||
app.run(cmd, p).ok();
|
||||
see_selection(app, ui, selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect for Export › As Single Asset / As Multiple Assets (`assets.add`).
|
||||
pub(super) fn collect_for_export_menu(ui: &mut Ui, enabled: bool, mut run: impl FnMut(bool)) {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
// English labels match the rest of this panel menu (and Object › Collect for Export).
|
||||
ui.menu_button("Collect for Export", |ui| {
|
||||
if widgets::menu_item(ui, "As Single Asset", true, false) {
|
||||
run(false);
|
||||
ui.close();
|
||||
}
|
||||
if widgets::menu_item(ui, "As Multiple Assets", true, false) {
|
||||
run(true);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Save Selection › Save Selection…, Update Selection and Edit Selection…, enabled and run as in
|
||||
/// the Select menu, then the active document's saved selections: choosing one recalls it.
|
||||
fn save_selection_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.menu_button(format!(" {}", tl!("Save Selection")), |ui| {
|
||||
for (label, id) in [("Save Selection…", "select.save"), ("Update Selection", "select.update"), ("Edit Selection…", "select.editSaved")] {
|
||||
if widgets::menu_item(ui, label, crate::menus::enabled(app, id), false) {
|
||||
crate::menus::invoke(app, id, json!({}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
let names: Vec<String> = app.session.active().map(|d| d.doc.saved_selections.iter().map(|s| s.name.clone()).collect()).unwrap_or_default();
|
||||
if !names.is_empty() {
|
||||
ui.separator();
|
||||
}
|
||||
for name in names {
|
||||
if widgets::menu_item_name(ui, &name, true, false) {
|
||||
let selected = last_selected(app);
|
||||
app.run("select.recall", json!({ "name": name })).ok();
|
||||
see_selection(app, ui, selected);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `ui.layersExpand {ids?, open?}`: open (or close) rows of the active document's Layers panel;
|
||||
/// without `ids`, every row that holds others.
|
||||
pub fn expand(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document open")?;
|
||||
let open = p.get("open").and_then(Value::as_bool).unwrap_or(true);
|
||||
let ids: Vec<u64> = match p.get("ids").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_u64).filter(|i| st.doc.node(NodeId(*i)).is_some()).collect(),
|
||||
None => {
|
||||
let mut v = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if n.is_container() {
|
||||
v.push(n.id.0);
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
};
|
||||
let st = app.session.active_mut().ok_or("no document open")?;
|
||||
for i in &ids {
|
||||
st.layers_open.set(NodeId(*i), open);
|
||||
}
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
}
|
||||
|
||||
/// `ui.layersFilter {objects?, layers?}`: set the Layers panel's Filter, `objects` to the object
|
||||
/// kinds ticked under By Object and `layers` to the row states ticked under By Layer, by parameter
|
||||
/// name ([`ObjectKind::ALL`], [`RowState::ALL`]). A key left out or set to null keeps its list; any
|
||||
/// other value that is not an array of those names is an error, and then nothing changes.
|
||||
/// → {objects, layers}
|
||||
pub fn set_filter(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let objects = parse_names(p, "objects", &ObjectKind::ALL)?;
|
||||
let layers = parse_names(p, "layers", &RowState::ALL)?;
|
||||
let f = &mut app.ui.layers_filter;
|
||||
if let Some(objects) = objects {
|
||||
f.objects = objects;
|
||||
}
|
||||
if let Some(layers) = layers {
|
||||
f.layers = layers;
|
||||
}
|
||||
Ok(json!({"objects": names(&ObjectKind::ALL, &f.objects), "layers": names(&RowState::ALL, &f.layers)}))
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
use super::rows::key;
|
||||
use super::*;
|
||||
|
||||
/// Where a dragged row goes relative to the row it is dropped on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Place {
|
||||
Above,
|
||||
Below,
|
||||
Inside,
|
||||
}
|
||||
|
||||
impl Place {
|
||||
pub(super) fn id(self) -> &'static str {
|
||||
match self {
|
||||
Place::Above => "above",
|
||||
Place::Below => "below",
|
||||
Place::Inside => "inside",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name painted for a row in `lang`: a generated `<Kind>` name is translated, anything else is
|
||||
/// user data (an unnamed text object shows its text, which can look like `<Path>`; only an empty
|
||||
/// one is called `<Text>`).
|
||||
pub(super) fn painted_name(n: &Node, name: &str, lang: crate::i18n::Lang) -> String {
|
||||
let generated = n.name.is_none() && !matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| !r.text.is_empty()));
|
||||
if generated && let Some(inner) = name.strip_prefix('<').and_then(|s| s.strip_suffix('>')) {
|
||||
return format!("<{}>", crate::i18n::tr(lang, inner));
|
||||
}
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// The name the panel shows for `n` in `lang`: its XML ID while Identify Objects By is XML ID,
|
||||
/// otherwise [`painted_name`].
|
||||
pub(crate) fn shown_name(app: &VectorcraftApp, n: &Node, lang: crate::i18n::Lang) -> String {
|
||||
let xml_id = app.session.prefs.identify_objects_by == "xmlId";
|
||||
let label = n.identify_label(xml_id);
|
||||
if xml_id { label } else { painted_name(n, &label, lang) }
|
||||
}
|
||||
|
||||
/// Whether `n` gets rows for what it holds (a compound path's paths are one object).
|
||||
pub(super) fn opens(n: &Node, opts: &PanelOptions) -> bool {
|
||||
match n.kind {
|
||||
NodeKind::Compound { .. } => false,
|
||||
_ => n.children().is_some_and(|c| c.iter().any(|c| !opts.layers_only || c.is_layer())),
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the panel.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Row {
|
||||
pub(super) node: Arc<Node>,
|
||||
/// Nesting level (0: a top-level layer).
|
||||
pub(super) depth: usize,
|
||||
/// A clipping path: its group or layer clips.
|
||||
pub(super) clip_path: bool,
|
||||
/// It holds rows ([`opens`]), and shows them.
|
||||
pub(super) opens: bool,
|
||||
pub(super) open: bool,
|
||||
/// The colour of its layer (its own, for a layer).
|
||||
pub(super) colour: Color32,
|
||||
/// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer.
|
||||
pub(super) dim: bool,
|
||||
/// A ruler guide on the layer `node` (its index in the document's guides): the guide's row.
|
||||
pub(super) guide: Option<usize>,
|
||||
}
|
||||
|
||||
/// An object kind under the Filter's By Object (`ui.layersFilter`'s `objects`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ObjectKind {
|
||||
Paths,
|
||||
Shapes,
|
||||
Text,
|
||||
Images,
|
||||
Effects,
|
||||
Groups,
|
||||
LinkedFiles,
|
||||
Guides,
|
||||
}
|
||||
|
||||
impl ObjectKind {
|
||||
/// Every kind with its parameter name, in the popup's order.
|
||||
pub const ALL: [(Self, &'static str); 8] = [
|
||||
(Self::Paths, "paths"),
|
||||
(Self::Shapes, "shapes"),
|
||||
(Self::Text, "text"),
|
||||
(Self::Images, "images"),
|
||||
(Self::Effects, "effects"),
|
||||
(Self::Groups, "groups"),
|
||||
(Self::LinkedFiles, "linkedFiles"),
|
||||
(Self::Guides, "guides"),
|
||||
];
|
||||
|
||||
pub(super) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Paths => tl!("Paths"),
|
||||
Self::Shapes => tl!("Shapes"),
|
||||
Self::Text => tl!("Text"),
|
||||
Self::Images => tl!("Images"),
|
||||
Self::Effects => tl!("Effects"),
|
||||
Self::Groups => tl!("Groups"),
|
||||
Self::LinkedFiles => tl!("Linked Files"),
|
||||
Self::Guides => tl!("Guides"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `n`'s row is of this kind. Paths take plain paths, the paths whose corners Live
|
||||
/// Corners cut and compound paths; Shapes take live rectangles, ellipses, polygons and lines;
|
||||
/// Paths and Shapes leave out paths made into guides. Effects take a row with an effect, shown
|
||||
/// or hidden, on itself or on one of its fills or strokes. Linked Files take linked images and
|
||||
/// placed documents.
|
||||
fn takes(self, n: &Node) -> bool {
|
||||
match (self, &n.kind) {
|
||||
(Self::Guides, NodeKind::Path { guide, .. }) => *guide,
|
||||
(Self::Paths | Self::Shapes, NodeKind::Path { guide: true, .. }) => false,
|
||||
(Self::Paths, NodeKind::Path { live, .. }) => live.as_deref().is_none_or(|l| matches!(l, LiveShape::Path { .. })),
|
||||
(Self::Shapes, NodeKind::Path { live, .. }) => live.as_deref().is_some_and(|l| !matches!(l, LiveShape::Path { .. })),
|
||||
(Self::Paths, NodeKind::Compound { .. })
|
||||
| (Self::Text, NodeKind::Text(_))
|
||||
| (Self::Images, NodeKind::Image(_))
|
||||
| (Self::Groups, NodeKind::Group { .. })
|
||||
| (Self::LinkedFiles, NodeKind::PlacedDocument(_)) => true,
|
||||
(Self::LinkedFiles, NodeKind::Image(im)) => im.link.is_some(),
|
||||
(Self::Effects, _) => !n.appearance.effects.is_empty() || n.appearance.items.iter().any(|i| !i.effects().is_empty()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A row state under the Filter's By Layer (`ui.layersFilter`'s `layers`): the row's own lock or
|
||||
/// eye, as its lock and eye columns show them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RowState {
|
||||
Locked,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl RowState {
|
||||
/// Every state with its parameter name, in the popup's order.
|
||||
pub const ALL: [(Self, &'static str); 2] = [(Self::Locked, "locked"), (Self::Hidden, "hidden")];
|
||||
|
||||
pub(super) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Locked => tl!("Locked"),
|
||||
Self::Hidden => tl!("Hidden"),
|
||||
}
|
||||
}
|
||||
|
||||
fn takes(self, n: &Node) -> bool {
|
||||
match self {
|
||||
Self::Locked => n.locked,
|
||||
Self::Hidden => !n.visible,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Layers panel's Filter (`ui.layersFilter`): the object kinds ticked under By Object and the
|
||||
/// row states ticked under By Layer, each in the popup's order. A section with nothing ticked takes
|
||||
/// every row.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
pub objects: Vec<ObjectKind>,
|
||||
pub layers: Vec<RowState>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Whether anything is ticked.
|
||||
pub fn is_on(&self) -> bool {
|
||||
!self.objects.is_empty() || !self.layers.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `n`'s row is of a ticked kind and in a ticked state.
|
||||
fn keeps(&self, n: &Node) -> bool {
|
||||
(self.objects.is_empty() || self.objects.iter().any(|k| k.takes(n))) && (self.layers.is_empty() || self.layers.iter().any(|s| s.takes(n)))
|
||||
}
|
||||
|
||||
/// Whether the ruler guide rows stay: with no filter on, or with Guides ticked and no row state
|
||||
/// ticked.
|
||||
fn keeps_guides(&self) -> bool {
|
||||
!self.is_on() || (self.objects.contains(&ObjectKind::Guides) && self.layers.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameter names of the ones of `all` in `on`, in `all`'s order.
|
||||
pub(super) fn names<T: Copy + PartialEq>(all: &[(T, &'static str)], on: &[T]) -> Vec<&'static str> {
|
||||
all.iter().filter(|(t, _)| on.contains(t)).map(|(_, name)| *name).collect()
|
||||
}
|
||||
|
||||
/// The parameter names of `on` with `t` added, or taken out when it is in `on`.
|
||||
pub(super) fn toggled<T: Copy + PartialEq>(all: &[(T, &'static str)], on: &[T], t: T) -> Vec<&'static str> {
|
||||
all.iter().filter(|(x, _)| if *x == t { !on.contains(x) } else { on.contains(x) }).map(|(_, name)| *name).collect()
|
||||
}
|
||||
|
||||
/// The list `key` of `p` read by the parameter names of `all`, in `all`'s order: `None` when `p`
|
||||
/// leaves it out or sets it to null, and an error when it is anything other than an array of
|
||||
/// those names.
|
||||
pub(super) fn parse_names<T: Copy + PartialEq>(p: &Value, key: &str, all: &[(T, &'static str)]) -> Result<Option<Vec<T>>, String> {
|
||||
let given = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().map(|name| all.iter().find(|(_, n)| Some(*n) == name.as_str()).map(|(t, _)| *t)).collect::<Option<Vec<T>>>()),
|
||||
};
|
||||
let Some(given) = given else {
|
||||
let names: Vec<&str> = all.iter().map(|(_, n)| *n).collect();
|
||||
return Err(format!("`{key}` must be an array of these names: {}", names.join(", ")));
|
||||
};
|
||||
Ok(Some(all.iter().map(|(t, _)| *t).filter(|t| given.contains(t)).collect()))
|
||||
}
|
||||
|
||||
/// What the rows were listed from: listed again when any of it changes.
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct RowsKey {
|
||||
uid: u64,
|
||||
revision: u64,
|
||||
open: u64,
|
||||
layers_only: bool,
|
||||
query: String,
|
||||
filter: Filter,
|
||||
}
|
||||
|
||||
/// The rows the panel shows, top down: the layers (only the opacity mask's while one is edited)
|
||||
/// with the rows of the open ones inside them. A search or a filter shows every row whose name
|
||||
/// contains `query` (lowercase) and that `filter` keeps, and every row holding one.
|
||||
pub(super) fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str, filter: &Filter) -> Vec<Row> {
|
||||
let mask_layer = doc.mask_edit.map(|m| m.layer);
|
||||
let mut out = vec![];
|
||||
for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) {
|
||||
push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, filter, doc), &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Add the rows of `n` and those open inside it to `out`; `(colour, shown)` are those of what holds
|
||||
/// it (the layer's colour, and whether it is visible).
|
||||
pub(super) fn push_rows(
|
||||
n: &Arc<Node>,
|
||||
depth: usize,
|
||||
clip_path: bool,
|
||||
(colour, shown): (Color32, bool),
|
||||
cx: (&OpenRows, &PanelOptions, &str, &Filter, &Document),
|
||||
out: &mut Vec<Row>,
|
||||
) {
|
||||
let (open_rows, opts, query, filter, doc) = cx;
|
||||
if opts.layers_only && !n.is_layer() {
|
||||
return;
|
||||
}
|
||||
let colour = match &n.kind {
|
||||
NodeKind::Layer { color, .. } => {
|
||||
let [r, g, b] = color.rgb();
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
_ => colour,
|
||||
};
|
||||
let shown = shown && n.visible;
|
||||
let searching = !query.is_empty();
|
||||
let narrowing = searching || filter.is_on();
|
||||
// A layer's ruler guides are rows in it, above its art (not while searching, nor while the
|
||||
// filter leaves them out).
|
||||
let guides: Vec<usize> =
|
||||
if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] };
|
||||
let opens = opens(n, opts) || !guides.is_empty();
|
||||
let open = opens && (narrowing || open_rows.contains(n.id));
|
||||
let at = out.len();
|
||||
out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None });
|
||||
if open {
|
||||
for i in guides.into_iter().rev() {
|
||||
out.push(Row { node: n.clone(), depth: depth + 1, clip_path: false, opens: false, open: false, colour, dim: !shown, guide: Some(i) });
|
||||
}
|
||||
}
|
||||
if open && let Some(children) = n.children() {
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
push_rows(c, depth + 1, i == 0 && n.clips(), (colour, shown), cx, out);
|
||||
}
|
||||
}
|
||||
// Searching or filtering, a row stays for a match of its own (its name holds the search text
|
||||
// and the filter keeps it) or for a row it keeps inside it.
|
||||
if narrowing && out.len() == at + 1 && !(filter.keeps(n) && (!searching || n.display_name().to_lowercase().contains(query))) {
|
||||
out.truncate(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows of the active document as [`list_rows`] lists them, listed again only when the
|
||||
/// document, its open rows, the search, the filter or Show Layers Only changed.
|
||||
pub(super) fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, query: &str, filter: &Filter) -> Arc<Vec<Row>> {
|
||||
let now = RowsKey {
|
||||
uid: st.uid,
|
||||
revision: st.revision,
|
||||
open: st.layers_open.generation(),
|
||||
layers_only: opts.layers_only,
|
||||
query: query.to_string(),
|
||||
filter: filter.clone(),
|
||||
};
|
||||
let id = key("rows");
|
||||
if let Some((was, rows)) = ui.data(|d| d.get_temp::<(RowsKey, Arc<Vec<Row>>)>(id))
|
||||
&& was == now
|
||||
{
|
||||
return rows;
|
||||
}
|
||||
let rows = Arc::new(list_rows(&st.doc, &st.layers_open, opts, query, filter));
|
||||
ui.data_mut(|d| d.insert_temp(id, (now, rows.clone())));
|
||||
rows
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
use super::controls::collect_for_export_menu;
|
||||
use super::model::{Place, Row, opens, painted_name};
|
||||
use super::thumbs::{node_arc, real_thumb, thumb};
|
||||
use super::*;
|
||||
|
||||
/// Stable id for the layers panel's per-name UI state.
|
||||
pub(super) fn key(name: &str) -> egui::Id {
|
||||
egui::Id::unique(("layers-panel", name))
|
||||
}
|
||||
|
||||
/// Temp-memory key for the layer rename editor.
|
||||
pub(super) fn rename_id() -> egui::Id {
|
||||
egui::Id::unique("layers-rename")
|
||||
}
|
||||
|
||||
/// Ghost rows are drawn at this opacity.
|
||||
const GHOST_OPACITY: f32 = 0.5;
|
||||
|
||||
/// The colour of the layer holding `id`.
|
||||
fn layer_colour(doc: &Document, id: NodeId) -> Color32 {
|
||||
let [r, g, b] = doc.layer_color(id);
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// The layer colour bar of row `n` (rect `r`) at `x`: wider on layers.
|
||||
fn colour_bar(ui: &Ui, n: &Node, r: egui::Rect, x: f32, colour: Color32) {
|
||||
let w = if n.is_layer() { 4.0 } else { 2.0 };
|
||||
ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(w, r.height())), 0.0, colour);
|
||||
}
|
||||
|
||||
/// The thumbnail of row `n` (rect `r`) at `x`; returns where it went.
|
||||
fn row_thumb(ui: &Ui, view: &View, n: &Arc<Node>, r: egui::Rect, x: f32) -> egui::Rect {
|
||||
let s = (view.h - 4.0).min(40.0);
|
||||
let th = egui::Rect::from_min_size(egui::pos2(x, r.center().y - s / 2.0), vec2(s, s));
|
||||
ui.painter().rect_filled(th, 0.0, Color32::WHITE);
|
||||
ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, view.t.border), StrokeKind::Outside);
|
||||
if !real_thumb(ui, &view.thumbs, n, th) {
|
||||
thumb(ui, n, th);
|
||||
}
|
||||
th
|
||||
}
|
||||
|
||||
/// The dragged row `src`, dimmed, following the pointer at `pos` above everything else: its
|
||||
/// colour bar, thumbnail and name.
|
||||
pub(super) fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) {
|
||||
let (doc, t, h) = (view.doc, &view.t, view.h);
|
||||
let Some(n) = node_arc(doc, src) else { return };
|
||||
let n = &n;
|
||||
let (grab, width, depth) = ui.data(|d| d.get_temp::<(egui::Vec2, f32, usize)>(key("grab"))).unwrap_or((vec2(h, h / 2.0), 240.0, 0));
|
||||
// A plain Ui on the tooltip layer, not an Area: a new Area is invisible for a frame and fades in.
|
||||
let r = egui::Rect::from_min_size(pos - grab, vec2(width, h));
|
||||
let id = key("ghost");
|
||||
let mut ui = Ui::new(ui.ctx().clone(), id, egui::UiBuilder::new().layer_id(egui::LayerId::new(egui::Order::Tooltip, id)).max_rect(r));
|
||||
ui.set_opacity(GHOST_OPACITY);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 0.0, t.row_selected);
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
|
||||
let mut x = r.left() + 2.0 * COLUMN + 2.0;
|
||||
colour_bar(&ui, n, r, x, layer_colour(doc, src));
|
||||
x += 22.0 + depth as f32 * INDENT;
|
||||
if view.opts.thumb(n) {
|
||||
x = row_thumb(&ui, view, n, r, x).right() + 4.0;
|
||||
}
|
||||
let label = n.identify_label(view.identify_xml_id);
|
||||
let name = if view.identify_xml_id { label } else { painted_name(n, &label, crate::i18n::current()) };
|
||||
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), t.text);
|
||||
}
|
||||
|
||||
/// Resolve the row clicked this frame: plain (highlight it), Ctrl/Cmd (toggle it) or Shift
|
||||
/// (highlight the rows from the last one clicked down to it).
|
||||
pub(super) fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) {
|
||||
let Some((id, m)) = out.click.take() else { return };
|
||||
let anchor: Option<NodeId> = ui.data(|d| d.get_temp(key("anchor")));
|
||||
ui.data_mut(|d| d.insert_temp(key("focus"), true));
|
||||
let at = |id: NodeId| list.iter().position(|r| r.node.id == id);
|
||||
if m.shift
|
||||
&& let Some(a) = anchor.or(view.rows.last().copied())
|
||||
&& let (Some(i), Some(j)) = (at(a), at(id))
|
||||
{
|
||||
let range: Vec<u64> = list.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().map(|r| r.node.id.0).collect();
|
||||
out.actions.push(("layer.highlight".into(), json!({"ids": range, "mode": if m.command { "add" } else { "set" }})));
|
||||
return;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(key("anchor"), id));
|
||||
if m.command {
|
||||
out.actions.push(("layer.highlight".into(), json!({"ids": [id.0], "mode": "toggle"})));
|
||||
} else {
|
||||
out.actions.push(("layer.setCurrent".into(), json!({"id": id.0})));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether row `n` can take `drag` inside it.
|
||||
fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool {
|
||||
match n.kind {
|
||||
NodeKind::Layer { .. } => true,
|
||||
NodeKind::Group { .. } => match drag {
|
||||
LayersDrag::Rows(ids) => !ids.iter().any(|i| doc.node(*i).is_some_and(Node::is_layer)),
|
||||
LayersDrag::Art => true,
|
||||
LayersDrag::Guides(_) => false,
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection
|
||||
/// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it
|
||||
/// from) the selected guides. A guide on a hidden or locked layer can't be selected or dragged.
|
||||
fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) {
|
||||
let (t, h) = (&view.t, view.h);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag());
|
||||
let Some(g) = view.doc.guides.get(i) else { return };
|
||||
let selected = view.guides.contains(&i);
|
||||
let editable = view.doc.guide_editable(g);
|
||||
if editable && resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6));
|
||||
}
|
||||
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border));
|
||||
let mut x = r.left() + 2.0 * COLUMN + 2.0;
|
||||
colour_bar(ui, &item.node, r, x, item.colour);
|
||||
x += 6.0 + item.depth as f32 * INDENT + 16.0;
|
||||
// The guide mark: a short line across or down, in the guides' colour.
|
||||
let c = egui::pos2(x + 8.0, r.center().y);
|
||||
let half = if g.vertical { vec2(0.0, 6.0) } else { vec2(6.0, 0.0) };
|
||||
ui.painter().line_segment([c - half, c + half], Stroke::new(1.5, t.accent));
|
||||
x += 22.0;
|
||||
let name = format!("<{}>", if g.vertical { tl!("Vertical Guide") } else { tl!("Horizontal Guide") });
|
||||
let color = if item.dim { t.text_dim } else { t.text };
|
||||
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), color);
|
||||
let col_x = r.right() - 43.5;
|
||||
ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border));
|
||||
if selected {
|
||||
let q = egui::Rect::from_center_size(egui::pos2(r.right() - SQUARE_X, r.center().y), vec2(7.0, 7.0));
|
||||
ui.painter().rect_filled(q, 0.0, item.colour);
|
||||
ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside);
|
||||
}
|
||||
if editable && resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command})));
|
||||
}
|
||||
// Dragged: the selected guides when this is one of them, else this one, to another layer's row.
|
||||
if editable && resp.drag_started() {
|
||||
let guides = if selected { view.guides.clone() } else { vec![i] };
|
||||
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides));
|
||||
}
|
||||
if resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
// Guides dropped here go to this guide's layer.
|
||||
if let Some(drag) = resp.dnd_hover_payload::<LayersDrag>()
|
||||
&& let LayersDrag::Guides(guides) = &*drag
|
||||
{
|
||||
guide_drop(ui, view, guides, item.node.id, r, &resp, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Guide rows `guides` dragged over row `r` (`resp`) of layer `layer`: the row lights up, and the
|
||||
/// release puts them on that layer (`guide.setLayer`, one undo step).
|
||||
fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Rect, resp: &egui::Response, out: &mut Out) {
|
||||
ui.painter().rect_stroke(r.shrink(1.0), 0.0, Stroke::new(2.0, view.t.accent), StrokeKind::Inside);
|
||||
if resp.dnd_release_payload::<LayersDrag>().is_some() {
|
||||
out.actions.push(("guide.setLayer".into(), json!({"indexes": guides, "layer": layer.0})));
|
||||
}
|
||||
}
|
||||
|
||||
/// The widgets of row `item`.
|
||||
pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
|
||||
if let Some(i) = item.guide {
|
||||
return guide_row(ui, view, item, i, out);
|
||||
}
|
||||
let n = &*item.node;
|
||||
let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour);
|
||||
let doc = view.doc;
|
||||
let t = &view.t;
|
||||
let h = view.h;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag());
|
||||
if view.reveal == Some(n.id) {
|
||||
ui.scroll_to_rect(r, None);
|
||||
}
|
||||
let is_sel = view.sel.contains(&n.id);
|
||||
let child_sel = !is_sel
|
||||
&& n.children().is_some_and(|_| {
|
||||
let mut any = false;
|
||||
n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id));
|
||||
any
|
||||
});
|
||||
// Highlighted rows (else the current layer's).
|
||||
let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) };
|
||||
if highlighted {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6));
|
||||
}
|
||||
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border));
|
||||
if n.is_layer() && Some(n.id) == view.current {
|
||||
// Current-layer marker: a small triangle in the top-right corner.
|
||||
let c = r.right_top();
|
||||
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], t.text, Stroke::NONE));
|
||||
}
|
||||
eye_and_lock(ui, view, n, r, out);
|
||||
// Layer colour bar (every row shows the colour of its layer).
|
||||
let mut x = r.left() + 2.0 * COLUMN + 2.0;
|
||||
colour_bar(ui, n, r, x, color);
|
||||
x += 6.0 + depth as f32 * INDENT;
|
||||
// Disclosure triangle; Alt opens or closes everything inside too.
|
||||
if has_children {
|
||||
let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0));
|
||||
let dresp = ui.interact(dr, ui.scope_id().with(("disc", n.id.0)), Sense::click());
|
||||
icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim);
|
||||
if dresp.clicked() {
|
||||
out.toggle.push((n.id, !open));
|
||||
if ui.input(|i| i.modifiers.alt) {
|
||||
n.walk(&mut |c| {
|
||||
if c.id != n.id && opens(c, &view.opts) {
|
||||
out.toggle.push((c.id, !open));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
x += 16.0;
|
||||
// Thumbnail.
|
||||
let thumb_rect = view.opts.thumb(n).then(|| {
|
||||
let th = row_thumb(ui, view, &item.node, r, x);
|
||||
x = th.right() + 4.0;
|
||||
th
|
||||
});
|
||||
// Name (Preferences › Units › Identify Objects By: Object Name or XML ID).
|
||||
let xml_id = view.identify_xml_id;
|
||||
let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "<Opacity Mask>".to_string() } else { n.identify_label(xml_id) };
|
||||
let font = egui::FontId::proportional(13.0);
|
||||
let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id()));
|
||||
let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0));
|
||||
let name_end;
|
||||
match renaming {
|
||||
Some((rid, mut buf)) if rid == n.id.0 => {
|
||||
let field = rename_id().with("field");
|
||||
let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect));
|
||||
let te = child.add(egui::TextEdit::singleline(&mut buf).id(field).desired_width(name_rect.width()).font(font.clone()));
|
||||
// Focus the field as it opens. Asking every frame took the focus back from Enter,
|
||||
// Escape or a click elsewhere, so the rename never ended.
|
||||
if !te.has_focus() && !te.lost_focus() {
|
||||
te.request_focus();
|
||||
}
|
||||
if te.lost_focus() {
|
||||
ui.data_mut(|d| d.remove::<(u64, String)>(rename_id()));
|
||||
if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name {
|
||||
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf})));
|
||||
}
|
||||
} else {
|
||||
// Shift+Enter renames it and keeps the field open.
|
||||
if kept && buf != name {
|
||||
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()})));
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf)));
|
||||
}
|
||||
name_end = name_rect.right();
|
||||
}
|
||||
_ => {
|
||||
let painter = ui.painter().with_clip_rect(name_rect);
|
||||
// Generated names ("<Path>", "<Opacity Mask>") are translated where painted; the stored name stays English.
|
||||
// XML IDs stay as ids (not translated).
|
||||
let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) {
|
||||
tl!("<Opacity Mask>").to_string()
|
||||
} else if xml_id {
|
||||
name.clone()
|
||||
} else {
|
||||
painted_name(n, &n.display_name(), crate::i18n::current())
|
||||
};
|
||||
let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, if item.dim { t.text_dim } else { t.text });
|
||||
name_end = text.right();
|
||||
// A clipping path's name is underlined, a masked object's with a dashed line.
|
||||
if clip_path {
|
||||
painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text));
|
||||
} else if n.extras().mask.is_some() {
|
||||
painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Target circle and selection square.
|
||||
let col_x = r.right() - 43.5;
|
||||
ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border));
|
||||
let tc = egui::pos2(r.right() - TARGET_X, r.center().y);
|
||||
let tresp = target_circle(ui, n, tc, h, view.target.map_or(is_sel, |id| id == n.id), &mut out.actions, t);
|
||||
let sq = egui::pos2(r.right() - SQUARE_X, r.center().y);
|
||||
if is_sel {
|
||||
let q = egui::Rect::from_center_size(sq, vec2(7.0, 7.0));
|
||||
ui.painter().rect_filled(q, 0.0, color);
|
||||
ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside);
|
||||
} else if child_sel {
|
||||
ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(4.0, 4.0)), 0.0, color);
|
||||
}
|
||||
let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, h)), ui.scope_id().with(("selsq", n.id.0)), Sense::click_and_drag());
|
||||
// Dragging the selected-art square carries the selection to another row.
|
||||
if (is_sel || child_sel) && sq_resp.drag_started() {
|
||||
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Art);
|
||||
}
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
if tresp.clicked() {
|
||||
out.actions.push(("layer.target".into(), json!({"id": n.id.0, "add": m.shift})));
|
||||
} else if sq_resp.clicked() {
|
||||
out.actions.push(("layer.selectAll".into(), json!({"id": n.id.0, "add": m.shift})));
|
||||
} else if resp.clicked() {
|
||||
out.click = Some((n.id, m));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
let on_name = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x <= name_end.max(name_rect.left() + 20.0) && p.x >= name_rect.left());
|
||||
let on_thumb = thumb_rect.is_some_and(|th| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| th.contains(p)));
|
||||
if on_name && !on_thumb {
|
||||
ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, n.identify_label(view.identify_xml_id))));
|
||||
} else {
|
||||
out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]})));
|
||||
}
|
||||
}
|
||||
// Row menu: Collect for Export on this row (or every highlighted row when this is one of them).
|
||||
let collect_ids: Vec<u64> = if view.rows.contains(&n.id) { view.rows.iter().map(|id| id.0).collect() } else { vec![n.id.0] };
|
||||
resp.context_menu(|ui| {
|
||||
collect_for_export_menu(ui, true, |multiple| {
|
||||
out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple })));
|
||||
});
|
||||
});
|
||||
// Dragging rows: the highlighted rows when this is one of them, else this row.
|
||||
if resp.drag_started() && view.column.is_none() {
|
||||
let ids = if view.rows.contains(&n.id) { view.rows.clone() } else { vec![n.id] };
|
||||
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Rows(ids));
|
||||
let grab = resp.interact_pointer_pos().map_or(vec2(h, h / 2.0), |p| p - r.min);
|
||||
ui.data_mut(|d| d.insert_temp(key("grab"), (grab, r.width(), depth)));
|
||||
}
|
||||
if resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
drop_target(ui, view, n, r, x - 16.0, open, &resp, out);
|
||||
}
|
||||
|
||||
/// Alt-clicking row `n`'s eye (`eye`) or lock: Hide Others or Lock Others, every top-level layer
|
||||
/// but the one holding the row; when they all are hidden (locked) already, Show All Layers (Unlock
|
||||
/// All Layers). Each is one undo step.
|
||||
fn others_action(doc: &Document, n: &Node, eye: bool) -> (String, Value) {
|
||||
let own = doc.layer_of(n.id);
|
||||
let mut others = doc.layers.iter().filter(|l| Some(l.id) != own);
|
||||
let cmd = match (eye, if eye { others.any(|l| l.visible) } else { others.any(|l| !l.locked) }) {
|
||||
(true, true) => "layer.hideOthers",
|
||||
(true, false) => "layer.showAll",
|
||||
(false, true) => "layer.lockOthers",
|
||||
(false, false) => "layer.unlockAll",
|
||||
};
|
||||
// Show All and Unlock All take no rows.
|
||||
let params = if cmd.ends_with("Others") { json!({ "ids": [n.id.0] }) } else { json!({}) };
|
||||
(cmd.into(), params)
|
||||
}
|
||||
|
||||
/// Ctrl/Cmd+Alt-clicking row `n`'s eye runs Outline Others, which puts every top-level layer but the
|
||||
/// one holding the row in Outline and puts that one in Preview. When all the other layers are in
|
||||
/// Outline already, it runs Preview All Layers. Each is one undo step.
|
||||
fn outline_others_action(doc: &Document, n: &Node) -> (String, Value) {
|
||||
let own = doc.layer_of(n.id);
|
||||
if doc.layers.iter().any(|l| Some(l.id) != own && !matches!(l.kind, NodeKind::Layer { preview: false, .. })) {
|
||||
("layer.outlineOthers".into(), json!({ "ids": [n.id.0] }))
|
||||
} else {
|
||||
// Preview All takes no rows.
|
||||
("layer.previewAll".into(), json!({}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The eye and lock columns of row `n` at `r`.
|
||||
fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out) {
|
||||
let t = &view.t;
|
||||
let h = view.h;
|
||||
let eye = egui::Rect::from_min_size(r.min, vec2(COLUMN, h));
|
||||
let lock = egui::Rect::from_min_size(r.min + vec2(COLUMN, 0.0), vec2(COLUMN, h));
|
||||
ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.input_border));
|
||||
ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.input_border));
|
||||
let preview = !matches!(n.kind, NodeKind::Layer { preview: false, .. });
|
||||
let er = ui.interact(eye, ui.scope_id().with(("eye", n.id.0)), Sense::click_and_drag());
|
||||
if n.visible {
|
||||
let icon = egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0));
|
||||
if n.is_template() {
|
||||
// A template layer shows the template mark instead of the eye.
|
||||
icons::paint(ui, "shapes", icon, t.icon);
|
||||
} else if preview {
|
||||
icons::paint(ui, "eye", icon, t.icon);
|
||||
} else {
|
||||
// Outline view: a hollow eye.
|
||||
icons::paint(ui, "eye", icon, t.text_dim);
|
||||
ui.painter().circle_stroke(eye.center(), 7.5, Stroke::new(1.0, t.text_dim));
|
||||
}
|
||||
}
|
||||
let lr = ui.interact(lock, ui.scope_id().with(("lock", n.id.0)), Sense::click_and_drag());
|
||||
if n.locked {
|
||||
icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon);
|
||||
} else if lr.hovered() {
|
||||
icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.text_disabled);
|
||||
}
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
if er.clicked() {
|
||||
if m.alt && m.command {
|
||||
out.actions.push(outline_others_action(view.doc, n));
|
||||
} else if m.alt {
|
||||
out.actions.push(others_action(view.doc, n, true));
|
||||
} else if m.command && n.is_layer() {
|
||||
// Ctrl/Cmd-click: Preview ↔ Outline for this layer.
|
||||
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "preview": !preview})));
|
||||
} else {
|
||||
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "visible": !n.visible})));
|
||||
}
|
||||
}
|
||||
if lr.clicked() && m.alt {
|
||||
out.actions.push(others_action(view.doc, n, false));
|
||||
} else if lr.clicked() {
|
||||
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "locked": !n.locked})));
|
||||
}
|
||||
// Dragging down a column gives every row it passes this row's new state.
|
||||
if er.drag_started() {
|
||||
out.column = Some(Some(ColumnDrag { eye: true, value: !n.visible, ids: vec![n.id.0] }));
|
||||
} else if lr.drag_started() {
|
||||
out.column = Some(Some(ColumnDrag { eye: false, value: !n.locked, ids: vec![n.id.0] }));
|
||||
} else if let Some(c) = out.column.clone().unwrap_or_else(|| view.column.clone())
|
||||
&& !c.ids.contains(&n.id.0)
|
||||
&& ui.input(|i| i.pointer.primary_down() && i.pointer.interact_pos().is_some_and(|p| p.y >= r.top() && p.y < r.bottom()))
|
||||
{
|
||||
let mut c = c;
|
||||
c.ids.push(n.id.0);
|
||||
out.column = Some(Some(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Row `n` at `r` as a drop target for dragged rows or the selected-art square: shows where they
|
||||
/// would go (a line above or below it, or a box around it for inside) and moves them on release
|
||||
/// (Alt copies). `indent` is where the row's content starts.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: bool, resp: &egui::Response, out: &mut Out) {
|
||||
let Some(drag) = resp.dnd_hover_payload::<LayersDrag>() else { return };
|
||||
let doc = view.doc;
|
||||
let moving: Vec<NodeId> = match &*drag {
|
||||
LayersDrag::Rows(ids) => ids.clone(),
|
||||
LayersDrag::Art => view.sel.iter().copied().collect(),
|
||||
LayersDrag::Guides(guides) => {
|
||||
// Guides go to the layer of the row (the row's own, for a layer).
|
||||
if let Some(layer) = if n.is_layer() { Some(n.id) } else { doc.layer_containing(n.id) } {
|
||||
guide_drop(ui, view, guides, layer, r, resp, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Never into itself or a row inside it.
|
||||
let around = doc.ancestry(n.id).unwrap_or_default();
|
||||
if moving.is_empty() || moving.iter().any(|m| around.contains(m)) {
|
||||
return;
|
||||
}
|
||||
let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return };
|
||||
let rel = (p.y - r.top()) / r.height().max(1.0);
|
||||
let inside = takes(n, &drag, doc);
|
||||
let place = match &*drag {
|
||||
LayersDrag::Art if inside => Place::Inside,
|
||||
LayersDrag::Art => Place::Above,
|
||||
LayersDrag::Rows(_) if inside && rel >= 0.25 && (rel <= 0.75 || open) => Place::Inside,
|
||||
LayersDrag::Rows(_) if rel < 0.5 => Place::Above,
|
||||
LayersDrag::Rows(_) | LayersDrag::Guides(_) => Place::Below,
|
||||
};
|
||||
let stroke = Stroke::new(2.0, view.t.accent);
|
||||
match place {
|
||||
Place::Above => {
|
||||
ui.painter().line_segment([egui::pos2(indent, r.top()), egui::pos2(r.right(), r.top())], stroke);
|
||||
}
|
||||
Place::Below => {
|
||||
ui.painter().line_segment([egui::pos2(indent, r.bottom()), egui::pos2(r.right(), r.bottom())], stroke);
|
||||
}
|
||||
Place::Inside => {
|
||||
ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
if resp.dnd_release_payload::<LayersDrag>().is_some() {
|
||||
let copy = ui.input(|i| i.modifiers.alt);
|
||||
let ids: Vec<u64> = match &*drag {
|
||||
// The selection's top objects, as Arrange and Group take them.
|
||||
LayersDrag::Art => doc.paint_order(moving.iter().copied()).iter().map(|i| i.0).collect(),
|
||||
LayersDrag::Rows(ids) => ids.iter().map(|i| i.0).collect(),
|
||||
LayersDrag::Guides(_) => return,
|
||||
};
|
||||
out.actions.push(("layer.move".into(), json!({"ids": ids, "target": n.id.0, "place": place.id(), "copy": copy})));
|
||||
}
|
||||
}
|
||||
|
||||
/// The target circle of `n`'s row at `c`: a ring, doubled while `n` is targeted, filled when `n`
|
||||
/// has an appearance or transparency of its own. Its response is clicked to target `n`; dragging
|
||||
/// it carries `n`'s appearance ([`PanelDrag::Appearance`]) onto another row's circle (Alt
|
||||
/// copies it) or the trash.
|
||||
fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, h: f32, targeted: bool, actions: &mut Vec<(String, Value)>, t: &Tokens) -> egui::Response {
|
||||
let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, h)), ui.scope_id().with(("target", n.id.0)), Sense::click_and_drag());
|
||||
widgets::drag_source(ui, &resp, || PanelDrag::Appearance(n.id));
|
||||
let held = resp.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id));
|
||||
if let Some(d) = resp.dnd_release_payload::<PanelDrag>()
|
||||
&& let PanelDrag::Appearance(source) = *d
|
||||
&& source != n.id
|
||||
{
|
||||
let copy = ui.input(|i| i.modifiers.alt);
|
||||
actions.push(("appearance.transfer".into(), json!({"source": source.0, "target": n.id.0, "copy": copy})));
|
||||
}
|
||||
let ring = if held { t.accent } else { t.icon };
|
||||
ui.painter().circle_stroke(c, 5.0, Stroke::new(if held { 1.5 } else { 1.0 }, ring));
|
||||
if targeted {
|
||||
ui.painter().circle_stroke(c, 2.8, Stroke::new(1.0, t.icon));
|
||||
}
|
||||
if has_styled_target(n) {
|
||||
ui.painter().circle_filled(c, 3.2, t.icon);
|
||||
}
|
||||
if resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Whether an object's target circle is filled: its appearance is not basic (a group or layer:
|
||||
/// it has fills, strokes or effects of its own) or its transparency (opacity, blend mode,
|
||||
/// isolation, knockout or an opacity mask) is not the default.
|
||||
fn has_styled_target(n: &Node) -> bool {
|
||||
!n.appearance.is_basic()
|
||||
|| (matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.appearance.items.is_empty())
|
||||
|| !n.has_default_transparency()
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
use super::*;
|
||||
|
||||
/// Where row thumbnails come from: document `uid` at `revision`, and whether a drag is in
|
||||
/// progress (the thumbnails shown then stay until it ends).
|
||||
pub(crate) struct ThumbSource<'a> {
|
||||
pub uid: u64,
|
||||
pub revision: u64,
|
||||
pub doc: &'a Arc<Document>,
|
||||
pub busy: bool,
|
||||
}
|
||||
|
||||
impl<'a> ThumbSource<'a> {
|
||||
/// The thumbnails of the document `st` holds (`doc`, its document).
|
||||
pub(crate) fn of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, doc: &'a Arc<Document>) -> Self {
|
||||
Self { uid: st.uid, revision: st.revision, doc, busy: st.interaction.is_some() || app.session.tool_busy() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Node `id` of `doc`, as the `Arc` its parent holds.
|
||||
pub(crate) fn node_arc(doc: &Document, id: NodeId) -> Option<Arc<Node>> {
|
||||
let path = doc.index_path(id)?;
|
||||
let (first, rest) = path.split_first()?;
|
||||
let mut cur = doc.layers.get(*first)?;
|
||||
for &i in rest {
|
||||
cur = cur.children()?.get(i)?;
|
||||
}
|
||||
Some(cur.clone())
|
||||
}
|
||||
|
||||
/// A row thumbnail's image.
|
||||
struct ThumbEntry {
|
||||
/// The node it shows. Weak, so the cache doesn't keep old art alive, but its allocation stays
|
||||
/// and no other node can come to have its address.
|
||||
node: Weak<Node>,
|
||||
px: u32,
|
||||
/// None: the node draws nothing.
|
||||
tex: Option<egui::TextureHandle>,
|
||||
/// How long it took to render (ms).
|
||||
ms: f64,
|
||||
/// The pass it was last drawn in.
|
||||
used: u64,
|
||||
}
|
||||
|
||||
/// A row thumbnail job: the row (document uid, node), the node and the size rendered.
|
||||
type ThumbJob = ((u64, NodeId), Weak<Node>, u32);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Thumbs {
|
||||
entries: HashMap<(u64, NodeId), ThumbEntry>,
|
||||
/// What the worker is rendering, by row.
|
||||
pending: HashMap<(u64, NodeId), (Weak<Node>, u32)>,
|
||||
worker: Option<ThumbWorker<(u64, NodeId), ThumbJob>>,
|
||||
worker_started: bool,
|
||||
settle: Settle,
|
||||
/// (pass, ms, count) rendered on the UI thread in that pass.
|
||||
spent: (u64, f64, u32),
|
||||
/// Thumbnails rendered on the UI thread (tests count them).
|
||||
pub(super) renders: u64,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
pub(super) static THUMBS: crate::graphics::TexCache<Thumbs> = crate::graphics::TexCache::default();
|
||||
}
|
||||
|
||||
/// The most row thumbnails kept: past it the ones drawn longest ago go.
|
||||
const MAX_THUMBS: usize = 512;
|
||||
|
||||
/// Rendering a thumbnail that took longer than this (ms) again goes to the worker.
|
||||
const LIGHT_THUMB_MS: f64 = 4.0;
|
||||
|
||||
/// A node with more objects than this inside has its first thumbnail made on the worker.
|
||||
const LIGHT_THUMB_NODES: usize = 200;
|
||||
|
||||
/// At most this long (ms) and this many thumbnails are rendered on the UI thread in a pass; the
|
||||
/// rest go to the worker, or wait for the next pass where there is none.
|
||||
const THUMB_BUDGET_MS: f64 = 8.0;
|
||||
|
||||
const THUMB_BUDGET_COUNT: u32 = 16;
|
||||
|
||||
/// How row thumbnails render: images far smaller than they are sample their mip levels.
|
||||
pub(crate) fn thumb_options() -> vectorcraft_render::RenderOptions {
|
||||
vectorcraft_render::RenderOptions { image_mips: true, ..Default::default() }
|
||||
}
|
||||
|
||||
/// Has `n` at most `limit` objects inside it (counting stops there)?
|
||||
fn small_node(n: &Node, limit: usize) -> bool {
|
||||
fn count(n: &Node, left: &mut usize) -> bool {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if *left == 0 {
|
||||
return false;
|
||||
}
|
||||
*left -= 1;
|
||||
if !count(c, left) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
let mut left = limit;
|
||||
count(n, &mut left)
|
||||
}
|
||||
|
||||
impl Thumbs {
|
||||
/// Keep `img` (None: nothing drawn) as the thumbnail of row `key` showing `node`.
|
||||
fn upload(&mut self, ctx: &egui::Context, key: (u64, NodeId), node: Weak<Node>, px: u32, img: Option<&vectorcraft_render::Rendered>, ms: f64) {
|
||||
let color = img.map(crate::canvas::color_image);
|
||||
let used = ctx.cumulative_pass_nr();
|
||||
let name = format!("layer-thumb-{}", key.1.0);
|
||||
match self.entries.get_mut(&key) {
|
||||
Some(e) => {
|
||||
match (color, &mut e.tex) {
|
||||
(Some(color), Some(tex)) => tex.set(color, egui::TextureOptions::LINEAR),
|
||||
(Some(color), tex) => *tex = Some(ctx.load_texture(name, color, egui::TextureOptions::LINEAR)),
|
||||
(None, tex) => *tex = None,
|
||||
}
|
||||
(e.node, e.px, e.ms, e.used) = (node, px, ms, used);
|
||||
}
|
||||
None => {
|
||||
let tex = color.map(|color| ctx.load_texture(name, color, egui::TextureOptions::LINEAR));
|
||||
self.entries.insert(key, ThumbEntry { node, px, tex, ms, used });
|
||||
if self.entries.len() > MAX_THUMBS {
|
||||
// Keep the three quarters drawn last.
|
||||
let mut by_use: Vec<u64> = self.entries.values().map(|e| e.used).collect();
|
||||
by_use.sort_unstable();
|
||||
let cut = by_use.get(by_use.len() - MAX_THUMBS * 3 / 4).copied().unwrap_or(0);
|
||||
self.entries.retain(|_, e| e.used >= cut);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture of row `n` (`px` pixels square) to draw: made or updated now, or on the
|
||||
/// worker (the old one is drawn meanwhile). Thumbnails shown stay during a drag and until
|
||||
/// the document settles.
|
||||
fn texture(&mut self, ctx: &egui::Context, src: &ThumbSource, n: &Arc<Node>, px: u32) -> Option<egui::TextureHandle> {
|
||||
if !self.worker_started {
|
||||
self.worker_started = true;
|
||||
self.worker = ThumbWorker::spawn(ctx, "thumbnails");
|
||||
}
|
||||
let done = match self.worker.as_ref().map(ThumbWorker::poll) {
|
||||
Some(Some(done)) => done,
|
||||
Some(None) => {
|
||||
// The thread is gone: render here from now on.
|
||||
self.worker = None;
|
||||
self.pending.clear();
|
||||
vec![]
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
for d in done {
|
||||
let (key, node, px) = d.key;
|
||||
if self.pending.get(&key).is_some_and(|(p, ppx)| p.ptr_eq(&node) && *ppx == px) {
|
||||
self.pending.remove(&key);
|
||||
}
|
||||
self.upload(ctx, key, node, px, d.img.as_ref(), d.ms);
|
||||
}
|
||||
let key = (src.uid, n.id);
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
let (fresh, light) = match self.entries.get_mut(&key) {
|
||||
Some(e) => {
|
||||
e.used = pass;
|
||||
(std::ptr::eq(e.node.as_ptr(), Arc::as_ptr(n)) && e.px == px, Some(e.ms <= LIGHT_THUMB_MS))
|
||||
}
|
||||
None => (false, None),
|
||||
};
|
||||
let shown = self.entries.get(&key).and_then(|e| e.tex.clone());
|
||||
if fresh || self.pending.get(&key).is_some_and(|(p, ppx)| std::ptr::eq(p.as_ptr(), Arc::as_ptr(n)) && *ppx == px) {
|
||||
return shown;
|
||||
}
|
||||
if light.is_some() && (src.busy || !self.settle.settled(ctx, src.uid, src.revision, ctx.input(|i| i.time))) {
|
||||
return shown;
|
||||
}
|
||||
if self.spent.0 != pass {
|
||||
self.spent = (pass, 0.0, 0);
|
||||
}
|
||||
let in_budget = self.spent.1 < THUMB_BUDGET_MS && self.spent.2 < THUMB_BUDGET_COUNT;
|
||||
let light = light.unwrap_or_else(|| small_node(n, LIGHT_THUMB_NODES)) && in_budget;
|
||||
if !light && let Some(w) = &self.worker {
|
||||
let (doc, node) = (src.doc.clone(), n.clone());
|
||||
let job: ThumbJob = (key, Arc::downgrade(n), px);
|
||||
if w.submit(key, job, Box::new(move |r| r.render_node_thumbnail_with(&doc, &node, px, None, &thumb_options()))) {
|
||||
self.pending.insert(key, (Arc::downgrade(n), px));
|
||||
return shown;
|
||||
}
|
||||
self.worker = None;
|
||||
self.pending.clear();
|
||||
}
|
||||
if !in_budget {
|
||||
ctx.request_repaint();
|
||||
return shown;
|
||||
}
|
||||
thread_local! {
|
||||
static RENDERER: std::cell::RefCell<vectorcraft_render::Renderer> = std::cell::RefCell::new(vectorcraft_render::Renderer::new());
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
let img = RENDERER.with(|r| r.borrow_mut().render_node_thumbnail_with(src.doc, n, px, None, &thumb_options()));
|
||||
let ms = crate::now_ms() - t0;
|
||||
self.spent.1 += ms;
|
||||
self.spent.2 += 1;
|
||||
self.renders += 1;
|
||||
self.upload(ctx, key, Arc::downgrade(n), px, img.as_ref(), ms);
|
||||
self.entries.get(&key).and_then(|e| e.tex.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// A real rendered thumbnail of row `n`, cached by node identity (unchanged nodes keep their `Arc`
|
||||
/// allocation, so the address is a free change detector). Only rendered for visible rows; see
|
||||
/// [`Thumbs::texture`] for when.
|
||||
pub(crate) fn real_thumb(ui: &Ui, src: &ThumbSource, n: &Arc<Node>, r: egui::Rect) -> bool {
|
||||
if !ui.is_rect_visible(r) {
|
||||
return true;
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let px = ((r.width() * ppp).round() as u32).max(8);
|
||||
match THUMBS.with(|c| c.borrow_mut().texture(ui.ctx(), src, n, px)) {
|
||||
Some(t) => {
|
||||
ui.painter().image(t.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny vector thumbnail painted with egui (fallback when rendering isn't possible).
|
||||
pub(super) fn thumb(ui: &Ui, n: &Node, r: egui::Rect) {
|
||||
let Some(b) = n.visual_bounds() else { return };
|
||||
let s = ((r.width() - 3.0) as f64 / b.width().max(b.height()).max(1e-6)) as f32;
|
||||
let off = r.center() - vec2(b.center().x as f32 * s, b.center().y as f32 * s);
|
||||
let p = ui.painter().with_clip_rect(r);
|
||||
let mut count = 0;
|
||||
n.walk(&mut |c| {
|
||||
if count > 40 {
|
||||
return;
|
||||
}
|
||||
if let Some(pd) = c.path_data() {
|
||||
count += 1;
|
||||
let col = c.appearance.fill_paint().color().or(c.appearance.stroke_paint().color()).map(|cc| {
|
||||
let [a, b2, d, _] = cc.to_rgba8(1.0);
|
||||
Color32::from_rgb(a, b2, d)
|
||||
});
|
||||
if let Some(bb) = pd.bounds() {
|
||||
let rr = egui::Rect::from_min_max(off + vec2(bb.x0 as f32 * s, bb.y0 as f32 * s), off + vec2(bb.x1 as f32 * s, bb.y1 as f32 * s));
|
||||
p.rect_filled(rr, 0.0, col.unwrap_or(Color32::from_gray(120)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user