Merge pull request: land #643 (fix #638)

Land #643 (fix #638: Layers panel lays out only rows in view, keeps open rows in the file)
This commit is contained in:
BFlat 2026-10-09 08:40:24 +07:00 committed by GitHub
commit c7aeaa637f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 290 additions and 73 deletions

File diff suppressed because one or more lines are too long

View File

@ -610,6 +610,11 @@ pub struct Document {
/// save time only, so changing the view never marks the document modified.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_view: Option<SavedView>,
/// The layers, sublayers and groups open in the Layers panel when the document was saved; they
/// reopen that way. `None`: the default, only the top-level layers open. Written at save time
/// only, like [`Document::last_view`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layers_open: Option<Vec<NodeId>>,
/// Edit → Assign Profile: the profiles the document is tagged with (files before format v3
/// kept them in `unknown`, see [`Document::migrate_color_profiles`]).
#[serde(default, skip_serializing_if = "ColorProfiles::is_empty")]
@ -702,6 +707,7 @@ impl Document {
metadata: DocMetadata::default(),
raster_effects: RasterEffectsSettings::default(),
last_view: None,
layers_open: None,
color_profiles: ColorProfiles::default(),
export_settings: Default::default(),
extra: Default::default(),

View File

@ -216,15 +216,20 @@ fn is_svg(f: &Format) -> bool {
matches!(f.id, "svg" | "svgz")
}
/// The document as written: native files carry the view to reopen at.
/// The document as written: native files carry the view to reopen at and the Layers panel's open
/// rows.
fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {
if is_native(f) && st.doc.last_view != st.view {
let mut d = (*st.doc).clone();
d.last_view = st.view.clone();
Arc::new(d)
} else {
st.doc.clone()
if !is_native(f) {
return st.doc.clone();
}
let open = st.layers_open.saved(&st.doc);
if st.doc.last_view == st.view && st.doc.layers_open == open {
return st.doc.clone();
}
let mut d = (*st.doc).clone();
d.last_view = st.view.clone();
d.layers_open = open;
Arc::new(d)
}
/// What a format loses against a native file (reported whenever a save writes it).
@ -274,6 +279,7 @@ fn artboard_doc(doc: &Document, i: usize) -> Document {
d.artboards = vec![a.clone()];
// It opens fitted to its artboard, not at the master file's view.
d.last_view = None;
d.layers_open = None;
for layer in &mut d.layers {
keep_on(layer, r);
}

View File

@ -153,6 +153,8 @@ pub struct DocState {
pub recovery: Option<cmd::recovery::RecoveryCopy>,
/// View → Show Print Tiling, per document (view state: not saved, not undoable).
pub print_tiling: bool,
/// The rows open in the Layers panel (view state: not undoable; native files keep it).
pub layers_open: OpenRows,
/// Transform Again after a perspective move or scale (Perspective Selection tool): the
/// `perspective.transform` params it repeats. `None` once an ordinary transform follows.
pub last_perspective: Option<Value>,
@ -160,11 +162,61 @@ pub struct DocState {
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold.
/// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only
/// its top-level layers open.
#[derive(Clone, Debug, Default)]
pub struct OpenRows {
ids: std::collections::HashSet<NodeId>,
/// Counts the changes, so views can keep what they work out from the open rows.
generation: u64,
}
impl OpenRows {
/// The rows `saved` in a file, else `doc`'s top-level layers.
fn new(doc: &Document, saved: Option<Vec<NodeId>>) -> Self {
let ids = match saved {
Some(ids) => ids.into_iter().collect(),
None => doc.layers.iter().map(|l| l.id).collect(),
};
Self { ids, generation: 0 }
}
pub fn contains(&self, id: NodeId) -> bool {
self.ids.contains(&id)
}
/// Open or close row `id`.
pub fn set(&mut self, id: NodeId, open: bool) {
let changed = if open { self.ids.insert(id) } else { self.ids.remove(&id) };
self.generation += u64::from(changed);
}
/// Changes so far: the same number means the same open rows.
pub fn generation(&self) -> u64 {
self.generation
}
/// What a native file keeps of the open rows: those of `doc` that hold others, in id order;
/// `None` when they are the default (only the top-level layers).
pub fn saved(&self, doc: &Document) -> Option<Vec<NodeId>> {
let mut ids = vec![];
doc.walk(|n| {
if n.children().is_some() && self.ids.contains(&n.id) {
ids.push(n.id);
}
});
ids.sort_unstable();
let mut layers: Vec<NodeId> = doc.layers.iter().map(|l| l.id).collect();
layers.sort_unstable();
(ids != layers).then_some(ids)
}
}
impl DocState {
pub fn new(mut doc: Document, path: Option<String>) -> Self {
let active_layer = doc.default_layer();
// The saved view lives here while the document is open (saves write it back).
// The saved view and open Layers rows live here while the document is open (saves write
// them back).
let view = doc.last_view.take();
let saved_open = doc.layers_open.take();
let layers_open = OpenRows::new(&doc, saved_open);
let doc = Arc::new(doc);
Self {
saved_doc: doc.clone(),
@ -190,6 +242,7 @@ impl DocState {
recovered: false,
recovery: None,
print_tiling: false,
layers_open,
last_perspective: None,
}
}
@ -956,6 +1009,7 @@ impl Session {
st.save_options = old.save_options.clone();
st.converted = old.converted;
st.view = old.view.clone();
st.layers_open = old.layers_open.clone();
let old = std::mem::replace(&mut self.docs[index], st);
if let Some(stash) = &mut self.batch_stash {
stash.push(old);

View File

@ -260,6 +260,37 @@ fn the_saved_view_round_trips_without_dirtying() {
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn open_layers_rows_round_trip_without_dirtying() {
let d = dir("open-rows");
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
// A document starts with its top-level layers open, and a file keeps nothing for that.
assert!(s.doc().unwrap().layers_open.contains(layer) && !s.doc().unwrap().layers_open.contains(g));
let native = path(&d, "rows.vectorcraft");
s.execute("document.save", &json!({"path": native})).unwrap();
let bytes = std::fs::read(&native).unwrap();
assert_eq!(vectorcraft_format::load(&bytes).unwrap().layers_open, None);
// Opening the group and closing the layer is view state: not an edit, kept by the file.
let st = s.doc_mut().unwrap();
st.layers_open.set(g, true);
st.layers_open.set(layer, false);
assert!(!s.doc().unwrap().is_dirty());
s.execute("document.save", &json!({"path": native})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers_open, None, "only the written file carries it");
let mut again = session();
again.execute("document.open", &json!({"path": native})).unwrap();
let open = &again.doc().unwrap().layers_open;
assert!(open.contains(g) && !open.contains(layer));
// Older files (none saved) open with the top-level layers open.
let doc = vectorcraft_format::load(&vectorcraft_format::save(&vectorcraft_doc::Document::new(5.0, 5.0), false)).unwrap();
assert_eq!(doc.layers_open, None);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn save_plans_suggest_names_and_folders() {
let mut s = session();

View File

@ -24,13 +24,20 @@
//! on the trash clears it.
//!
//! While an opacity mask is edited the panel lists only its art, under an `<Opacity Mask>` entry.
//!
//! The rows open are the document's ([`OpenRows`], saved in native files): a document opens with
//! only its top-level layers open. The rows shown are listed once per document change, and only
//! those scrolled into view are laid out, so a document of many thousand objects costs no more
//! per frame than a small one.
use std::collections::HashSet;
use std::sync::Arc;
use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_engine::OpenRows;
use crate::theme::Tokens;
use crate::widgets::{Live, PanelDrag};
@ -198,9 +205,97 @@ fn opens(n: &Node, opts: &PanelOptions) -> bool {
}
}
/// Whether `n` or anything in it has a name containing `q` (lowercase).
fn matches(n: &Node, q: &str) -> bool {
n.display_name().to_lowercase().contains(q) || n.children().is_some_and(|c| c.iter().any(|c| matches(c, q)))
/// One row of the panel.
#[derive(Clone)]
struct Row {
node: Arc<Node>,
/// Nesting level (0: a top-level layer).
depth: usize,
/// A clipping path: its group or layer clips.
clip_path: bool,
/// It holds rows ([`opens`]), and shows them.
opens: bool,
open: bool,
/// The colour of its layer (its own, for a layer).
colour: Color32,
/// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer.
dim: bool,
}
/// 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,
}
/// 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 shows every row whose name, or the name of
/// anything inside it, contains `query` (lowercase).
fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str) -> 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), &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).
fn push_rows(
n: &Arc<Node>,
depth: usize,
clip_path: bool,
(colour, shown): (Color32, bool),
cx: (&OpenRows, &PanelOptions, &str),
out: &mut Vec<Row>,
) {
let (open_rows, opts, query) = 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 opens = opens(n, opts);
let searching = !query.is_empty();
let open = opens && (searching || 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() });
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, a row stays for its own name or for a row it keeps inside it.
if searching && out.len() == at + 1 && !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 or Show Layers Only changed.
fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, query: &str) -> 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() };
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));
ui.data_mut(|d| d.insert_temp(id, (now, rows.clone())));
rows
}
/// Everything one frame of the panel reads.
@ -213,8 +308,6 @@ struct View<'a> {
opts: PanelOptions,
t: Tokens,
h: f32,
/// The search field's text, lowercase (empty: every row).
query: String,
column: Option<ColumnDrag>,
/// The row to scroll into view.
reveal: Option<NodeId>,
@ -224,8 +317,6 @@ struct View<'a> {
#[derive(Default)]
struct Out {
actions: Vec<(String, Value)>,
/// The rows shown, top down.
order: Vec<NodeId>,
/// A row clicked with these modifiers (resolved once every row is listed).
click: Option<(NodeId, egui::Modifiers)>,
/// The column drag after this frame (`Some(None)`: it ended).
@ -249,23 +340,25 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
d.insert_temp(shown_doc, st.uid);
});
}
let uid = st.uid;
let doc = st.doc.clone();
let rows = st.highlighted_rows();
// Rows highlighted anew (a click, Locate Object, a new layer): open the layers around them.
let seen: Vec<NodeId> = ui.data(|d| d.get_temp(key("seen"))).unwrap_or_default();
let mut reveal = None;
let expanded = app.ui.layers_expanded.entry(uid).or_insert_with(|| doc.layers.iter().map(|l| l.id.0).collect());
if rows != seen {
for r in &rows {
for a in doc.ancestry(*r).unwrap_or_default().split_last().map(|(_, a)| a.to_vec()).unwrap_or_default() {
expanded.insert(a.0);
let around: Vec<NodeId> =
rows.iter().flat_map(|r| doc.ancestry(*r).unwrap_or_default().split_last().map(|(_, a)| a.to_vec()).unwrap_or_default()).collect();
if let Some(st) = app.session.active_mut() {
for a in around {
st.layers_open.set(a, true);
}
}
reveal = rows.first().copied();
ui.data_mut(|d| d.insert_temp(key("seen"), rows.clone()));
}
let query = crate::widgets::search_field(ui, egui::Id::new("layers-search"), tl!("Search All")).trim().to_lowercase();
let Some(st) = app.session.active() else { return };
let list = rows_of_doc(ui, st, &app.ui.layers_panel, &query);
let view = View {
doc: &doc,
sel: st.selection.objects.iter().copied().collect(),
@ -275,30 +368,31 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
opts: app.ui.layers_panel.clone(),
t,
h: app.ui.layers_panel.row(),
query,
column: ui.data(|d| d.get_temp(key("column"))),
reveal,
};
// While an opacity mask is edited only its art is listed.
let mask_layer = doc.mask_edit.map(|m| m.layer);
let mut out = Out::default();
ui.add_space(6.0);
let list_h = ui.available_height() - 34.0;
let list = egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show(ui, |ui| {
// Only the rows in view are laid out; one revealed out of view is scrolled to.
let revealed = reveal.and_then(|id| list.iter().position(|r| r.node.id == id));
let scrolled = ui.scope(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let expanded = app.ui.layers_expanded.entry(uid).or_default();
for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) {
row(ui, &view, l, 0, false, expanded, &mut out);
}
egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, view.h, list.len(), |ui, range| {
if let Some(i) = revealed.filter(|i| !range.contains(i)) {
let top = ui.max_rect().top() + (i as f32 - range.start as f32) * view.h;
ui.scroll_to_rect(egui::Rect::from_min_size(egui::pos2(ui.max_rect().left(), top), vec2(ui.max_rect().width(), view.h)), None);
}
for r in list.get(range).unwrap_or_default() {
row(ui, &view, r, &mut out);
}
})
});
let list_rect = list.inner_rect;
resolve_click(ui, &view, &mut out);
let expanded = app.ui.layers_expanded.entry(uid).or_default();
for (id, open) in out.toggle.drain(..) {
if open {
expanded.insert(id.0);
} else {
expanded.remove(&id.0);
let list_rect = scrolled.inner.inner_rect;
resolve_click(ui, &view, &list, &mut out);
if let Some(st) = app.session.active_mut() {
for (id, open) in out.toggle.drain(..) {
st.layers_open.set(id, open);
}
}
// The column drag: live while it lasts, one undo step when the button is released.
@ -415,15 +509,16 @@ fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc: &Document
/// 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).
fn resolve_click(ui: &Ui, view: &View, out: &mut Out) {
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)) = (out.order.iter().position(|r| *r == a), out.order.iter().position(|r| *r == id))
&& let (Some(i), Some(j)) = (at(a), at(id))
{
let range: Vec<u64> = out.order.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().map(|r| r.0).collect();
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;
}
@ -447,19 +542,14 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool {
}
}
#[allow(clippy::too_many_arguments)]
fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expanded: &mut HashSet<u64>, out: &mut Out) {
/// The widgets of row `item`.
fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut 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;
if !view.query.is_empty() && !matches(n, &view.query) {
return;
}
if view.opts.layers_only && !n.is_layer() {
return;
}
let h = view.h;
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag());
out.order.push(n.id);
if view.reveal == Some(n.id) {
ui.scroll_to_rect(r, None);
}
@ -470,7 +560,6 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id));
any
});
let color = layer_colour(doc, n.id);
// 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 {
@ -490,9 +579,6 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
colour_bar(ui, n, r, x, color);
x += 6.0 + depth as f32 * INDENT;
// Disclosure triangle; Alt opens or closes everything inside too.
let searching = !view.query.is_empty();
let has_children = opens(n, &view.opts);
let open = has_children && (searching || expanded.contains(&n.id.0));
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.id().with(("disc", n.id.0)), Sense::click());
@ -548,8 +634,7 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
} else {
painted_name(n, &name, crate::i18n::current())
};
let dim = !doc.is_visible(n.id) || n.is_template();
let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, if dim { t.text_dim } else { t.text });
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 {
@ -605,11 +690,6 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
drop_target(ui, view, n, r, x - 16.0, open, &resp, out);
if open && let Some(children) = n.children() {
for (i, c) in children.iter().enumerate().rev() {
row(ui, view, c, depth + 1, i == 0 && n.clips(), expanded, out);
}
}
}
/// Alt-clicking row `n`'s eye (`eye`) or lock: Hide Others or Lock Others, every top-level layer
@ -951,15 +1031,9 @@ pub fn expand(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
v
}
};
let uid = st.uid;
let layers: Vec<u64> = st.doc.layers.iter().map(|l| l.id.0).collect();
let set = app.ui.layers_expanded.entry(uid).or_insert_with(|| layers.into_iter().collect());
let st = app.session.active_mut().ok_or("no document open")?;
for i in &ids {
if open {
set.insert(*i);
} else {
set.remove(i);
}
st.layers_open.set(NodeId(*i), open);
}
Ok(json!({ "count": ids.len() }))
}
@ -1623,4 +1697,53 @@ mod tests {
assert_eq!(rows_of(&app), vec![sub]);
assert_eq!(app.session.active().unwrap().current_layer(), Some(sub));
}
/// A layer of 2,000 groups of 10 paths with every row open: one frame lays out only the rows
/// in view, and a row highlighted far below them is scrolled to.
#[test]
fn only_the_rows_in_view_are_laid_out() {
use vectorcraft_doc::Appearance;
use vectorcraft_geom::{Rect, shapes};
let mut d = Document::new(1000.0, 1000.0);
let mut groups = vec![];
let mut needle = None;
for g in 0..2000 {
let kids = (0..10)
.map(|k| {
let r = Rect::new(k as f64, g as f64 * 0.1, k as f64 + 1.0, g as f64 * 0.1 + 1.0);
let mut n = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default());
if g == 0 && k == 0 {
n.name = Some("Needle".into());
needle = Some(n.id);
}
Arc::new(n)
})
.collect();
groups.push(Arc::new(Node::group(d.alloc_id(), kids)));
}
*Arc::make_mut(&mut d.layers[0]).children_mut().unwrap() = groups;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.add_document(d, None);
expand(&mut app, &json!({})).unwrap();
let ctx = egui::Context::default();
// (shapes drawn, whether the needle's name is among them); a second passes per frame, so
// scrolling ends.
let mut time = 0.0;
let mut frame = |app: &mut VectorcraftApp| {
time += 1.0;
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 800.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
let found = out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == "Needle"));
(out.shapes.len(), found)
};
let (shapes, found) = frame(&mut app);
// 22,001 rows; about 30 fit.
assert!(shapes < 1000 && !found, "{shapes} shapes");
let needle = needle.unwrap();
app.session.execute("layer.highlight", &json!({"ids": [needle.0], "mode": "set"})).unwrap();
let found = (0..4).any(|_| frame(&mut app).1);
assert_eq!(app.session.active().unwrap().highlighted_rows(), [needle]);
assert!(found, "the highlighted row is scrolled into view");
}
}

