mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
* Shortcuts: the keymap, overrides and dispatch come from craft-keymap
The editor's catalogue (commands, UI commands, panels, tools; one tool
group per tool; show/hide commands marked as toggles) builds a
craft-keymap Keymap. UiState::shortcut_overrides is a craft_keymap
Overrides stored through keymap::stored, which writes the same map of
entry key -> one string as before, with panel: keys and VectorCraft's
spellings, so earlier versions read the file; a value that is not text
drops that entry instead of every preference.
Key presses run through craft_keymap's Dispatcher with accept filters:
keys that type text go to the one-key text path, and while the Type
tool edits only command-key chords run. Held keys repeat. Conflicts
and assignment use the crate's conflicts and set_bindings. Extra Shift
no longer falls back to the chord without it.
* Shortcuts: .kys import matches and applies through craft-keymap
The review proposes targets with craft_keymap's namematch, and Apply
hands the ready rows to craft_keymap::plan as one set, each row's
target passed as the remembered answer for its entry. The later row
takes a chord, and commands outside the set lose the chords the set
gives. Rows with one name in one block keep their own targets.
The engine's own namematch module is removed.
* Shortcuts: Cmd+, beside other hits, web Mac platform, cached dialog view
- Pin craft-keymap at 362eb59f.
- Cmd+, opens Settings on a Mac after the frame's other shortcuts run, with the
Mac read from egui's OS; drop settings_chord, which dispatch no longer used.
- The web build follows the browser's OS, so Ctrl stays Ctrl on a Mac.
- The Keyboard Shortcuts dialog keeps its keymap, row texts and conflict count
between frames while the working set is unchanged.
- Every default shortcut text becomes its entry's binding (test).
- craft-keymap counts as a UI crate for the layer check.
- Translate "too many changed shortcuts"; the parse error reuses the existing
"can't parse shortcut `{c}`" message.
* Shortcuts: the chords the operating system takes can't be assigned
The keymap reserves craft_keymap::reserved::system for its platform:
window switching and closing, and the virtual consoles off the Mac.
The Keyboard Shortcuts dialog and shortcuts.set refuse them with
"<chord> is reserved by the system", and a saved override on one keeps
the entry's default. No default shortcut uses a reserved chord.
* Shortcuts: craft-keymap at craft-libs 2eca9b3a
craft-keymap comes from storytold/craft-libs (craft-libs #164 and #186), at the rev craft-pdf
already uses. The web build keeps the browser's full platform, so Windows browsers get Windows'
reserved chords, and each platform caches its own default keymap.
364 lines
14 KiB
Rust
364 lines
14 KiB
Rust
//! Dependency layering rules (plan/architecture.md §3).
|
|
//!
|
|
//! The rule engine works on a small, metadata-independent model so it can be
|
|
//! unit-tested; `from_metadata` builds that model from `cargo metadata`.
|
|
|
|
use serde_json::Value;
|
|
|
|
/// Where a workspace crate sits.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Class {
|
|
/// Regular layered crate.
|
|
Layer(u8),
|
|
/// Layer 0, and additionally may depend on no workspace crate at all.
|
|
Standalone,
|
|
/// Test tooling: may depend on anything up to L5 (it sits at L6 for rule
|
|
/// purposes); other crates may use it only as a dev-dependency.
|
|
Testkit,
|
|
/// Binaries and build tooling: exempt from the rules.
|
|
Exempt,
|
|
}
|
|
|
|
impl Class {
|
|
fn layer(self) -> Option<u8> {
|
|
match self {
|
|
Class::Layer(l) => Some(l),
|
|
Class::Standalone => Some(0),
|
|
Class::Testkit => Some(6),
|
|
Class::Exempt => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The layering table. Names are package names without the `vectorcraft-`
|
|
/// prefix.
|
|
pub const TABLE: &[(&str, Class)] = &[
|
|
("geom", Class::Layer(0)),
|
|
("color", Class::Layer(0)),
|
|
("doc", Class::Layer(1)),
|
|
("pathops", Class::Layer(2)),
|
|
("brush", Class::Layer(2)),
|
|
("trace", Class::Layer(2)),
|
|
("text", Class::Layer(2)),
|
|
("effects", Class::Layer(2)),
|
|
("three-d", Class::Layer(2)),
|
|
("render", Class::Layer(3)),
|
|
("svg", Class::Layer(3)),
|
|
("pdf", Class::Layer(3)),
|
|
("format", Class::Layer(3)),
|
|
("tools", Class::Layer(4)),
|
|
("engine", Class::Layer(5)),
|
|
("ui-egui", Class::Layer(6)),
|
|
("mcp", Class::Layer(6)),
|
|
("testkit", Class::Testkit),
|
|
// L7 apps and tooling
|
|
("vectorcraft", Class::Exempt),
|
|
("cli", Class::Exempt),
|
|
("web", Class::Exempt),
|
|
("xtask", Class::Exempt),
|
|
// Crates added later (append-only).
|
|
("cad", Class::Layer(3)),
|
|
("eps", Class::Layer(3)),
|
|
("metafile", Class::Layer(3)),
|
|
("plugins", Class::Layer(2)),
|
|
("affinity", Class::Standalone),
|
|
("scriptcheck", Class::Standalone),
|
|
];
|
|
|
|
/// Explicit orderings *within* a layer (earlier may be used by later).
|
|
/// The L0 foundation is a small chain: `raster` builds on `color` and
|
|
/// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`)
|
|
/// reuses the CPU reference (`compose`) for LUTs and parity tests. EPS previews use the renderer's
|
|
/// TIFF writer and open Windows metafile previews. Live effects run effect plug-ins.
|
|
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
|
|
&["geom", "color"],
|
|
&["pathops", "effects"],
|
|
&["pathops", "trace"],
|
|
&["text", "effects"],
|
|
&["render", "eps"],
|
|
&["metafile", "eps"],
|
|
// Non-native art in Illustrator editing data is a PDF, drawn by the PDF importer.
|
|
&["pdf", "eps"],
|
|
&["plugins", "effects"],
|
|
&["three-d", "effects"],
|
|
// Inflate cuts its surface's grid cells to the shape with Pathfinder's booleans.
|
|
&["pathops", "three-d"],
|
|
];
|
|
|
|
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
|
let (from, to) = (short_name(from), short_name(to));
|
|
INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) {
|
|
(Some(f), Some(t)) => t < f,
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
/// External crates that constitute a UI toolkit / windowing dependency.
|
|
/// Entries ending in `*` are prefixes.
|
|
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "craft-ui", "craft-keymap", "rfd", "bevy*"];
|
|
|
|
/// First layer allowed to use UI crates.
|
|
pub const UI_MIN_LAYER: u8 = 6;
|
|
|
|
pub fn short_name(pkg: &str) -> &str {
|
|
pkg.strip_prefix("vectorcraft-").unwrap_or(pkg)
|
|
}
|
|
|
|
pub fn classify(pkg: &str) -> Option<Class> {
|
|
let s = short_name(pkg);
|
|
TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
|
|
}
|
|
|
|
fn is_ui_crate(name: &str) -> bool {
|
|
UI_CRATES.iter().any(|p| match p.strip_suffix('*') {
|
|
Some(prefix) => name.starts_with(prefix),
|
|
None => name == *p,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DepKind {
|
|
Normal,
|
|
Dev,
|
|
Build,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Dep {
|
|
pub name: String,
|
|
pub kind: DepKind,
|
|
/// `true` if the dependency is a workspace member.
|
|
pub workspace: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Crate {
|
|
pub name: String,
|
|
pub deps: Vec<Dep>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Violation {
|
|
Unregistered { krate: String },
|
|
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
|
|
StandaloneHasWorkspaceDep { krate: String, dep: String },
|
|
TestkitAsNormalDep { krate: String },
|
|
UiBelowL6 { krate: String, dep: String, layer: u8 },
|
|
}
|
|
|
|
impl std::fmt::Display for Violation {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Violation::Unregistered { krate } => {
|
|
write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)")
|
|
}
|
|
Violation::Upward { krate, dep, from, to, kind } => {
|
|
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers")
|
|
}
|
|
Violation::StandaloneHasWorkspaceDep { krate, dep } => {
|
|
write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}")
|
|
}
|
|
Violation::TestkitAsNormalDep { krate } => {
|
|
write!(f, "{krate}: vectorcraft-testkit may only be a dev-dependency")
|
|
}
|
|
Violation::UiBelowL6 { krate, dep, layer } => {
|
|
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check all rules. Returns violations sorted for stable output.
|
|
pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
|
let mut out = Vec::new();
|
|
for c in crates {
|
|
let Some(class) = classify(&c.name) else {
|
|
out.push(Violation::Unregistered { krate: c.name.clone() });
|
|
continue;
|
|
};
|
|
if class == Class::Exempt {
|
|
continue;
|
|
}
|
|
let layer = class.layer().unwrap_or(0);
|
|
for d in &c.deps {
|
|
// Self dev-dependencies (e.g. to enable features in tests) are fine.
|
|
if d.name == c.name {
|
|
continue;
|
|
}
|
|
if d.workspace {
|
|
if class == Class::Standalone {
|
|
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
|
|
continue;
|
|
}
|
|
match classify(&d.name) {
|
|
// Unregistered deps are reported on their own entry.
|
|
None => {}
|
|
Some(Class::Testkit) if d.kind != DepKind::Dev => {
|
|
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
|
|
}
|
|
// The testkit as a dev-dependency is how tests use it.
|
|
Some(Class::Testkit) => {}
|
|
Some(dc) => {
|
|
let to = dc.layer().unwrap_or(u8::MAX);
|
|
if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {
|
|
out.push(Violation::Upward {
|
|
krate: c.name.clone(),
|
|
dep: d.name.clone(),
|
|
from: layer,
|
|
to: if to == u8::MAX { 7 } else { to },
|
|
kind: d.kind,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
|
out.push(Violation::UiBelowL6 { krate: c.name.clone(), dep: d.name.clone(), layer });
|
|
}
|
|
}
|
|
}
|
|
out.sort_by_key(|v| v.to_string());
|
|
out.dedup();
|
|
out
|
|
}
|
|
|
|
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
|
|
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
|
|
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
|
|
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
|
|
let mut out = Vec::new();
|
|
for p in pkgs {
|
|
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
|
|
let mut deps = Vec::new();
|
|
for d in p["dependencies"].as_array().into_iter().flatten() {
|
|
let dname = d["name"].as_str().unwrap_or_default().to_owned();
|
|
let kind = match d["kind"].as_str() {
|
|
Some("dev") => DepKind::Dev,
|
|
Some("build") => DepKind::Build,
|
|
_ => DepKind::Normal,
|
|
};
|
|
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
|
|
deps.push(Dep { name: dname, kind, workspace });
|
|
}
|
|
out.push(Crate { name, deps });
|
|
}
|
|
out.sort_by(|a, b| a.name.cmp(&b.name));
|
|
Ok(out)
|
|
}
|
|
|
|
pub fn describe(class: Option<Class>) -> String {
|
|
match class {
|
|
Some(Class::Layer(l)) => format!("L{l}"),
|
|
Some(Class::Standalone) => "L0 standalone".into(),
|
|
Some(Class::Testkit) => "testkit".into(),
|
|
Some(Class::Exempt) => "exempt".into(),
|
|
None => "UNREGISTERED".into(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
|
|
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
|
|
}
|
|
use DepKind::*;
|
|
|
|
#[test]
|
|
fn clean_downward_graph_passes() {
|
|
let g = [
|
|
c("vectorcraft-geom", &[("kurbo", Normal, false)]),
|
|
c("vectorcraft-doc", &[("vectorcraft-geom", Normal, true)]),
|
|
c("vectorcraft-engine", &[("vectorcraft-doc", Normal, true), ("vectorcraft-testkit", Dev, true)]),
|
|
c("vectorcraft-ui-egui", &[("vectorcraft-engine", Normal, true), ("egui", Normal, false)]),
|
|
c("vectorcraft-cli", &[("vectorcraft-ui-egui", Normal, true)]),
|
|
];
|
|
assert!(check(&g).is_empty(), "{:?}", check(&g));
|
|
}
|
|
|
|
#[test]
|
|
fn upward_dependency_flagged() {
|
|
let v = check(&[c("vectorcraft-doc", &[("vectorcraft-engine", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 5, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn sideways_dependency_flagged() {
|
|
let v = check(&[c("vectorcraft-text", &[("vectorcraft-pathops", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn l0_foundation_chain_allowed_one_way() {
|
|
assert!(check(&[c("vectorcraft-color", &[("vectorcraft-geom", Normal, true)])]).is_empty());
|
|
let v = check(&[c("vectorcraft-geom", &[("vectorcraft-color", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn self_dev_dependency_ignored() {
|
|
assert!(check(&[c("vectorcraft-doc", &[("vectorcraft-doc", Dev, true)])]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn upward_dev_dependency_flagged() {
|
|
let v = check(&[c("vectorcraft-geom", &[("vectorcraft-doc", Dev, true)])]);
|
|
assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }]));
|
|
}
|
|
|
|
#[test]
|
|
fn ui_crates_below_l6_flagged() {
|
|
for dep in ["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy_ecs", "bevy"] {
|
|
let v = check(&[c("vectorcraft-engine", &[(dep, Normal, false)])]);
|
|
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}");
|
|
}
|
|
assert!(check(&[c("vectorcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
|
|
assert!(check(&[c("vectorcraft-ui-egui", &[("craft-ui", Normal, false)])]).is_empty());
|
|
assert!(check(&[c("vectorcraft-mcp", &[("winit", Normal, false)])]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn unregistered_crate_is_error() {
|
|
let v = check(&[c("vectorcraft-mystery", &[])]);
|
|
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "vectorcraft-mystery"));
|
|
assert!(v[0].to_string().contains("register"));
|
|
}
|
|
|
|
#[test]
|
|
fn testkit_only_as_dev_dependency() {
|
|
let v = check(&[c("vectorcraft-render", &[("vectorcraft-testkit", Normal, true)])]);
|
|
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
|
|
assert!(check(&[c("vectorcraft-render", &[("vectorcraft-testkit", Dev, true)])]).is_empty());
|
|
// testkit itself may use anything up to L5 but not L6 crates.
|
|
assert!(check(&[c("vectorcraft-testkit", &[("vectorcraft-engine", Normal, true)])]).is_empty());
|
|
assert!(!check(&[c("vectorcraft-testkit", &[("vectorcraft-ui-egui", Normal, true)])]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn apps_and_xtask_exempt() {
|
|
for app in ["vectorcraft", "vectorcraft-cli", "vectorcraft-web", "xtask"] {
|
|
assert!(check(&[c(app, &[("egui", Normal, false), ("vectorcraft-ui-egui", Normal, true)])]).is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_parsing() {
|
|
let meta: Value = serde_json::from_str(
|
|
r#"{"packages":[
|
|
{"name":"vectorcraft-doc","dependencies":[
|
|
{"name":"vectorcraft-geom","kind":null,"path":"/x/crates/geom"},
|
|
{"name":"serde","kind":null},
|
|
{"name":"proptest","kind":"dev"}]},
|
|
{"name":"vectorcraft-geom","dependencies":[]}
|
|
]}"#,
|
|
)
|
|
.unwrap();
|
|
let g = from_metadata(&meta).unwrap();
|
|
assert_eq!(g.len(), 2);
|
|
let doc = g.iter().find(|c| c.name == "vectorcraft-doc").unwrap();
|
|
assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
|
|
assert_eq!(doc.deps[2].kind, Dev);
|
|
assert!(check(&g).is_empty());
|
|
}
|
|
}
|