mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 00:39:44 -06:00
M2: auto layout keyboard reordering and section hide contents (#78)
* M2: auto layout keyboard reordering, nested auto layout fixes, section hide contents and dev status - Arrow keys move auto layout children one place: along the flow (past hidden and absolute siblings), between wrapped rows, to the neighbouring grid cell, and into the neighbouring nested auto layout frame; no undo step when nothing moves. - Sections: section.toggleContents (hide contents), object.wrapInSection, frames in sections are top-level frames (labels, flows, navigation, PDF export), navigating to a section shows its frame shown last. - Ready for dev turns "Changed" when contents are edited; dev.resolveChange with a reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M2: README and ROADMAP for auto layout arrow keys and sections Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M7: roadmap refresh after #68–#77 Parity table rows for canvas, selection and transform, layers, effects, text, prototyping and comments now reflect the merged work; weighted feature depth ~76%; README command count and hand-off features. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M2: the dev change reason resolves only on Enter or Resolve Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -78,7 +78,7 @@
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Layouts that build themselves</h3>
|
||||
Auto layout in both directions, with wrap and grid (fixed, hug and `fr` tracks, cells that span), padding, gaps, alignment including text baselines, Hug / Fill / Fixed sizing, min and max sizes, absolute positioning, and drag handles for padding and spacing right on the canvas. Constraints and layout grids for everything else.
|
||||
Auto layout in both directions, with wrap and grid (fixed, hug and `fr` tracks, cells that span), padding, gaps, alignment including text baselines, Hug / Fill / Fixed sizing, min and max sizes, absolute positioning, drag handles for padding and spacing right on the canvas, and arrow keys that move children through the flow, grid cells and nested frames. Constraints and layout grids for everything else.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Components that scale</h3>
|
||||
|
||||
+28
-14
@@ -18,7 +18,7 @@ _Last updated: 2026-10-10 (day 2, evening: measuring, scale tool handles and sel
|
||||
| Infrastructure (crates, command registry, undo, renderer, formats, control channel, tests) | **~72%** |
|
||||
| Look & feel vs the incumbent's 2026 UI (measured side by side) | **~65%** |
|
||||
| Menu/command breadth (`docs/parity.md`) | **97.5%** (197/202) of the catalogued menu tree has a command |
|
||||
| Feature depth (weighted rubric below) | **~76%** |
|
||||
| Feature depth (weighted rubric below) | **~77%** |
|
||||
| Parity including interaction fidelity and hardening | **~45–50%** |
|
||||
| Time to **feature parity** | **~45–85 h** one agent · **~12–25 h** with 4–6 agents |
|
||||
| Time to **full parity** (feature parity + fidelity pass + hardening) | **~115–195 h** one agent · **~32–55 h** with 4–6 agents |
|
||||
@@ -73,15 +73,28 @@ dimension measured side by side.
|
||||
dragged axis slider (one undo step on release, Escape cancels). Visual QA: list markers keep the
|
||||
paragraph's font, renames start with the name selected and centred, Max lines shows while
|
||||
truncating. *Left:* list markers on the right of right-to-left paragraphs. 1 h.
|
||||
3. **Auto layout depth**: *landed:* on-canvas padding/gap handles (also wrapped-row and grid
|
||||
column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, hugging text
|
||||
wraps at its max), ignore auto layout (absolute position) with constraints, text baseline
|
||||
alignment, grid tracks (fixed/hug/`fr`), grid cells and spans, wrap row gap UI, wrap-aware
|
||||
drag-reorder (by row) and dragging into a grid cell, nested hug/fill settling in one pass
|
||||
(wrapping fill text, three levels deep, grid rows), min/max redistribution, coherent
|
||||
3. **Auto layout and sections depth**: *landed:* on-canvas padding/gap handles (also wrapped-row
|
||||
and grid column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed,
|
||||
hugging text wraps at its max), ignore auto layout (absolute position) with constraints, text
|
||||
baseline alignment, grid tracks (fixed/hug/`fr`), grid cells and spans, wrap row gap UI,
|
||||
wrap-aware drag-reorder (by row) and dragging into a grid cell, nested hug/fill settling in one
|
||||
pass (wrapping fill text, three levels deep, grid rows), min/max redistribution, coherent
|
||||
Fill/Hug rules, drag across nested auto layout frames (deepest one wins, insertion line, out to
|
||||
the canvas or a plain frame). *Left:* visual QA of nested edge cases, arrow-key moves across
|
||||
nested frames. 2–4 h.
|
||||
the canvas or a plain frame). Arrow keys move auto layout children one place (along the flow
|
||||
past hidden and absolute siblings, Up/Down between wrapped rows, to the neighbouring grid cell
|
||||
(automatically placed children swap, anchored ones take or swap cells), and at the end of a
|
||||
flow or across a row's axis into the neighbouring nested auto layout frame when the frame
|
||||
around flows that way; one undo step, none when nothing can move). Visual QA of nested
|
||||
hug/fill/fixed/min/max/wrap/grid samples against the documented rules (Fill turns a hugging
|
||||
parent Fixed, min/max redistribution, wrap inside fill, absolute children following
|
||||
constraints as hug frames grow). Sections: hide contents (name, outline and "Contents hidden"
|
||||
on the canvas; contents skipped by rendering, hit testing and labels; dimmed in the layers
|
||||
panel; Design panel checkbox and context menu), Wrap in new section, frames in sections get
|
||||
canvas labels and are top-level frames (flows start there, navigation, Export frames to PDF),
|
||||
"Navigate to" a section shows its frame shown last, else its first; ready for dev turns
|
||||
"Changed" when the contents are edited, resolved with a reason. *Left:* whether `fr` rows of a
|
||||
fixed-height grid should make room for fixed-size children (now `minmax(0, 1fr)`-like),
|
||||
section-level comparisons of changes. 1–2 h.
|
||||
4. **Components depth**: *landed:* component properties (edit/delete/bind) with the properties
|
||||
panel, add variant, instance swap UI, per-layer overrides with reset, restore main, instance
|
||||
structure protection; Assets panel with live thumbnails, search and drag-to-canvas (drops into
|
||||
@@ -181,7 +194,8 @@ dimension measured side by side.
|
||||
|
||||
- **Next:** the fidelity pass (7) and hardening (9: a real-file corpus, platform QA); effects:
|
||||
inner shadows under strokes (render order), progressive blur, glass tuned side by side; the last
|
||||
text details (right-to-left list markers).
|
||||
text details (right-to-left list markers) and auto layout details (grid `fr` rows around
|
||||
fixed-size children).
|
||||
- **Then:** interop depth (6: .fig best effort), plugins and multiplayer (8; comments and version
|
||||
history are in).
|
||||
- **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs.
|
||||
@@ -247,8 +261,8 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
||||
| Paint | 7 | 78% | a wide-gamut canvas (egui draws sRGB, so Display P3 colours outside sRGB clip on screen; exports keep them), images' own embedded colour profiles (images are taken as sRGB) | 1–3 |
|
||||
| Strokes | 3 | 82% | stroke styles | 1–2 |
|
||||
| Effects | 4 | 80% | inner shadows under strokes, progressive blur, noise and texture in SVG, glass tuned side by side | 3–5 |
|
||||
| Frames, groups, sections, masks | 5 | 75% | hiding section contents UI, section-level prototype/dev affordances | 2–4 |
|
||||
| Auto layout & constraints | 9 | 72% | visual QA of nested edge cases, arrow-key moves across nested auto layout frames | 2–4 |
|
||||
| Frames, groups, sections, masks | 5 | 80% | comparing what changed in ready-for-dev frames and sections, visual QA | 1–2 |
|
||||
| Auto layout & constraints | 9 | 76% | grid `fr` rows around fixed-size children, QA with real-world files | 1–2 |
|
||||
| Text | 8 | 78% | list markers on the right of right-to-left paragraphs, visual QA | 1–2 |
|
||||
| Components & variants | 9 | 76% | QA with larger real-world libraries | 1 |
|
||||
| Styles & variables | 5 | 86% | grid and component property bindings, reordering paints and effects in the panel | 1–2 |
|
||||
@@ -257,7 +271,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
||||
| Dev mode / inspect | 2 | 78% | component code with properties as parameters, visual QA | 2–3 |
|
||||
| Comments, versions, misc | 3 | 55% | plugins (WASM), multiplayer | 8–14 |
|
||||
|
||||
Weighted feature depth ≈ **76%** (self-graded, from the table).
|
||||
Weighted feature depth ≈ **77%** (self-graded, from the table).
|
||||
|
||||
## Milestones
|
||||
|
||||
@@ -266,7 +280,7 @@ Weighted feature depth ≈ **76%** (self-graded, from the table).
|
||||
| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) |
|
||||
| M0.9 CLI + MCP, web build, release packaging, PDF export | CLI, MCP, web build and PDF export done; release packaging in progress |
|
||||
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~70% |
|
||||
| M2 Layout depth | ~72% |
|
||||
| M2 Layout depth | ~76% |
|
||||
| M3 Components & styles depth | ~70% |
|
||||
| M4 Vector & text depth | ~66% |
|
||||
| M5 Prototype & present | ~82% |
|
||||
|
||||
+43
-11
@@ -609,29 +609,61 @@ impl Document {
|
||||
self.ancestors(id).into_iter().find(|a| self.node(*a).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })))
|
||||
}
|
||||
|
||||
/// The top-level frame containing `id` (or `id` if it is one).
|
||||
/// The top-level frame containing `id` (or `id` if it is one): the outermost frame, on the
|
||||
/// page or in a section (sections nest and hold top-level frames).
|
||||
pub fn top_level_frame(&self, id: NodeId) -> Option<NodeId> {
|
||||
let mut chain = vec![id];
|
||||
chain.extend(self.ancestors(id));
|
||||
// chain ends with the page; the element before it is the top-level node.
|
||||
// chain ends with the page; below it, past any sections, is the top-level node.
|
||||
let page_pos = chain.iter().position(|c| self.node(*c).is_some_and(Node::is_page))?;
|
||||
let top = *chain.get(page_pos.checked_sub(1)?)?;
|
||||
let top = chain.iter().take(page_pos).rev().copied().find(|c| !self.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. })))?;
|
||||
self.node(top).filter(|n| n.kind.is_frame_like()).map(|_| top)
|
||||
}
|
||||
|
||||
/// Top-level frames of a page in reading order (rows top to bottom, then left to right).
|
||||
/// Top-level frames of a page (also those in sections) in reading order (rows top to bottom,
|
||||
/// then left to right).
|
||||
pub fn top_frames(&self, page: NodeId) -> Vec<NodeId> {
|
||||
let Some(p) = self.node(page) else { return vec![] };
|
||||
let mut v: Vec<(NodeId, Rect)> = p
|
||||
.children
|
||||
.iter()
|
||||
.filter(|c| self.node(**c).is_some_and(|n| n.kind.is_frame_like()))
|
||||
.filter_map(|c| self.world_bounds(*c).map(|b| (*c, b)))
|
||||
.collect();
|
||||
self.frames_in(page)
|
||||
}
|
||||
|
||||
/// The top-level frames inside page or section `container` (and its sections, at any depth), in
|
||||
/// reading order.
|
||||
pub fn frames_in(&self, container: NodeId) -> Vec<NodeId> {
|
||||
fn walk(doc: &Document, id: NodeId, depth: usize, out: &mut Vec<(NodeId, Rect)>) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
for c in &n.children {
|
||||
let Some(cn) = doc.node(*c) else { continue };
|
||||
if cn.kind.is_frame_like() {
|
||||
if let Some(b) = doc.world_bounds(*c) {
|
||||
out.push((*c, b));
|
||||
}
|
||||
} else if matches!(cn.kind, NodeKind::Section { .. }) && depth < 64 {
|
||||
walk(doc, *c, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
walk(self, container, 0, &mut v);
|
||||
v.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0).then(a.1.x0.total_cmp(&b.1.x0)));
|
||||
v.into_iter().map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// The sections on page `page` (nested ones after their section), in layer order.
|
||||
pub fn sections_in(&self, page: NodeId) -> Vec<NodeId> {
|
||||
fn walk(doc: &Document, id: NodeId, depth: usize, out: &mut Vec<NodeId>) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
for c in &n.children {
|
||||
if doc.node(*c).is_some_and(|cn| matches!(cn.kind, NodeKind::Section { .. })) && depth < 64 {
|
||||
out.push(*c);
|
||||
walk(doc, *c, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
walk(self, page, 0, &mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// A unique name like "Frame 3" for a new node of a kind.
|
||||
pub fn next_name(&self, base: &str) -> String {
|
||||
let mut max = 0u64;
|
||||
|
||||
@@ -398,6 +398,13 @@ pub struct Node {
|
||||
/// Handoff status of a frame or section (shown as a badge next to its name).
|
||||
#[serde(default, skip_serializing_if = "DevStatus::is_none")]
|
||||
pub dev_status: DevStatus,
|
||||
/// Ready for dev, but its contents changed since it was marked (shown as "Changed" until the
|
||||
/// change is resolved or the layer is marked again).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub dev_changed: bool,
|
||||
/// Why the last change to a ready-for-dev layer was made (entered when resolving it).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub dev_note: String,
|
||||
/// Inside an instance: property groups overridden locally ("fills", "characters", …; "slot":
|
||||
/// this slot holds the instance's own content).
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
@@ -511,12 +518,24 @@ impl Node {
|
||||
exposed: false,
|
||||
|
||||
dev_status: DevStatus::None,
|
||||
dev_changed: false,
|
||||
dev_note: String::new(),
|
||||
overrides: BTreeSet::new(),
|
||||
prop_refs: BTreeMap::new(),
|
||||
plugin_data: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A new section with the look sections are created with (white, a light grey border, slightly
|
||||
/// rounded, contents unclipped).
|
||||
pub fn new_section(id: NodeId, name: impl Into<String>, width: f64, height: f64) -> Self {
|
||||
let mut n = Self::new(id, NodeKind::Section { contents_hidden: false }, name, width, height);
|
||||
n.strokes = vec![Paint::solid(Color::from_rgb8_const(0xE6, 0xE6, 0xE6))];
|
||||
n.corners.radii = [2.0; 4];
|
||||
n.clip = false;
|
||||
n
|
||||
}
|
||||
|
||||
pub fn size(&self) -> (f64, f64) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
@@ -529,6 +548,31 @@ impl Node {
|
||||
matches!(self.kind, NodeKind::Page { .. })
|
||||
}
|
||||
|
||||
/// A section whose contents are hidden: only its name and outline show; its children are
|
||||
/// skipped when rendering and hit testing.
|
||||
pub fn hides_contents(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Section { contents_hidden: true })
|
||||
}
|
||||
|
||||
/// Whether `self` and `other` differ only in handoff status and whether a section's contents
|
||||
/// are hidden (neither counts as a change to the design).
|
||||
pub fn same_design(&self, other: &Node) -> bool {
|
||||
if self == other {
|
||||
return true;
|
||||
}
|
||||
let plain = |n: &Node| {
|
||||
let mut n = n.clone();
|
||||
n.dev_status = DevStatus::None;
|
||||
n.dev_changed = false;
|
||||
n.dev_note.clear();
|
||||
if let NodeKind::Section { contents_hidden } = &mut n.kind {
|
||||
*contents_hidden = false;
|
||||
}
|
||||
n
|
||||
};
|
||||
plain(self) == plain(other)
|
||||
}
|
||||
|
||||
/// The ruler guides on this page or frame, in its own coordinates.
|
||||
pub fn guides(&self) -> &[Guide] {
|
||||
match &self.kind {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Arrow keys on auto layout children (`transform.nudge`): instead of nudging, a child placed by
|
||||
//! its auto layout frame moves one place.
|
||||
//!
|
||||
//! - **Along the flow** (Left/Right in a row, Up/Down in a column) it swaps with the next placed
|
||||
//! sibling; selected neighbours move together. Hidden siblings and those ignoring auto layout
|
||||
//! keep their places in the layer order.
|
||||
//! - **Wrapping rows:** Left/Right move along the flow, Up/Down to the previous/next visual row,
|
||||
//! next to the child nearest above/below it.
|
||||
//! - **Grids:** each arrow moves the child to the neighbouring cell. Two automatically placed
|
||||
//! children swap places in the flow; otherwise the child takes the cell (an anchored child there
|
||||
//! gets its old cell, automatically placed ones flow around it).
|
||||
//! - **Across nested auto layout frames:** when the child can't move any further in its own frame
|
||||
//! that way (at the end of the flow, across a row's axis, past a grid's edge), and that frame sits
|
||||
//! in an auto layout frame flowing that way, the child moves into the neighbouring auto layout
|
||||
//! frame there: at its near end when both flow the same way, else at the slot nearest to the
|
||||
//! child (a list of rows: Down moves an item into the row below, under where it was).
|
||||
//!
|
||||
//! Children ignoring auto layout, and children of frames without it, nudge as usual (the caller
|
||||
//! handles those).
|
||||
|
||||
use figcraft_doc::{AutoLayout, Document, LayoutMode, NodeId};
|
||||
use figcraft_geom::Point;
|
||||
use figcraft_layout::{GridCell, flow_children, grid_place};
|
||||
|
||||
use crate::Result;
|
||||
use crate::components::structure_locked;
|
||||
use crate::drop::{auto_layout_slot, wrap_rows};
|
||||
|
||||
/// One arrow key: an axis and a direction (`forward`: right or down).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Dir {
|
||||
pub horizontal: bool,
|
||||
pub forward: bool,
|
||||
}
|
||||
|
||||
impl Dir {
|
||||
/// The arrows in a nudge of (`ux`, `uy`) units: horizontal first, then vertical.
|
||||
pub fn from_units(ux: f64, uy: f64) -> Vec<Dir> {
|
||||
let mut out = Vec::new();
|
||||
if ux.is_finite() && ux != 0.0 {
|
||||
out.push(Dir { horizontal: true, forward: ux > 0.0 });
|
||||
}
|
||||
if uy.is_finite() && uy != 0.0 {
|
||||
out.push(Dir { horizontal: false, forward: uy > 0.0 });
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The auto layout frame that places `id` (`None` for layers ignoring auto layout, hidden ones and
|
||||
/// layers of plain frames).
|
||||
pub fn flowing_parent(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let n = doc.node(id)?;
|
||||
let parent = n.parent?;
|
||||
let pn = doc.node(parent)?;
|
||||
(pn.layout.is_some() && pn.kind.is_frame_like() && n.visible && !n.child_layout.absolute).then_some(parent)
|
||||
}
|
||||
|
||||
/// Move the `selected` children of auto layout frame `parent` one place in `dir`. Returns whether
|
||||
/// anything moved.
|
||||
pub fn arrow_move(doc: &mut Document, parent: NodeId, selected: &[NodeId], dir: Dir) -> Result<bool> {
|
||||
let Some(layout) = doc.node(parent).and_then(|n| n.layout.clone()) else { return Ok(false) };
|
||||
let flow = flow_children(doc, parent);
|
||||
let block: Vec<NodeId> = flow.iter().copied().filter(|c| selected.contains(c)).collect();
|
||||
if block.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let moved = match layout.mode {
|
||||
LayoutMode::Grid => grid_move(doc, parent, &layout, &block, dir)?,
|
||||
mode => {
|
||||
let along = (mode == LayoutMode::Horizontal) == dir.horizontal;
|
||||
if along {
|
||||
step_in_flow(doc, parent, &flow, &block, dir.forward)?
|
||||
} else if layout.wrap && mode == LayoutMode::Horizontal {
|
||||
wrap_row_move(doc, parent, &flow, &block, dir.forward)?
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
if moved {
|
||||
return Ok(true);
|
||||
}
|
||||
move_into_neighbour(doc, parent, &block, dir)
|
||||
}
|
||||
|
||||
/// Write a new order of `parent`'s placed children back: the slots placed children held in the
|
||||
/// child list take `flow` in order; the other children keep theirs.
|
||||
fn write_flow(doc: &mut Document, parent: NodeId, flow: &[NodeId]) -> Result<()> {
|
||||
let p = doc.get_mut(parent)?;
|
||||
let mut next = flow.iter().copied();
|
||||
let placed: Vec<NodeId> = flow.to_vec();
|
||||
for c in p.children.iter_mut() {
|
||||
if placed.contains(c)
|
||||
&& let Some(n) = next.next()
|
||||
{
|
||||
*c = n;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Swap each selected child with the next unselected placed sibling that way (selected neighbours
|
||||
/// move as a block). Returns whether anything moved.
|
||||
fn step_in_flow(doc: &mut Document, parent: NodeId, flow: &[NodeId], block: &[NodeId], forward: bool) -> Result<bool> {
|
||||
let mut order = flow.to_vec();
|
||||
let idx: Vec<usize> = if forward { (0..order.len()).rev().collect() } else { (0..order.len()).collect() };
|
||||
for i in idx {
|
||||
let Some(j) = (if forward { i.checked_add(1) } else { i.checked_sub(1) }) else { continue };
|
||||
let (Some(a), Some(b)) = (order.get(i).copied(), order.get(j).copied()) else { continue };
|
||||
if block.contains(&a) && !block.contains(&b) {
|
||||
order.swap(i, j);
|
||||
}
|
||||
}
|
||||
if order == flow {
|
||||
return Ok(false);
|
||||
}
|
||||
write_flow(doc, parent, &order)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Up/Down in a wrapping row layout: into the previous/next visual row, next to the child nearest
|
||||
/// the selection's centre (before it going up, after it going down, so the selection lands under
|
||||
/// or over where it was once the rows reflow). Returns whether anything moved.
|
||||
fn wrap_row_move(doc: &mut Document, parent: NodeId, flow: &[NodeId], block: &[NodeId], forward: bool) -> Result<bool> {
|
||||
let rows = wrap_rows(doc, flow);
|
||||
let lead = if forward { block.last() } else { block.first() };
|
||||
let Some(lead_i) = lead.and_then(|l| flow.iter().position(|c| c == l)) else { return Ok(false) };
|
||||
let Some(row) = rows.iter().position(|r| r.iter().any(|(i, _)| *i == lead_i)) else { return Ok(false) };
|
||||
let target_row = if forward { row.checked_add(1) } else { row.checked_sub(1) };
|
||||
let Some(target) = target_row.and_then(|r| rows.get(r)) else { return Ok(false) };
|
||||
let Some(centre) = doc.bounds_of(block).map(|b| b.center().x) else { return Ok(false) };
|
||||
let nearest = target
|
||||
.iter()
|
||||
.filter(|(i, _)| flow.get(*i).is_some_and(|c| !block.contains(c)))
|
||||
.min_by(|a, b| (a.1.center().x - centre).abs().total_cmp(&(b.1.center().x - centre).abs()))
|
||||
.and_then(|(i, _)| flow.get(*i).copied());
|
||||
let Some(nearest) = nearest else { return Ok(false) };
|
||||
let mut order: Vec<NodeId> = flow.iter().copied().filter(|c| !block.contains(c)).collect();
|
||||
let Some(at) = order.iter().position(|c| *c == nearest) else { return Ok(false) };
|
||||
let at = if forward { at + 1 } else { at };
|
||||
for (k, c) in block.iter().enumerate() {
|
||||
order.insert((at + k).min(order.len()), *c);
|
||||
}
|
||||
write_flow(doc, parent, &order)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Whether grid cell `c` covers column `col`, row `row`.
|
||||
fn covers(c: &GridCell, col: usize, row: usize) -> bool {
|
||||
(c.column..c.column + c.column_span).contains(&col) && (c.row..c.row + c.row_span).contains(&row)
|
||||
}
|
||||
|
||||
/// An arrow in a grid: each selected child moves to the neighbouring cell. Returns whether anything
|
||||
/// moved (`false` at the grid's edge).
|
||||
fn grid_move(doc: &mut Document, parent: NodeId, layout: &AutoLayout, block: &[NodeId], dir: Dir) -> Result<bool> {
|
||||
let cols = (layout.grid_columns as usize).max(1);
|
||||
let mut moved = false;
|
||||
// The leading child first, so the ones behind it follow into the cells it frees.
|
||||
let order: Vec<NodeId> = if dir.forward { block.iter().rev().copied().collect() } else { block.to_vec() };
|
||||
for id in order {
|
||||
let flow = flow_children(doc, parent);
|
||||
let (cells, rows) = grid_place(doc, layout, &flow);
|
||||
let Some(cell) = cells.iter().find(|c| c.id == id).copied() else { continue };
|
||||
let (dc, dr): (isize, isize) = match (dir.horizontal, dir.forward) {
|
||||
(true, true) => (1, 0),
|
||||
(true, false) => (-1, 0),
|
||||
(false, true) => (0, 1),
|
||||
(false, false) => (0, -1),
|
||||
};
|
||||
let (Some(tc), Some(tr)) = (cell.column.checked_add_signed(dc), cell.row.checked_add_signed(dr)) else { continue };
|
||||
if tc + cell.column_span > cols || tr + cell.row_span > rows {
|
||||
continue;
|
||||
}
|
||||
// The cell the child moves into first.
|
||||
let lead = (if dc > 0 { tc + cell.column_span - 1 } else { tc }, if dr > 0 { tr + cell.row_span - 1 } else { tr });
|
||||
let occupant = cells.iter().find(|c| c.id != id && covers(c, lead.0, lead.1)).copied();
|
||||
let anchored =
|
||||
|doc: &Document, i: NodeId| doc.node(i).is_some_and(|n| n.child_layout.grid_column.is_some() || n.child_layout.grid_row.is_some());
|
||||
match occupant {
|
||||
Some(o) if !anchored(doc, id) && !anchored(doc, o.id) => {
|
||||
// Both flow automatically: swap places in the flow.
|
||||
let mut order: Vec<NodeId> = flow.iter().copied().filter(|c| *c != id).collect();
|
||||
let Some(at) = order.iter().position(|c| *c == o.id) else { continue };
|
||||
order.insert(if dir.forward { at + 1 } else { at }, id);
|
||||
write_flow(doc, parent, &order)?;
|
||||
}
|
||||
_ => {
|
||||
let to_u32 = |v: usize| u32::try_from(v).unwrap_or(u32::MAX);
|
||||
if let Some(o) = occupant.filter(|o| anchored(doc, o.id)) {
|
||||
let cl = &mut doc.get_mut(o.id)?.child_layout;
|
||||
cl.grid_column = Some(to_u32(cell.column));
|
||||
cl.grid_row = Some(to_u32(cell.row));
|
||||
}
|
||||
let cl = &mut doc.get_mut(id)?.child_layout;
|
||||
cl.grid_column = Some(to_u32(tc));
|
||||
cl.grid_row = Some(to_u32(tr));
|
||||
}
|
||||
}
|
||||
moved = true;
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Move `block` (children of `parent`) into the auto layout frame next to `parent` in `dir`, when
|
||||
/// `parent` is placed by an auto layout frame flowing that way. Returns whether anything moved.
|
||||
fn move_into_neighbour(doc: &mut Document, parent: NodeId, block: &[NodeId], dir: Dir) -> Result<bool> {
|
||||
let Some(outer) = flowing_parent(doc, parent) else { return Ok(false) };
|
||||
let Some(outer_layout) = doc.node(outer).and_then(|n| n.layout.clone()) else { return Ok(false) };
|
||||
let flows_that_way = match outer_layout.mode {
|
||||
LayoutMode::Horizontal => dir.horizontal,
|
||||
LayoutMode::Vertical => !dir.horizontal,
|
||||
LayoutMode::Grid => false,
|
||||
};
|
||||
if !flows_that_way {
|
||||
return Ok(false);
|
||||
}
|
||||
let siblings = flow_children(doc, outer);
|
||||
let Some(i) = siblings.iter().position(|c| *c == parent) else { return Ok(false) };
|
||||
let next = if dir.forward { i.checked_add(1) } else { i.checked_sub(1) };
|
||||
let Some(dest) = next.and_then(|j| siblings.get(j)).copied() else { return Ok(false) };
|
||||
let accepts =
|
||||
doc.node(dest).is_some_and(|n| n.kind.is_frame_like() && !n.locked && n.layout.as_ref().is_some_and(|l| l.mode != LayoutMode::Grid))
|
||||
&& !structure_locked(doc, dest);
|
||||
if !accepts {
|
||||
return Ok(false);
|
||||
}
|
||||
let (Some(b), Some(sel)) = (doc.world_bounds(dest), doc.bounds_of(block)) else { return Ok(false) };
|
||||
// Enter at the near edge, level with the selection on the other axis (just before its centre,
|
||||
// so a child level with it is displaced and the selection keeps its place).
|
||||
let edge = |lo: f64, hi: f64| if dir.forward { lo + (hi - lo).min(0.5) } else { hi - (hi - lo).min(0.5) };
|
||||
let level = |c: f64, lo: f64, hi: f64| (c - 0.5).clamp(lo, hi.max(lo));
|
||||
let probe = if dir.horizontal {
|
||||
Point::new(edge(b.x0, b.x1), level(sel.center().y, b.y0, b.y1))
|
||||
} else {
|
||||
Point::new(level(sel.center().x, b.x0, b.x1), edge(b.y0, b.y1))
|
||||
};
|
||||
let Some((index, _)) = auto_layout_slot(doc, dest, probe, block) else { return Ok(false) };
|
||||
for (k, c) in block.iter().enumerate() {
|
||||
doc.reparent(*c, dest, Some(index + k))?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Arrow keys on auto layout children: along the flow, wrapped rows, grid cells and nested frames.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, parent: NodeId, w: f64, h: f64) -> NodeId {
|
||||
id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": w, "height": h, "parent": parent})).unwrap())
|
||||
}
|
||||
|
||||
/// A fixed-size auto layout frame (`layout`: horizontal|vertical|grid), no padding, gap 10.
|
||||
fn al(s: &mut Session, parent: Option<NodeId>, layout: &str, w: f64, h: f64) -> NodeId {
|
||||
let mut p = json!({"x": 0, "y": 0, "width": w, "height": h});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
let f = id(s.execute("frame.create", &p).unwrap());
|
||||
set(s, f, json!({"layout": layout, "gap": 10, "crossGap": 10, "padding": 0}));
|
||||
f
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, node: NodeId, props: Value) {
|
||||
s.execute("node.set", &json!({"id": node, "props": props})).unwrap();
|
||||
}
|
||||
|
||||
fn kids(s: &Session, node: NodeId) -> Vec<NodeId> {
|
||||
s.doc().unwrap().node(node).unwrap().children.clone()
|
||||
}
|
||||
|
||||
fn arrow(s: &mut Session, ids: &[NodeId], dx: i32, dy: i32) {
|
||||
s.execute("transform.nudge", &json!({"ids": ids, "dx": dx, "dy": dy})).unwrap();
|
||||
}
|
||||
|
||||
fn parent(s: &Session, node: NodeId) -> NodeId {
|
||||
s.doc().unwrap().node(node).unwrap().parent.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn along_the_flow_skips_hidden_and_absolute_siblings() {
|
||||
let mut s = Session::new();
|
||||
let row = al(&mut s, None, "horizontal", 400.0, 40.0);
|
||||
let [a, hidden, b, free] = [0; 4].map(|_| rect(&mut s, row, 40.0, 20.0));
|
||||
set(&mut s, hidden, json!({"visible": false}));
|
||||
set(&mut s, free, json!({"absolute": true}));
|
||||
// Right swaps with the next placed sibling; the hidden and absolute layers keep their slots.
|
||||
arrow(&mut s, &[a], 1, 0);
|
||||
assert_eq!(kids(&s, row), vec![b, hidden, a, free]);
|
||||
// At the end of the flow there's nowhere to go.
|
||||
arrow(&mut s, &[a], 1, 0);
|
||||
assert_eq!(kids(&s, row), vec![b, hidden, a, free]);
|
||||
// Across the axis with no auto layout around: nothing either.
|
||||
arrow(&mut s, &[a], 0, 1);
|
||||
assert_eq!(kids(&s, row), vec![b, hidden, a, free]);
|
||||
// One undo step per arrow.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(kids(&s, row), vec![a, hidden, b, free]);
|
||||
// The absolute child nudges.
|
||||
let x = s.doc().unwrap().world_bounds(free).unwrap().x0;
|
||||
arrow(&mut s, &[free], 1, 0);
|
||||
assert_eq!(s.doc().unwrap().world_bounds(free).unwrap().x0, x + 1.0);
|
||||
assert_eq!(kids(&s, row), vec![a, hidden, b, free]);
|
||||
// A column moves on Up/Down.
|
||||
let col = al(&mut s, None, "vertical", 100.0, 300.0);
|
||||
let [c0, c1] = [0; 2].map(|_| rect(&mut s, col, 40.0, 20.0));
|
||||
arrow(&mut s, &[c1], 0, -1);
|
||||
assert_eq!(kids(&s, col), vec![c1, c0]);
|
||||
arrow(&mut s, &[c1], 1, 0);
|
||||
assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_rows_move_up_and_down_a_row() {
|
||||
let mut s = Session::new();
|
||||
// 40 px items, gap 10, 160 px wide: three per row.
|
||||
let row = al(&mut s, None, "horizontal", 160.0, 200.0);
|
||||
set(&mut s, row, json!({"wrap": true}));
|
||||
let k: Vec<NodeId> = (0..6).map(|_| rect(&mut s, row, 40.0, 20.0)).collect();
|
||||
let y = |s: &Session, n: NodeId| s.doc().unwrap().world_bounds(n).unwrap().y0;
|
||||
let x = |s: &Session, n: NodeId| s.doc().unwrap().world_bounds(n).unwrap().x0;
|
||||
assert_eq!(y(&s, k[3]), 30.0, "second row");
|
||||
// Down: the second item lands in the second row, under where it was.
|
||||
arrow(&mut s, &[k[1]], 0, 1);
|
||||
assert_eq!(kids(&s, row), vec![k[0], k[2], k[3], k[4], k[1], k[5]]);
|
||||
assert_eq!((x(&s, k[1]), y(&s, k[1])), (50.0, 30.0));
|
||||
// Down again: no third row.
|
||||
arrow(&mut s, &[k[1]], 0, 1);
|
||||
assert_eq!(kids(&s, row), vec![k[0], k[2], k[3], k[4], k[1], k[5]]);
|
||||
// Up: back to the first row over where it is.
|
||||
arrow(&mut s, &[k[1]], 0, -1);
|
||||
assert_eq!((x(&s, k[1]), y(&s, k[1])), (50.0, 0.0));
|
||||
// Left/Right still step along the flow, across the row break.
|
||||
let before = kids(&s, row);
|
||||
let third = before[2];
|
||||
arrow(&mut s, &[third], 1, 0);
|
||||
assert_eq!(y(&s, third), 30.0, "the last of a row steps into the next one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_arrows_move_between_cells() {
|
||||
let mut s = Session::new();
|
||||
let g = al(&mut s, None, "grid", 200.0, 200.0);
|
||||
set(&mut s, g, json!({"gridColumns": 2, "gridRows": 2}));
|
||||
let [a, b, c, d] = [0; 4].map(|_| rect(&mut s, g, 20.0, 20.0));
|
||||
// Automatically placed children swap places in the flow.
|
||||
arrow(&mut s, &[a], 1, 0);
|
||||
assert_eq!(kids(&s, g), vec![b, a, c, d]);
|
||||
s.undo().unwrap();
|
||||
arrow(&mut s, &[a], 0, 1);
|
||||
assert_eq!(kids(&s, g), vec![b, c, a, d], "Down: the cell below (A lands at column 0, row 1)");
|
||||
let cell = |s: &Session, n: NodeId| {
|
||||
let doc = s.doc().unwrap();
|
||||
let (cells, _) = figcraft_layout::grid_place(doc, doc.node(g).unwrap().layout.as_ref().unwrap(), &kids(s, g));
|
||||
cells.iter().find(|x| x.id == n).map(|x| (x.column, x.row)).unwrap()
|
||||
};
|
||||
assert_eq!(cell(&s, a), (0, 1));
|
||||
// Past the grid's edge: nothing.
|
||||
arrow(&mut s, &[a], -1, 0);
|
||||
arrow(&mut s, &[a], 0, 1);
|
||||
assert_eq!(cell(&s, a), (0, 1));
|
||||
// Into an empty cell: the child is anchored there.
|
||||
s.execute("edit.delete", &json!({"ids": [d]})).unwrap();
|
||||
arrow(&mut s, &[a], 1, 0);
|
||||
assert_eq!(cell(&s, a), (1, 1));
|
||||
let cl = s.doc().unwrap().node(a).unwrap().child_layout.clone();
|
||||
assert_eq!((cl.grid_column, cl.grid_row), (Some(1), Some(1)));
|
||||
// Anchored children swap cells.
|
||||
set(&mut s, b, json!({"gridColumn": 0, "gridRow": 0}));
|
||||
set(&mut s, c, json!({"gridColumn": 1, "gridRow": 0}));
|
||||
arrow(&mut s, &[b], 1, 0);
|
||||
assert_eq!((cell(&s, b), cell(&s, c)), ((1, 0), (0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_cross_into_neighbouring_nested_frames() {
|
||||
let mut s = Session::new();
|
||||
// A column of two rows of three items.
|
||||
let list = al(&mut s, None, "vertical", 300.0, 200.0);
|
||||
let r1 = al(&mut s, Some(list), "horizontal", 200.0, 40.0);
|
||||
let r2 = al(&mut s, Some(list), "horizontal", 200.0, 40.0);
|
||||
let top: Vec<NodeId> = (0..3).map(|_| rect(&mut s, r1, 40.0, 20.0)).collect();
|
||||
let bottom: Vec<NodeId> = (0..3).map(|_| rect(&mut s, r2, 40.0, 20.0)).collect();
|
||||
// Down from the middle of the top row: into the bottom row, at the same place.
|
||||
arrow(&mut s, &[top[1]], 0, 1);
|
||||
assert_eq!(parent(&s, top[1]), r2);
|
||||
assert_eq!(kids(&s, r2), vec![bottom[0], top[1], bottom[1], bottom[2]]);
|
||||
// One undo step.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(kids(&s, r1), top);
|
||||
assert_eq!(kids(&s, r2), bottom);
|
||||
// Up from the top row: no row above.
|
||||
arrow(&mut s, &[top[1]], 0, -1);
|
||||
assert_eq!(kids(&s, r1), top);
|
||||
// Right at the end of a row in a column: the column doesn't flow sideways.
|
||||
arrow(&mut s, &[top[2]], 1, 0);
|
||||
assert_eq!(kids(&s, r1), top);
|
||||
// Up from the bottom row's first item: the top row's start.
|
||||
arrow(&mut s, &[bottom[0]], 0, -1);
|
||||
assert_eq!(kids(&s, r1), vec![bottom[0], top[0], top[1], top[2]]);
|
||||
|
||||
// A row of two rows: Right at the end of the first enters the second at its start, Left back.
|
||||
let outer = al(&mut s, None, "horizontal", 600.0, 60.0);
|
||||
let a = al(&mut s, Some(outer), "horizontal", 150.0, 40.0);
|
||||
let b = al(&mut s, Some(outer), "horizontal", 150.0, 40.0);
|
||||
let ak: Vec<NodeId> = (0..2).map(|_| rect(&mut s, a, 20.0, 20.0)).collect();
|
||||
let bk: Vec<NodeId> = (0..2).map(|_| rect(&mut s, b, 20.0, 20.0)).collect();
|
||||
arrow(&mut s, &[ak[1]], 1, 0);
|
||||
assert_eq!(kids(&s, b), vec![ak[1], bk[0], bk[1]]);
|
||||
arrow(&mut s, &[ak[1]], -1, 0);
|
||||
assert_eq!(kids(&s, a), vec![ak[0], ak[1]], "back at the end of the first row");
|
||||
// A neighbour without auto layout, or an instance, takes nothing.
|
||||
set(&mut s, b, json!({"layout": "none"}));
|
||||
arrow(&mut s, &[ak[1]], 1, 0);
|
||||
assert_eq!(parent(&s, ak[1]), a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_frames_and_instances_keep_their_rules() {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap());
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20, "parent": f})).unwrap());
|
||||
s.execute("transform.nudge", &json!({"ids": [r], "dx": 1, "dy": -1, "big": true})).unwrap();
|
||||
let b = s.doc().unwrap().world_bounds(r).unwrap();
|
||||
assert_eq!((b.x0, b.y0), (20.0, 0.0));
|
||||
// An instance's auto layout children can't be reordered (only its main's can).
|
||||
let main = al(&mut s, None, "horizontal", 200.0, 40.0);
|
||||
for _ in 0..2 {
|
||||
rect(&mut s, main, 20.0, 20.0);
|
||||
}
|
||||
let comp = id(s.execute("component.create", &json!({"ids": [main]})).unwrap());
|
||||
let inst = id(s.execute("instance.create", &json!({"component": comp, "x": 0, "y": 300})).unwrap());
|
||||
let first = kids(&s, inst)[0];
|
||||
assert!(s.execute("transform.nudge", &json!({"ids": [first], "dx": 1, "dy": 0})).is_err());
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Creating nodes.
|
||||
|
||||
use figcraft_color::{Color, ImageScaleMode, Paint};
|
||||
use figcraft_color::{ImageScaleMode, Paint};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
@@ -232,17 +232,11 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ds = s.ds_mut()?;
|
||||
let doc = ds.doc_mut();
|
||||
let nid = doc.alloc_id();
|
||||
let mut node = Node::new(nid, kind, name, w, h);
|
||||
let mut node = if base == "Section" { Node::new_section(nid, name, w, h) } else { Node::new(nid, kind, name, w, h) };
|
||||
node.transform = figcraft_geom::Affine::translate((x + offset.0, y + offset.1));
|
||||
if base == "Arrow" {
|
||||
node.stroke.cap_end = StrokeCap::LineArrow;
|
||||
}
|
||||
if base == "Section" {
|
||||
node.fills = vec![Paint::solid(Color::WHITE)];
|
||||
node.strokes = vec![Paint::solid(Color::from_rgb8_const(0xE6, 0xE6, 0xE6))];
|
||||
node.corners.radii = [2.0; 4];
|
||||
node.clip = false;
|
||||
}
|
||||
if let NodeKind::Text(_) = node.kind
|
||||
&& let Some((tw, th)) = figcraft_text::auto_size(&node)
|
||||
{
|
||||
|
||||
@@ -127,7 +127,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"transform.nudge",
|
||||
"Nudge",
|
||||
"{dx, dy, big? (use the big nudge amount)} (auto layout children move one place along the flow instead)",
|
||||
"{dx, dy, big? (use the big nudge amount)} (auto layout children move one place instead: along the flow, between wrapped rows, to the next grid cell, or into the neighbouring nested auto layout frame)",
|
||||
true,
|
||||
nudge
|
||||
),
|
||||
@@ -762,70 +762,38 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (ux, uy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0));
|
||||
let (dx, dy) = (ux * k, uy * k);
|
||||
let ids = req_targets("transform.nudge", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
// Edit a copy and keep it only if something moved, so an arrow that can't move anything adds
|
||||
// no undo step.
|
||||
let mut work = s.doc()?.clone();
|
||||
let doc = &mut work;
|
||||
let ids = roots(doc, &ids);
|
||||
// Children in an auto layout flow move one place instead (Left/Up earlier, Right/Down later).
|
||||
let flowing = |doc: &Document, i: NodeId| {
|
||||
let n = doc.node(i)?;
|
||||
let parent = n.parent?;
|
||||
let l = doc.node(parent)?.layout.clone()?;
|
||||
(!n.child_layout.absolute).then_some((parent, l))
|
||||
};
|
||||
let mut parents: BTreeMap<NodeId, AutoLayout> = BTreeMap::new();
|
||||
let mut changed = false;
|
||||
// Children in an auto layout flow move one place instead (see `al_keys`).
|
||||
for i in &ids {
|
||||
match flowing(doc, *i) {
|
||||
Some((parent, l)) => {
|
||||
parents.insert(parent, l);
|
||||
}
|
||||
None => {
|
||||
let n = doc.get_mut(*i)?;
|
||||
n.transform = Affine::translate((dx, dy)) * n.transform;
|
||||
}
|
||||
if crate::al_keys::flowing_parent(doc, *i).is_none() && (dx, dy) != (0.0, 0.0) {
|
||||
let n = doc.get_mut(*i)?;
|
||||
n.transform = Affine::translate((dx, dy)) * n.transform;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (parent, l) in parents {
|
||||
// (`f64::signum` is 1 for 0.0, so compare explicitly.)
|
||||
let sign = |v: f64| -> isize {
|
||||
if v > 0.0 {
|
||||
1
|
||||
} else if v < 0.0 {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
for dir in crate::al_keys::Dir::from_units(ux, uy) {
|
||||
// Grouped per arrow: a move into a neighbouring frame changes the parent.
|
||||
let mut groups: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
|
||||
for i in &ids {
|
||||
if let Some(parent) = crate::al_keys::flowing_parent(doc, *i) {
|
||||
groups.entry(parent).or_default().push(*i);
|
||||
}
|
||||
};
|
||||
let step: isize = match l.mode {
|
||||
LayoutMode::Vertical => sign(uy),
|
||||
LayoutMode::Grid => sign(ux) + sign(uy) * l.grid_columns.clamp(1, 1000) as isize,
|
||||
_ => sign(ux),
|
||||
};
|
||||
reorder_in_flow(doc, parent, &ids, step)?;
|
||||
}
|
||||
for (parent, block) in groups {
|
||||
changed |= crate::al_keys::arrow_move(doc, parent, &block, dir)?;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.ds_mut()?.doc = std::sync::Arc::new(work);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Move the selected children of `parent` `step` places along its child order (selected
|
||||
/// neighbours move together; the block stops at either end).
|
||||
fn reorder_in_flow(doc: &mut Document, parent: NodeId, selected: &[NodeId], step: isize) -> Result<()> {
|
||||
if step == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut kids = doc.get(parent)?.children.clone();
|
||||
for _ in 0..step.unsigned_abs().min(kids.len()) {
|
||||
let forward = step > 0;
|
||||
let order: Vec<usize> = if forward { (0..kids.len()).rev().collect() } else { (0..kids.len()).collect() };
|
||||
for i in order {
|
||||
let j = if forward { i + 1 } else { i.wrapping_sub(1) };
|
||||
let (Some(a), Some(b)) = (kids.get(i).copied(), kids.get(j).copied()) else { continue };
|
||||
if selected.contains(&a) && !selected.contains(&b) {
|
||||
kids.swap(i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.get_mut(parent)?.children = kids;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nudge_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let big = boolean(p, "big").unwrap_or(false);
|
||||
let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small };
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod library;
|
||||
pub mod page;
|
||||
pub mod props;
|
||||
pub mod proto;
|
||||
pub mod section;
|
||||
pub mod structure;
|
||||
pub mod styles;
|
||||
pub mod text;
|
||||
@@ -68,6 +69,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(edit::specs());
|
||||
list.extend(arrange::specs());
|
||||
list.extend(structure::specs());
|
||||
list.extend(section::specs());
|
||||
list.extend(layout::specs());
|
||||
list.extend(file::specs());
|
||||
list.extend(page::specs());
|
||||
|
||||
@@ -320,7 +320,10 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
} else {
|
||||
v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s)))
|
||||
};
|
||||
doc.get_mut(id)?.dev_status = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
|
||||
let n = doc.get_mut(id)?;
|
||||
n.dev_status = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
|
||||
// Marking a layer (again) starts from its current contents.
|
||||
n.dev_changed = false;
|
||||
}
|
||||
"maskType" => {
|
||||
let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s)));
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Sections and handoff: hide a section's contents, resolve a change to a ready-for-dev layer.
|
||||
//! (Wrapping layers in a new section is `object.wrapInSection`, with the other wrappers.)
|
||||
|
||||
use figcraft_doc::{DevStatus, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::*;
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Longest change reason kept.
|
||||
const MAX_NOTE: usize = 500;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"section.toggleContents",
|
||||
"Hide/Show section contents",
|
||||
"{ids?, hidden? (default: hide unless all are hidden)} (a hidden section shows only its name and outline; its layers aren't drawn or hit)",
|
||||
true,
|
||||
toggle_contents
|
||||
),
|
||||
spec!(
|
||||
"dev.resolveChange",
|
||||
"Resolve change",
|
||||
"{ids?, reason?} (ready-for-dev frames and sections that changed since they were marked go back to Ready for dev; the reason is kept)",
|
||||
true,
|
||||
resolve_change
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn toggle_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "section.toggleContents";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc()?;
|
||||
let sections: Vec<NodeId> = ids.iter().copied().filter(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))).collect();
|
||||
if sections.is_empty() {
|
||||
return Err(bad(cmd, "select a section"));
|
||||
}
|
||||
let hide = boolean(p, "hidden").unwrap_or_else(|| !sections.iter().all(|i| doc.node(*i).is_some_and(|n| n.hides_contents())));
|
||||
// Selected layers inside a section being hidden give way to the section.
|
||||
let sel: Vec<NodeId> = s.selection();
|
||||
let mut new_sel: Vec<NodeId> = Vec::new();
|
||||
for i in sel {
|
||||
let inside = if hide { sections.iter().copied().find(|sec| doc.is_ancestor(*sec, i)) } else { None };
|
||||
let keep = inside.unwrap_or(i);
|
||||
if !new_sel.contains(&keep) {
|
||||
new_sel.push(keep);
|
||||
}
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
for sec in §ions {
|
||||
if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(*sec)?.kind {
|
||||
*contents_hidden = hide;
|
||||
}
|
||||
}
|
||||
s.set_selection(new_sel);
|
||||
Ok(json!({"hidden": hide, "ids": sections}))
|
||||
}
|
||||
|
||||
fn resolve_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "dev.resolveChange";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let reason: String = string(p, "reason").unwrap_or_default().trim().chars().take(MAX_NOTE).collect();
|
||||
let doc = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
if n.dev_status == DevStatus::ReadyForDev && n.dev_changed {
|
||||
n.dev_changed = false;
|
||||
n.dev_note = reason.clone();
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad(cmd, "nothing selected has changed since it was marked ready for dev"));
|
||||
}
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
@@ -16,6 +16,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
|
||||
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
|
||||
spec!(
|
||||
"object.wrapInSection",
|
||||
"Wrap in new section",
|
||||
"{ids?, name?} (layers on the page or in a section; the section wraps them with some room)",
|
||||
true,
|
||||
|s, p| wrap(s, p, Wrap::Section)
|
||||
),
|
||||
spec!(
|
||||
"object.resizeToFit",
|
||||
"Resize to fit",
|
||||
@@ -89,9 +96,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
enum Wrap {
|
||||
Group,
|
||||
Frame,
|
||||
Section,
|
||||
Bool(BoolOp),
|
||||
}
|
||||
|
||||
/// Room a section made around layers leaves on each side.
|
||||
const SECTION_PAD: f64 = 40.0;
|
||||
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place.
|
||||
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets("object.group", s, p)?;
|
||||
@@ -99,18 +110,32 @@ fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
|
||||
// Sections sit on the page or in sections, never in frames or groups.
|
||||
let board = |doc: &Document, i: NodeId| {
|
||||
doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
|
||||
};
|
||||
if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) {
|
||||
return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section"));
|
||||
}
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let bounds = doc.bounds_of(&ids).unwrap_or_default();
|
||||
let mut bounds = doc.bounds_of(&ids).unwrap_or_default();
|
||||
if matches!(w, Wrap::Section) {
|
||||
bounds = bounds.inflate(SECTION_PAD, SECTION_PAD);
|
||||
}
|
||||
let pw = doc.world_transform(parent);
|
||||
let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds);
|
||||
let (kind, base) = match &w {
|
||||
Wrap::Group => (NodeKind::Group, "Group"),
|
||||
Wrap::Frame => (NodeKind::Frame, "Frame"),
|
||||
Wrap::Section => (NodeKind::Section { contents_hidden: false }, "Section"),
|
||||
Wrap::Bool(op) => (NodeKind::BooleanOp { op: *op }, op.label()),
|
||||
};
|
||||
let name = string(p, "name").unwrap_or_else(|| if matches!(w, Wrap::Bool(_)) { base.to_string() } else { doc.next_name(base) });
|
||||
let gid = doc.alloc_id();
|
||||
let mut g = Node::new(gid, kind, name, local.width(), local.height());
|
||||
let mut g = match w {
|
||||
Wrap::Section => Node::new_section(gid, name, local.width(), local.height()),
|
||||
_ => Node::new(gid, kind, name, local.width(), local.height()),
|
||||
};
|
||||
g.transform = Affine::translate((local.x0, local.y0));
|
||||
if let Wrap::Bool(_) = w {
|
||||
// A boolean group takes its look from the bottom-most shape.
|
||||
@@ -130,7 +155,7 @@ fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
|
||||
for i in &ids {
|
||||
doc.reparent(*i, gid, None)?;
|
||||
}
|
||||
if !matches!(w, Wrap::Frame) {
|
||||
if !matches!(w, Wrap::Frame | Wrap::Section) {
|
||||
let _ = doc.fit_to_children(gid);
|
||||
}
|
||||
s.set_selection(vec![gid]);
|
||||
|
||||
@@ -53,17 +53,23 @@ pub fn line_before(b: figcraft_geom::Rect, vertical: bool, off: f64) -> [f64; 4]
|
||||
if vertical { [b.x0, b.y0 - off, b.x1, b.y0 - off] } else { [b.x0 - off, b.y0, b.x0 - off, b.y1] }
|
||||
}
|
||||
|
||||
/// The slot in a wrapping row layout: the visual row under the pointer (children wrap in flow
|
||||
/// order, so a new row starts where a child sits left of the one before), then the position in it.
|
||||
fn wrap_slot(doc: &Document, kids: &[NodeId], p: Point) -> (usize, Option<[f64; 4]>) {
|
||||
let bounds: Vec<(usize, figcraft_geom::Rect)> = kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, b))).collect();
|
||||
/// The visual rows of a wrapping row layout's placed children `kids`, each child as (its index in
|
||||
/// `kids`, its page bounds). Children wrap in flow order, so a new row starts where a child sits
|
||||
/// left of the one before.
|
||||
pub fn wrap_rows(doc: &Document, kids: &[NodeId]) -> Vec<Vec<(usize, figcraft_geom::Rect)>> {
|
||||
let mut rows: Vec<Vec<(usize, figcraft_geom::Rect)>> = Vec::new();
|
||||
for (i, b) in bounds {
|
||||
for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, b))) {
|
||||
match rows.last_mut() {
|
||||
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)),
|
||||
_ => rows.push(vec![(i, b)]),
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The slot in a wrapping row layout: the visual row under the pointer, then the position in it.
|
||||
fn wrap_slot(doc: &Document, kids: &[NodeId], p: Point) -> (usize, Option<[f64; 4]>) {
|
||||
let rows = wrap_rows(doc, kids);
|
||||
let span =
|
||||
|row: &[(usize, figcraft_geom::Rect)]| row.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), (_, b)| (lo.min(b.y0), hi.max(b.y1)));
|
||||
// The row containing the pointer, else the nearest one.
|
||||
|
||||
@@ -108,6 +108,15 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
if !n.dev_status.is_none() {
|
||||
o.insert("devStatus".into(), json!(n.dev_status.id()));
|
||||
}
|
||||
if n.dev_changed {
|
||||
o.insert("devChanged".into(), json!(true));
|
||||
}
|
||||
if !n.dev_note.is_empty() {
|
||||
o.insert("devNote".into(), json!(n.dev_note));
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden } = n.kind {
|
||||
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
|
||||
}
|
||||
if n.is_mask {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
o.insert("maskType".into(), json!(n.mask_type.id()));
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! edits and are journaled the same way.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod al_keys;
|
||||
pub mod catalog;
|
||||
pub mod cmd;
|
||||
pub mod codegen;
|
||||
@@ -898,14 +899,47 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
for f in frames {
|
||||
figcraft_layout::layout_frame(doc, f, &m);
|
||||
}
|
||||
touched.extend(doc.take_dirty());
|
||||
// 5. Ready-for-dev layers whose contents changed show "Changed".
|
||||
mark_dev_changes(doc, before, &touched);
|
||||
doc.take_dirty();
|
||||
// 5. Comment pins follow their layers to other pages and stay put when the layers are deleted.
|
||||
cmd::comment::follow_anchors(doc, before);
|
||||
}
|
||||
|
||||
/// Flag the frames and sections marked ready for dev (before this edit) that contain a layer whose
|
||||
/// design `touched` changed (added, removed or edited; the handoff status itself and hiding a
|
||||
/// section's contents don't count).
|
||||
fn mark_dev_changes(doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) {
|
||||
let ready = |d: &Document, id: NodeId| d.node(id).is_some_and(|n| n.dev_status == figcraft_doc::DevStatus::ReadyForDev);
|
||||
let mut changed: BTreeSet<NodeId> = BTreeSet::new();
|
||||
for &id in touched {
|
||||
let differs = match (before.node(id), doc.node(id)) {
|
||||
(Some(a), Some(b)) => !a.same_design(b),
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
};
|
||||
if !differs {
|
||||
continue;
|
||||
}
|
||||
let mut chain = vec![id];
|
||||
chain.extend(if doc.contains(id) { doc.ancestors(id) } else { before.ancestors(id) });
|
||||
changed.extend(chain.into_iter().filter(|m| ready(before, *m) && ready(doc, *m)));
|
||||
}
|
||||
for m in changed {
|
||||
if let Some(n) = doc.node_mut(m)
|
||||
&& !n.dev_changed
|
||||
{
|
||||
n.dev_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod al_drag_tests;
|
||||
#[cfg(test)]
|
||||
mod al_keys_tests;
|
||||
#[cfg(test)]
|
||||
mod al_nesting_tests;
|
||||
#[cfg(test)]
|
||||
mod autosave_tests;
|
||||
@@ -950,6 +984,8 @@ mod rotated_select_tests;
|
||||
#[cfg(test)]
|
||||
mod scale_tool_tests;
|
||||
#[cfg(test)]
|
||||
mod section_contents_tests;
|
||||
#[cfg(test)]
|
||||
mod section_tests;
|
||||
#[cfg(test)]
|
||||
mod selection_depth_tests;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Prototype actions on variables, run by the prototype player on its own copy of the document:
|
||||
//! set variable (a value or an expression), set variable mode, and choosing the branch of a
|
||||
//! conditional action.
|
||||
//! Prototype actions run by the prototype player on its own copy of the document: set variable (a
|
||||
//! value or an expression), set variable mode, choosing the branch of a conditional action, and
|
||||
//! where navigating to a section goes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use figcraft_doc::proto::{Action, ConditionalBlock, Expr};
|
||||
use figcraft_doc::{Document, NodeId, VariableType, VariableValue};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, VariableType, VariableValue};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cmd::styles::apply_bindings;
|
||||
@@ -85,3 +87,23 @@ pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str)
|
||||
pub fn branch(doc: &Document, node: NodeId, blocks: &[ConditionalBlock]) -> Vec<Action> {
|
||||
ConditionalBlock::choose(blocks, &doc.variables, &doc.modes_for(node)).map(<[Action]>::to_vec).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The frame "Navigate to" `dest` shows: the top-level frame holding it; for a section, the frame
|
||||
/// in it shown last (`visited`: section → frame, see [`remember_sections`]), else its first frame
|
||||
/// in reading order. `None` when there's nothing to show.
|
||||
pub fn navigate_target(doc: &Document, dest: NodeId, visited: &HashMap<NodeId, NodeId>) -> Option<NodeId> {
|
||||
let n = doc.node(dest)?;
|
||||
if !matches!(n.kind, NodeKind::Section { .. }) {
|
||||
return Some(doc.top_level_frame(dest).unwrap_or(dest));
|
||||
}
|
||||
visited.get(&dest).copied().filter(|f| doc.contains(*f) && doc.is_ancestor(dest, *f)).or_else(|| doc.frames_in(dest).first().copied())
|
||||
}
|
||||
|
||||
/// Record `frame` as the frame shown last in each section around it.
|
||||
pub fn remember_sections(doc: &Document, frame: NodeId, visited: &mut HashMap<NodeId, NodeId>) {
|
||||
for a in doc.ancestors(frame) {
|
||||
if doc.node(a).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. })) {
|
||||
visited.insert(a, frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Sections: hiding their contents, wrapping layers in a new one, "Changed" after ready for dev,
|
||||
//! and frames in sections as prototype screens.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_geom::Point;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn section(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id(s.execute("node.create", &json!({"type": "section", "x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, node: NodeId) -> Value {
|
||||
s.execute("node.get", &json!({"id": node})).unwrap()
|
||||
}
|
||||
|
||||
/// The RGBA of `node` rendered at 1x, at (`x`, `y`) of its box.
|
||||
fn pixel(s: &Session, node: NodeId, x: u32, y: u32) -> [u8; 4] {
|
||||
let pix = figcraft_render::render_node(s.doc().unwrap(), node, 1.0).unwrap();
|
||||
let i = ((y * pix.width() + x) * 4) as usize;
|
||||
let d = pix.data();
|
||||
[d[i], d[i + 1], d[i + 2], d[i + 3]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_contents_skip_rendering_and_hit_testing() {
|
||||
let mut s = Session::new();
|
||||
let sec = section(&mut s, 0.0, 0.0, 200.0, 200.0);
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40, "parent": sec})).unwrap());
|
||||
s.execute("node.set", &json!({"id": r, "props": {"fills": ["#FF0000"]}})).unwrap();
|
||||
let page = s.page().unwrap();
|
||||
assert_eq!(s.doc().unwrap().hit_test(page, Point::new(60.0, 60.0), 0.0), Some(r));
|
||||
assert_eq!(pixel(&s, sec, 60, 60), [255, 0, 0, 255]);
|
||||
// Hiding: the selected child gives way to the section.
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let v = s.execute("section.toggleContents", &json!({"ids": [sec]})).unwrap();
|
||||
assert_eq!(v["hidden"], json!(true));
|
||||
assert_eq!(s.selection(), vec![sec]);
|
||||
assert_eq!(get(&mut s, sec)["sectionContentsHidden"], json!(true));
|
||||
assert_eq!(s.doc().unwrap().hit_test(page, Point::new(60.0, 60.0), 0.0), Some(sec));
|
||||
assert_eq!(pixel(&s, sec, 60, 60), [255, 255, 255, 255], "the child isn't drawn");
|
||||
// Toggling again shows them; explicit `hidden` sets.
|
||||
s.execute("section.toggleContents", &json!({"ids": [sec]})).unwrap();
|
||||
assert_eq!(pixel(&s, sec, 60, 60), [255, 0, 0, 255]);
|
||||
s.execute("section.toggleContents", &json!({"ids": [sec], "hidden": true})).unwrap();
|
||||
s.execute("section.toggleContents", &json!({"ids": [sec], "hidden": true})).unwrap();
|
||||
assert!(s.doc().unwrap().node(sec).unwrap().hides_contents());
|
||||
s.undo().unwrap();
|
||||
s.undo().unwrap();
|
||||
assert!(!s.doc().unwrap().node(sec).unwrap().hides_contents());
|
||||
// Only sections.
|
||||
assert!(s.execute("section.toggleContents", &json!({"ids": [r]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_in_new_section() {
|
||||
let mut s = Session::new();
|
||||
let a = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
let b = id(s.execute("frame.create", &json!({"x": 200, "y": 50, "width": 100, "height": 100})).unwrap());
|
||||
let before = (s.doc().unwrap().world_bounds(a).unwrap(), s.doc().unwrap().world_bounds(b).unwrap());
|
||||
let sec = id(s.execute("object.wrapInSection", &json!({"ids": [a, b]})).unwrap());
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(sec).unwrap().kind == figcraft_doc::NodeKind::Section { contents_hidden: false });
|
||||
assert_eq!(doc.node(sec).unwrap().children, vec![a, b]);
|
||||
let sb = doc.world_bounds(sec).unwrap();
|
||||
assert_eq!((sb.x0, sb.y0, sb.x1, sb.y1), (-40.0, -40.0, 340.0, 190.0), "some room around");
|
||||
assert_eq!((doc.world_bounds(a).unwrap(), doc.world_bounds(b).unwrap()), before, "contents stay put");
|
||||
assert_eq!(s.selection(), vec![sec]);
|
||||
assert!(doc.node(sec).unwrap().strokes.len() == 1, "the section look");
|
||||
// Not from inside a frame.
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": a})).unwrap());
|
||||
assert!(s.execute("object.wrapInSection", &json!({"ids": [r]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_for_dev_turns_changed_until_resolved() {
|
||||
let mut s = Session::new();
|
||||
let sec = section(&mut s, 0.0, 0.0, 400.0, 400.0);
|
||||
let f = id(s.execute("frame.create", &json!({"x": 20, "y": 20, "width": 100, "height": 100, "parent": sec})).unwrap());
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap());
|
||||
let other = id(s.execute("frame.create", &json!({"x": 600, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
s.execute("node.set", &json!({"ids": [sec, f], "props": {"devStatus": "readyForDev"}})).unwrap();
|
||||
let changed = |s: &mut Session, n: NodeId| get(s, n).get("devChanged").is_some();
|
||||
assert!(!changed(&mut s, sec) && !changed(&mut s, f), "marking isn't a change");
|
||||
// Hiding contents and editing elsewhere aren't changes either.
|
||||
s.execute("section.toggleContents", &json!({"ids": [sec]})).unwrap();
|
||||
s.execute("section.toggleContents", &json!({"ids": [sec]})).unwrap();
|
||||
s.execute("node.set", &json!({"id": other, "props": {"opacity": 0.5}})).unwrap();
|
||||
assert!(!changed(&mut s, sec));
|
||||
// Editing a layer inside: both the frame and the section around it changed.
|
||||
s.execute("node.set", &json!({"id": r, "props": {"fills": ["#00FF00"]}})).unwrap();
|
||||
assert!(changed(&mut s, sec) && changed(&mut s, f));
|
||||
s.undo().unwrap();
|
||||
assert!(!changed(&mut s, sec), "undo takes the change back");
|
||||
s.redo().unwrap();
|
||||
// Resolving keeps the reason and returns to Ready for dev.
|
||||
s.execute("dev.resolveChange", &json!({"ids": [sec], "reason": " New brand colour "})).unwrap();
|
||||
let v = get(&mut s, sec);
|
||||
assert_eq!((v.get("devChanged"), v["devNote"].clone(), v["devStatus"].clone()), (None, json!("New brand colour"), json!("readyForDev")));
|
||||
assert!(s.execute("dev.resolveChange", &json!({"ids": [sec]})).is_err(), "nothing left to resolve");
|
||||
// Marking again also starts over; removing a layer is a change.
|
||||
s.execute("node.set", &json!({"id": f, "props": {"devStatus": "readyForDev"}})).unwrap();
|
||||
assert!(!changed(&mut s, f));
|
||||
s.execute("edit.delete", &json!({"ids": [r]})).unwrap();
|
||||
assert!(changed(&mut s, f) && changed(&mut s, sec));
|
||||
// Other statuses never show changes.
|
||||
s.execute("node.set", &json!({"id": f, "props": {"devStatus": "completed"}})).unwrap();
|
||||
assert!(!changed(&mut s, f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_in_sections_are_prototype_screens() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let sec = section(&mut s, 0.0, 0.0, 800.0, 400.0);
|
||||
let inner = section(&mut s, 0.0, 0.0, 300.0, 300.0);
|
||||
s.execute("node.reparent", &json!({"ids": [inner], "parent": sec})).unwrap();
|
||||
let a = id(s.execute("frame.create", &json!({"x": 400, "y": 20, "width": 100, "height": 100, "parent": sec})).unwrap());
|
||||
let b = id(s.execute("frame.create", &json!({"x": 20, "y": 20, "width": 100, "height": 100, "parent": inner})).unwrap());
|
||||
let child = id(s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 10, "height": 10, "parent": b})).unwrap());
|
||||
let loose = id(s.execute("frame.create", &json!({"x": 0, "y": 600, "width": 100, "height": 100})).unwrap());
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.top_level_frame(child), Some(b));
|
||||
assert_eq!(doc.top_level_frame(sec), None);
|
||||
assert_eq!(doc.top_frames(page), vec![b, a, loose], "reading order, sections looked into");
|
||||
assert_eq!(doc.sections_in(page), vec![sec, inner]);
|
||||
// Flows can start there.
|
||||
let v = s.execute("proto.flow.add", &json!({"start": b})).unwrap();
|
||||
assert_eq!(v["start"], json!(b));
|
||||
// Navigating to a section shows its first frame, then the one shown last.
|
||||
let doc = s.doc().unwrap();
|
||||
let mut visited = HashMap::new();
|
||||
assert_eq!(crate::proto_run::navigate_target(doc, sec, &visited), Some(b));
|
||||
crate::proto_run::remember_sections(doc, a, &mut visited);
|
||||
assert_eq!(crate::proto_run::navigate_target(doc, sec, &visited), Some(a));
|
||||
assert_eq!(crate::proto_run::navigate_target(doc, inner, &visited), Some(b), "a was never shown in the inner section");
|
||||
assert_eq!(crate::proto_run::navigate_target(doc, child, &visited), Some(b), "a layer goes to its frame");
|
||||
let empty = {
|
||||
let e = section(&mut s, 0.0, 900.0, 50.0, 50.0);
|
||||
(e, s.doc().unwrap())
|
||||
};
|
||||
assert_eq!(crate::proto_run::navigate_target(empty.1, empty.0, &visited), None);
|
||||
}
|
||||
@@ -1272,16 +1272,10 @@ impl Session {
|
||||
_ => base.to_string(),
|
||||
};
|
||||
let nid = doc.alloc_id();
|
||||
let mut n = Node::new(nid, kind, name, 0.0, 0.0);
|
||||
let mut n = if tool == ToolId::Section { Node::new_section(nid, name, 0.0, 0.0) } else { Node::new(nid, kind, name, 0.0, 0.0) };
|
||||
if tool == ToolId::Arrow {
|
||||
n.stroke.cap_end = StrokeCap::LineArrow;
|
||||
}
|
||||
if tool == ToolId::Section {
|
||||
n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)];
|
||||
n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_rgb8_const(0xE6, 0xE6, 0xE6))];
|
||||
n.corners.radii = [2.0; 4];
|
||||
n.clip = false;
|
||||
}
|
||||
if matches!(tool, ToolId::Line | ToolId::Arrow) {
|
||||
let mut v = b - a;
|
||||
if ev.shift {
|
||||
|
||||
@@ -271,7 +271,7 @@ fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Optio
|
||||
let reach = n.paint_reach();
|
||||
let mut r = n.local_rect().inflate(reach, reach);
|
||||
let clips = n.clip && n.kind.is_frame_like();
|
||||
if (!clips || n.kind.bounds_follow_children()) && depth < 64 {
|
||||
if (!clips || n.kind.bounds_follow_children()) && depth < 64 && !n.hides_contents() {
|
||||
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
|
||||
if !c.visible {
|
||||
continue;
|
||||
@@ -447,7 +447,7 @@ fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
|
||||
let stroke = tiny_skia::Stroke { width: 1.0 / ctx.scale.max(1e-6) as f32, ..Default::default() };
|
||||
pix.stroke_path(&tp, &paint, &stroke, ts_transform(xf), None);
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) {
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.hides_contents() {
|
||||
for c in &n.children {
|
||||
draw_node(ctx, pix, *c, xf, depth + 1);
|
||||
}
|
||||
@@ -507,7 +507,7 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
|
||||
{
|
||||
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, doc, None);
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() {
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() && !n.hides_contents() {
|
||||
if clips {
|
||||
draw_children_clipped(ctx, pix, n, xf, depth, outline.as_ref().map(|(p, _)| p));
|
||||
} else {
|
||||
|
||||
@@ -193,7 +193,7 @@ pub fn splat_subtree(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, clip: Op
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) {
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.hides_contents() {
|
||||
let clip = if n.clip && n.kind.is_frame_like() {
|
||||
let own = figcraft_geom::transform_rect_bbox(xf, local);
|
||||
Some(clip.map_or(own, |c| c.intersect(own)))
|
||||
|
||||
@@ -544,13 +544,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
|
||||
y += 1.0;
|
||||
}
|
||||
}
|
||||
let Some(pg) = doc.node(page) else { return };
|
||||
// Layout grids and frame labels for top-level frames.
|
||||
for c in &pg.children {
|
||||
// Layout grids and frame labels for top-level frames (also those in sections).
|
||||
for c in &boards(&doc, page) {
|
||||
let Some(n) = doc.node(*c) else { continue };
|
||||
if !n.visible {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = doc.world_bounds(*c) else { continue };
|
||||
// Off-screen frames draw neither grids nor a label (labels sit just above the frame).
|
||||
let on_screen = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand2(vec2(0.0, 20.0));
|
||||
@@ -587,12 +583,24 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
|
||||
t.text_secondary
|
||||
};
|
||||
if matches!(n.kind, NodeKind::Section { .. }) {
|
||||
// Section titles are pills inside the top-left.
|
||||
let galley = crate::bidi::name_galley(painter, &n.name, semibold(11.0), t.text, 480.0);
|
||||
let r = Rect::from_min_size(tl + vec2(0.0, 0.0), galley.size() + vec2(16.0, 8.0));
|
||||
// Section titles are pills inside the top-left; a section hiding its contents shows
|
||||
// an eye in its pill and says so in the middle.
|
||||
let hidden = n.hides_contents();
|
||||
let fg = if selected { Color32::WHITE } else { t.text };
|
||||
let galley = crate::bidi::name_galley(painter, &n.name, semibold(11.0), fg, 480.0);
|
||||
let eye = if hidden { 16.0 } else { 0.0 };
|
||||
let r = Rect::from_min_size(tl, galley.size() + vec2(16.0 + eye, 8.0));
|
||||
painter.rect_filled(r, CornerRadius::same(4), if selected { ACCENT } else { t.hover });
|
||||
painter.galley(r.min + vec2(8.0, 4.0), galley, if selected { Color32::WHITE } else { t.text });
|
||||
status_badge(painter, n.dev_status, pos2(r.max.x + 6.0, r.center().y));
|
||||
if hidden {
|
||||
let ir = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(12.0, 12.0));
|
||||
crate::icons::paint(painter, ir, crate::icons::Icon::EyeOff, fg);
|
||||
}
|
||||
painter.galley(r.min + vec2(8.0 + eye, 4.0), galley, fg);
|
||||
status_badge(painter, n, pos2(r.max.x + 6.0, r.center().y));
|
||||
let body = Rect::from_two_pos(tl, app.to_screen(b.x1, b.y1));
|
||||
if hidden && body.width() > 120.0 && body.height() > 60.0 {
|
||||
painter.text(body.center(), Align2::CENTER_CENTER, "Contents hidden", regular(11.0), t.text_tertiary);
|
||||
}
|
||||
} else {
|
||||
let max_w = (b.width() * zoom) as f32;
|
||||
let mut x = tl.x;
|
||||
@@ -604,7 +612,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
|
||||
let galley = crate::bidi::name_galley(painter, &n.name, regular(11.0), col, (max_w - (x - tl.x)).max(20.0));
|
||||
let end = x + galley.size().x;
|
||||
painter.galley(pos2(x, tl.y - 16.0), galley, col);
|
||||
status_badge(painter, n.dev_status, pos2(end + 6.0, tl.y - 9.0));
|
||||
status_badge(painter, n, pos2(end + 6.0, tl.y - 9.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -990,12 +998,35 @@ fn hatch(polys: &[Vec<Pos2>], gap: f32) -> Vec<[Pos2; 2]> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A frame's or section's handoff status as a small pill left-centred on `at`.
|
||||
fn status_badge(painter: &egui::Painter, status: figcraft_doc::DevStatus, at: Pos2) {
|
||||
let (label, fill) = match status {
|
||||
/// The layers that get canvas labels and layout grids, in z-order: the page's visible top-level
|
||||
/// layers and, inside sections whose contents show, their visible children (sections nest).
|
||||
fn boards(doc: &Document, page: NodeId) -> Vec<NodeId> {
|
||||
fn walk(doc: &Document, id: NodeId, depth: usize, out: &mut Vec<NodeId>) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
for c in &n.children {
|
||||
let Some(cn) = doc.node(*c) else { continue };
|
||||
if !cn.visible {
|
||||
continue;
|
||||
}
|
||||
out.push(*c);
|
||||
if matches!(cn.kind, NodeKind::Section { contents_hidden: false }) && depth < 32 {
|
||||
walk(doc, *c, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(doc, page, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// A frame's or section's handoff status as a small pill left-centred on `at` ("Changed" once a
|
||||
/// ready-for-dev layer was edited).
|
||||
fn status_badge(painter: &egui::Painter, n: &figcraft_doc::Node, at: Pos2) {
|
||||
let (label, fill) = match n.dev_status {
|
||||
figcraft_doc::DevStatus::None => return,
|
||||
figcraft_doc::DevStatus::ReadyForDev => ("Ready for dev", Color32::from_rgb(0x14, 0xAE, 0x5C)),
|
||||
figcraft_doc::DevStatus::Completed => ("Completed", Color32::from_rgb(0x6E, 0x6E, 0x73)),
|
||||
figcraft_doc::DevStatus::ReadyForDev if n.dev_changed => ("Changed", crate::theme::DEV_CHANGED),
|
||||
figcraft_doc::DevStatus::ReadyForDev => ("Ready for dev", crate::theme::DEV_READY),
|
||||
figcraft_doc::DevStatus::Completed => ("Completed", crate::theme::DEV_COMPLETED),
|
||||
};
|
||||
let galley = painter.layout_no_wrap(label.to_string(), semibold(10.0), Color32::WHITE);
|
||||
let r = Rect::from_min_size(pos2(at.x, at.y - galley.size().y / 2.0 - 2.0), galley.size() + vec2(10.0, 4.0));
|
||||
|
||||
@@ -279,6 +279,8 @@ pub(crate) struct Row {
|
||||
pub(crate) id: NodeId,
|
||||
depth: usize,
|
||||
has_kids: bool,
|
||||
/// Not shown on the canvas because of an ancestor (hidden, or a section hiding its contents).
|
||||
dimmed: bool,
|
||||
}
|
||||
|
||||
/// The layer rows of the last frame and what they were built from, so a frame where neither the
|
||||
@@ -308,7 +310,7 @@ pub(crate) fn rows_for(ui: &Ui, doc: &Arc<Document>, page: NodeId, expanded: &BT
|
||||
if !fresh {
|
||||
let mut rows = Vec::new();
|
||||
if filter.is_empty() {
|
||||
collect(doc, page, 0, expanded, &mut rows);
|
||||
collect(doc, page, 0, false, expanded, &mut rows);
|
||||
} else {
|
||||
matching(doc, page, filter, &mut rows);
|
||||
}
|
||||
@@ -318,15 +320,21 @@ pub(crate) fn rows_for(ui: &Ui, doc: &Arc<Document>, page: NodeId, expanded: &BT
|
||||
})
|
||||
}
|
||||
|
||||
fn collect(doc: &Document, id: NodeId, depth: usize, expanded: &BTreeSet<NodeId>, out: &mut Vec<Row>) {
|
||||
/// Whether the children of `n` don't show on the canvas (given whether `n` itself doesn't).
|
||||
fn hides_children(n: &figcraft_doc::Node, dimmed: bool) -> bool {
|
||||
dimmed || !n.visible || n.hides_contents()
|
||||
}
|
||||
|
||||
fn collect(doc: &Document, id: NodeId, depth: usize, dimmed: bool, expanded: &BTreeSet<NodeId>, out: &mut Vec<Row>) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
let dim_kids = !n.is_page() && hides_children(n, dimmed);
|
||||
// Layers panel lists top of the stack first.
|
||||
for c in n.children.iter().rev() {
|
||||
let Some(cn) = doc.node(*c) else { continue };
|
||||
let has_kids = !cn.children.is_empty() || matches!(cn.kind, NodeKind::BooleanOp { .. });
|
||||
out.push(Row { id: *c, depth, has_kids });
|
||||
out.push(Row { id: *c, depth, has_kids, dimmed: dim_kids });
|
||||
if expanded.contains(c) && depth < 64 {
|
||||
collect(doc, *c, depth + 1, expanded, out);
|
||||
collect(doc, *c, depth + 1, dim_kids, expanded, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,7 +346,8 @@ fn matching(doc: &Document, id: NodeId, q: &str, out: &mut Vec<Row>) {
|
||||
let Some(cn) = doc.node(*c) else { continue };
|
||||
let name = if cn.name.is_empty() { cn.kind.label() } else { cn.name.as_str() };
|
||||
if name.to_lowercase().contains(q) {
|
||||
out.push(Row { id: *c, depth: 0, has_kids: false });
|
||||
let dimmed = doc.ancestors(*c).iter().filter_map(|a| doc.node(*a)).any(|a| !a.is_page() && hides_children(a, false));
|
||||
out.push(Row { id: *c, depth: 0, has_kids: false, dimmed });
|
||||
}
|
||||
if out.len() < 5000 {
|
||||
matching(doc, *c, q, out);
|
||||
@@ -360,7 +369,7 @@ pub(crate) fn key_nav(app: &mut FigApp, key: egui::Key, shift: bool) -> bool {
|
||||
let filter = app.ui.layer_filter.trim().to_lowercase();
|
||||
let mut rows = Vec::new();
|
||||
if filter.is_empty() {
|
||||
collect(&doc, page, 0, &app.ui.expanded, &mut rows);
|
||||
collect(&doc, page, 0, false, &app.ui.expanded, &mut rows);
|
||||
} else {
|
||||
matching(&doc, page, &filter, &mut rows);
|
||||
}
|
||||
@@ -597,9 +606,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
let icon = node_icon(&doc, row.id);
|
||||
let shown = n.visible && !row.dimmed;
|
||||
let icon_col = if is_comp || icon == Icon::Slot {
|
||||
COMPONENT
|
||||
} else if n.visible {
|
||||
COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })
|
||||
} else if shown {
|
||||
t.icon
|
||||
} else {
|
||||
t.text_tertiary
|
||||
@@ -618,8 +628,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
} else {
|
||||
let col = if is_comp {
|
||||
COMPONENT
|
||||
} else if n.visible {
|
||||
COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })
|
||||
} else if shown {
|
||||
t.text
|
||||
} else {
|
||||
t.text_tertiary
|
||||
|
||||
@@ -333,8 +333,18 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
close = true;
|
||||
}
|
||||
let has_sel = !ids.is_empty();
|
||||
let entries: &[(&str, &str)] = if has_sel {
|
||||
&[
|
||||
// Sections in the selection add a contents toggle naming what it will do.
|
||||
let section_toggle = app.session.doc().ok().and_then(|doc| {
|
||||
let secs: Vec<bool> = ids
|
||||
.iter()
|
||||
.filter_map(|i| doc.node(*i))
|
||||
.filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Section { .. }))
|
||||
.map(|n| n.hides_contents())
|
||||
.collect();
|
||||
(!secs.is_empty()).then(|| if secs.iter().all(|h| *h) { "Show section contents" } else { "Hide section contents" })
|
||||
});
|
||||
let mut entries: Vec<(&str, &str)> = if has_sel {
|
||||
vec![
|
||||
("Copy", "edit.copy"),
|
||||
("Paste here", "edit.paste"),
|
||||
("Paste to replace", "edit.pasteReplace"),
|
||||
@@ -346,6 +356,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
("-", ""),
|
||||
("Group selection", "object.group"),
|
||||
("Frame selection", "object.frameSelection"),
|
||||
("Wrap in new section", "object.wrapInSection"),
|
||||
("Ungroup", "object.ungroup"),
|
||||
("Flatten", "vector.flatten"),
|
||||
("Outline stroke", "vector.outlineStroke"),
|
||||
@@ -368,7 +379,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
("Delete", "edit.delete"),
|
||||
]
|
||||
} else {
|
||||
&[
|
||||
vec![
|
||||
("Paste here", "edit.paste"),
|
||||
("-", ""),
|
||||
("Select all", "select.all"),
|
||||
@@ -379,7 +390,12 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
("Pixel grid", "view.pixelGrid"),
|
||||
]
|
||||
};
|
||||
for (label, id) in entries {
|
||||
if let Some(label) = section_toggle
|
||||
&& let Some(at) = entries.iter().position(|e| e.1 == "object.toggleLock")
|
||||
{
|
||||
entries.insert(at + 1, (label, "section.toggleContents"));
|
||||
}
|
||||
for (label, id) in &entries {
|
||||
if *label == "-" {
|
||||
separator(ui);
|
||||
continue;
|
||||
|
||||
@@ -266,6 +266,8 @@ pub struct Presenter {
|
||||
fling: Option<Fling>,
|
||||
/// The wheel's last target and its speed (units/s), while it turns.
|
||||
wheel: Option<(Scroller, (f64, f64))>,
|
||||
/// The frame shown last in each section (navigating to a section returns there).
|
||||
sections: HashMap<NodeId, NodeId>,
|
||||
/// The frame shown last, where it was placed and what it could draw into (comment pins go there).
|
||||
shown: Option<(NodeId, Place, Rect)>,
|
||||
}
|
||||
@@ -318,6 +320,7 @@ impl Presenter {
|
||||
zoom: 1.0,
|
||||
fling: None,
|
||||
wheel: None,
|
||||
sections: HashMap::new(),
|
||||
shown: None,
|
||||
}
|
||||
}
|
||||
@@ -554,6 +557,9 @@ impl Presenter {
|
||||
}
|
||||
|
||||
fn arrive(&mut self, now: f64) {
|
||||
if let Some(doc) = self.doc.as_ref() {
|
||||
figcraft_engine::proto_run::remember_sections(doc, self.current, &mut self.sections);
|
||||
}
|
||||
self.arrived = now;
|
||||
self.delays_fired.clear();
|
||||
self.hovered.clear();
|
||||
@@ -589,6 +595,7 @@ impl Presenter {
|
||||
pub fn restart(&mut self, start: NodeId, now: f64) {
|
||||
self.start = start;
|
||||
self.current = start;
|
||||
self.sections.clear();
|
||||
self.history.clear();
|
||||
self.overlays.clear();
|
||||
self.scroll.clear();
|
||||
@@ -646,7 +653,7 @@ impl Presenter {
|
||||
let Some(doc) = self.doc.clone() else { return false };
|
||||
match a {
|
||||
Action::Navigate { dest, preserve_scroll } => {
|
||||
let top = doc.top_level_frame(*dest).unwrap_or(*dest);
|
||||
let Some(top) = figcraft_engine::proto_run::navigate_target(&doc, *dest, &self.sections) else { return false };
|
||||
if !doc.contains(top) || (top == self.current && self.overlays.is_empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -123,10 +123,12 @@ fn handles(app: &FigApp, doc: &Document, sel: &[NodeId]) -> Vec<(NodeId, Pos2)>
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The top-level frame under a screen point (top-most first).
|
||||
/// The top-level frame under a screen point (top-most first), else the innermost section there
|
||||
/// (a connection to a section opens the frame of it shown last).
|
||||
fn frame_at(app: &FigApp, doc: &Document, page: NodeId, p: Pos2) -> Option<NodeId> {
|
||||
let pt = app.to_page(p);
|
||||
doc.top_frames(page).into_iter().rev().find(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(pt)))
|
||||
let under = |i: &NodeId| doc.world_bounds(*i).is_some_and(|b| b.contains(pt));
|
||||
doc.top_frames(page).into_iter().rev().find(under).or_else(|| doc.sections_in(page).into_iter().rev().find(under))
|
||||
}
|
||||
|
||||
fn arrow(painter: &egui::Painter, pts: &[Pos2; 4], col: Color32) {
|
||||
|
||||
@@ -131,6 +131,14 @@ fn frames_for(doc: &Document, page: NodeId) -> Vec<NodeId> {
|
||||
doc.top_frames(page)
|
||||
}
|
||||
|
||||
/// Destinations for "Navigate to": the top-level frames, then the sections (navigating to a
|
||||
/// section shows the frame of it shown last, else its first).
|
||||
fn nav_targets(doc: &Document, page: NodeId) -> Vec<NodeId> {
|
||||
let mut v = frames_for(doc, page);
|
||||
v.extend(doc.sections_in(page));
|
||||
v
|
||||
}
|
||||
|
||||
fn name_of(doc: &Document, id: NodeId) -> String {
|
||||
doc.node(id).map(|n| n.name.clone()).unwrap_or_else(|| "Missing layer".into())
|
||||
}
|
||||
@@ -764,7 +772,17 @@ fn action_rows(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node
|
||||
match &action {
|
||||
Action::Navigate { dest, preserve_scroll } => {
|
||||
let (dest, preserve_scroll) = (*dest, *preserve_scroll);
|
||||
dest_dropdown(app, ui, doc, &frames, Some(dest), "Destination", |d| Action::Navigate { dest: d, preserve_scroll }, node, k);
|
||||
dest_dropdown(
|
||||
app,
|
||||
ui,
|
||||
doc,
|
||||
&nav_targets(doc, page),
|
||||
Some(dest),
|
||||
"Destination",
|
||||
|d| Action::Navigate { dest: d, preserve_scroll },
|
||||
node,
|
||||
k,
|
||||
);
|
||||
row(ui, 28.0, |ui| {
|
||||
if widgets::checkbox(ui, preserve_scroll, "Preserve scroll position") {
|
||||
patch(app, node, k, json!({"action": Action::Navigate { dest, preserve_scroll: !preserve_scroll }}));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Design panel extras for frames and sections: section colours, handoff status and resize to
|
||||
//! fit.
|
||||
//! Design panel extras for frames and sections: section colours, hiding a section's contents,
|
||||
//! handoff status (with resolving a change to a ready-for-dev layer) and resize to fit.
|
||||
|
||||
use egui::{CornerRadius, Sense, Stroke, Ui, vec2};
|
||||
use figcraft_doc::{DevStatus, Document, NodeId, NodeKind};
|
||||
@@ -55,11 +55,23 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
}
|
||||
}
|
||||
});
|
||||
row(ui, 28.0, |ui| {
|
||||
let hidden = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.hides_contents()));
|
||||
if widgets::checkbox(ui, hidden, "Hide contents") {
|
||||
app.run("section.toggleContents", json!({"ids": ids, "hidden": !hidden}));
|
||||
}
|
||||
});
|
||||
}
|
||||
widgets::sublabels(ui, "Status", None);
|
||||
let changed = sel.iter().any(|i| doc.node(*i).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed));
|
||||
row(ui, 32.0, |ui| {
|
||||
let st = common(doc, sel, |n| n.dev_status);
|
||||
let resp = widgets::dropdown(ui, st.map(DevStatus::label).unwrap_or("Mixed"), col * 2.0 + 8.0, Some(Icon::Code));
|
||||
let st = common(doc, sel, |n| (n.dev_status, n.dev_changed));
|
||||
let label = match st {
|
||||
Some((DevStatus::ReadyForDev, true)) => "Changed",
|
||||
Some((d, _)) => d.label(),
|
||||
None => "Mixed",
|
||||
};
|
||||
let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Code));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for d in DevStatus::ALL {
|
||||
if menu_item(ui, d.label(), "") {
|
||||
@@ -69,6 +81,35 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
}
|
||||
});
|
||||
});
|
||||
if changed {
|
||||
// Changed since marked ready: say why to resolve it (back to Ready for dev).
|
||||
row(ui, 32.0, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
let id = egui::Id::new(("dev_reason", ids.first().copied()));
|
||||
let draft_id = id.with("draft");
|
||||
// The typed reason stays a draft until Enter or Resolve: clicking away keeps it.
|
||||
let mut draft: String = ui.data(|d| d.get_temp(draft_id)).unwrap_or_default();
|
||||
let committed = widgets::text_field(ui, id, &draft, col * 2.0 - 64.0, "Why it changed");
|
||||
let enter = committed.is_some() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if let Some(text) = committed {
|
||||
draft = text;
|
||||
ui.data_mut(|d| d.insert_temp(draft_id, draft.clone()));
|
||||
}
|
||||
let button = widgets::button(ui, "Resolve", true, true).on_hover_text("Back to Ready for dev");
|
||||
if enter || button.clicked() {
|
||||
let reason = draft.trim();
|
||||
let params = if reason.is_empty() { json!({"ids": ids}) } else { json!({"ids": ids, "reason": reason}) };
|
||||
app.run("dev.resolveChange", params);
|
||||
ui.data_mut(|d| d.remove::<String>(draft_id));
|
||||
}
|
||||
});
|
||||
} else if let Some(note) = common(doc, sel, |n| n.dev_note.clone()).filter(|n| !n.is_empty()) {
|
||||
row(ui, 24.0, |ui| {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let text = widgets::elide(ui, &format!("Last change: {note}"), col * 2.0 + 8.0);
|
||||
widgets::label(ui, &text, t.text_secondary);
|
||||
});
|
||||
}
|
||||
row(ui, 32.0, |ui| {
|
||||
let fit = ui.add_sized(vec2(col * 2.0 + 8.0, widgets::FIELD_H), egui::Button::new("Resize to fit")).on_hover_text("Fit to contents ⌥⇧⌘R");
|
||||
if fit.clicked() {
|
||||
|
||||
@@ -43,6 +43,10 @@ pub struct Tokens {
|
||||
|
||||
pub const ACCENT: Color32 = Color32::from_rgb(0x0D, 0x99, 0xFF);
|
||||
pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF);
|
||||
/// Handoff status badges (white text on these, in both themes).
|
||||
pub const DEV_READY: Color32 = Color32::from_rgb(0x14, 0xAE, 0x5C);
|
||||
pub const DEV_CHANGED: Color32 = Color32::from_rgb(0xD9, 0x7A, 0x06);
|
||||
pub const DEV_COMPLETED: Color32 = Color32::from_rgb(0x6E, 0x6E, 0x73);
|
||||
|
||||
impl Tokens {
|
||||
pub fn light() -> Self {
|
||||
|
||||
@@ -89,7 +89,7 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d
|
||||
### `ui.key {key, shift?, alt?, cmd?, ctrl?}`
|
||||
|
||||
A key as if typed on the canvas (in vector edit mode the edit keys come first: `V` `P` `Q` `X` `Shift+B` `Shift+E` `Shift+W` tools, arrows nudge points, `Delete`/`Shift+Delete`, `Cmd+A`, `Cmd+J`; with the shape builder, `Enter` finishes): a menu shortcut (`{"key":"G","cmd":true}` groups), a tool key,
|
||||
`Escape`, `Delete`/`Backspace`, `Enter`, arrows (nudge; shift = big nudge; auto layout children move one place along the flow; with `cmd` they resize the selection). With the Comment
|
||||
`Escape`, `Delete`/`Backspace`, `Enter`, arrows (nudge; shift = big nudge; auto layout children move one place instead: along the flow, between wrapped rows, to the next grid cell, or into the neighbouring nested auto layout frame; with `cmd` they resize the selection). With the Comment
|
||||
tool, `ArrowUp`/`ArrowDown` open the previous/next thread of the Comments panel's list instead
|
||||
(returns `{"comment": id}`). While text is being edited, keys go to the text editor. Returns
|
||||
`{"command": id}` for shortcuts.
|
||||
|
||||
+18
-1
@@ -129,7 +129,7 @@ Or in a project's `.mcp.json`:
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, SwiftUI or Jetpack Compose for a layer), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
|
||||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`), text…); returns the updated layers. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
|
||||
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
|
||||
| `get_node {id, depth}` | One layer as JSON. |
|
||||
| `select {ids}` | Replace the selection (`[]` deselects). |
|
||||
@@ -142,6 +142,23 @@ Or in a project's `.mcp.json`:
|
||||
| `type_text {text, id?}` | Type into the text being edited, or replace the contents of text layer `id`. |
|
||||
| `inspect_ui {}` | Tool, view and status (and panels/timings when attached). |
|
||||
|
||||
### Sections, handoff and arrow keys
|
||||
|
||||
`run_command` with `object.wrapInSection {ids?, name?}` (Wrap in new section: layers on the page or
|
||||
in a section, with some room around them), `section.toggleContents {ids?, hidden?}` (a section
|
||||
hiding its contents shows only its name and outline; its layers aren't drawn or hit, and the layers
|
||||
panel dims them), `object.resizeToFit`. A frame or section marked `devStatus: readyForDev` turns
|
||||
"Changed" (`devChanged: true` in `get_node`) when a layer in it is edited, added or removed;
|
||||
`dev.resolveChange {ids?, reason?}` returns it to Ready for dev and keeps the reason (`devNote`).
|
||||
Frames in sections are top-level frames (flows can start there; `proto.flow.add`); a "Navigate to"
|
||||
whose destination is a section shows the frame of it shown last in the presentation, else its
|
||||
first.
|
||||
|
||||
`transform.nudge {dx, dy}` (arrow keys) moves auto layout children one place instead of nudging:
|
||||
along the flow, to the previous/next row of a wrapping layout, to the neighbouring grid cell, and,
|
||||
at the end of a flow or across a row's axis, into the neighbouring nested auto layout frame when the
|
||||
frame around flows that way (a list of rows: Down moves an item into the row below).
|
||||
|
||||
### Editing vectors
|
||||
|
||||
Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures):
|
||||
|
||||
Reference in New Issue
Block a user