View File

@ -365,9 +365,6 @@ pub struct UiState {
/// Layers panel › Panel Options… (row size, thumbnails, Show Layers Only).
#[serde(default)]
pub layers_panel: crate::panels::layers::PanelOptions,
/// The Layers panel's open rows, per open document (`DocState::uid` → node ids).
#[serde(skip)]
pub layers_expanded: std::collections::HashMap<u64, std::collections::HashSet<u64>>,
/// The desktop window's size, position and maximized state, saved when the app quits and
/// restored at the next launch (the desktop host reads and writes it; none on the web).
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -521,7 +518,6 @@ impl Default for UiState {
dxf_import: Value::Null,
home: None,
layers_panel: Default::default(),
layers_expanded: Default::default(),
window: None,
}
}

View File

@ -882,6 +882,7 @@ and `layerRows`, the rows highlighted in the panel.
dimImages, dimPercent; OK is one undo step), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`:
layersOnly, rowSize small|medium|large|other, otherSize, thumbLayers, thumbGroups, thumbObjects), and
`ui.layersExpand {ids?, open?}` opens or closes rows as their triangles do (Alt-click: everything inside).
A document opens with only its top-level layers open; the open rows are saved in the native file and reopen that way.
```json
{"name":"run_command","arguments":{"command":"layer.newSublayer","params":{"name":"Shadows"}}}