diff --git a/packages/frontend/src/help/logics_help_overview.tsx b/packages/frontend/src/help/logics_help_overview.tsx
index 339e3518c..7da3419b9 100644
--- a/packages/frontend/src/help/logics_help_overview.tsx
+++ b/packages/frontend/src/help/logics_help_overview.tsx
@@ -1,6 +1,6 @@
import { Title } from "@solidjs/meta";
import { A } from "@solidjs/router";
-import { For, useContext } from "solid-js";
+import { For, Show, useContext } from "solid-js";
import invariant from "tiny-invariant";
import { TheoryLibraryContext } from "../theory";
@@ -17,25 +17,29 @@ export default function LogicsHelpOverview() {
Logics - {appTitle}
- {([group, theories]) => (
- <>
- {group}
-
-
- {(theoryMeta) => (
- <>
- -
-
- {theoryMeta.name}
-
-
- - {theoryMeta.description}
- >
- )}
-
-
- >
- )}
+ {([group, groupTheories]) => {
+ const visible = () =>
+ groupTheories.filter((t) => !theories.isEditorVariant(t.id));
+ return (
+ 0}>
+ {group}
+
+
+ {(theoryMeta) => (
+ <>
+ -
+
+ {theoryMeta.name}
+
+
+ - {theoryMeta.description}
+ >
+ )}
+
+
+
+ );
+ }}
>
);
diff --git a/packages/frontend/src/model/document.ts b/packages/frontend/src/model/document.ts
index 9cfdcc2e7..ade537161 100644
--- a/packages/frontend/src/model/document.ts
+++ b/packages/frontend/src/model/document.ts
@@ -11,10 +11,11 @@ import type { ValidatedModel } from "./model_library";
export type ModelDocument = Document & { type: "model" };
/** Create an empty model document. */
-export const newModelDocument = (theory: string): ModelDocument => ({
+export const newModelDocument = (theory: string, editorVariant?: string): ModelDocument => ({
name: "",
type: "model",
theory,
+ ...(editorVariant ? { editorVariant } : {}),
notebook: newNotebook(),
version: currentVersion(),
});
@@ -57,37 +58,79 @@ export async function createModel(
return api.createDoc(init);
}
-/** Migrate a model document from one theory to another. */
+/** The effective theory ID for a model document.
+
+Combines the base theory with the editor variant, if any.
+ */
+export function effectiveTheoryId(doc: ModelDocument): string {
+ return doc.editorVariant ?? doc.theory;
+}
+
+/** Migrate a model document to a different theory, or switch its editor variant.
+
+When the target is an editor variant of the current base theory, only the
+`editorVariant` field is updated. For real theory migrations (inclusions or
+pushforwards), the `theory` field is updated and `editorVariant` is cleared
+or set depending on whether the target is an editor variant.
+ */
export async function migrateModelDocument(
liveModel: LiveModelDoc,
- targetTheoryId: string,
+ targetId: string,
theories: TheoryLibrary,
) {
const { doc, changeDoc } = liveModel.liveDoc;
- const targetTheory = await theories.get(targetTheoryId);
+
+ // Resolve target: is it an editor variant or a base theory?
+ const targetBaseId = theories.getBaseTheoryId(targetId) ?? targetId;
+ const targetEditorVariant = theories.isEditorVariant(targetId) ? targetId : undefined;
+
+ // If only the editor variant is changing (same base theory), just flip the field.
+ if (targetBaseId === doc.theory) {
+ changeDoc((doc) => {
+ if (targetEditorVariant) {
+ doc.editorVariant = targetEditorVariant;
+ } else {
+ delete doc.editorVariant;
+ }
+ });
+ return;
+ }
+
+ // Real theory migration below.
+ const targetTheory = await theories.get(targetBaseId);
const theory = liveModel.theory();
let model = liveModel.elaboratedModel();
invariant(theory && model); // FIXME: Should fail gracefully.
- // Trivial migration.
- if (!NotebookUtils.hasFormalCells(doc.notebook) || theory.inclusions.includes(targetTheoryId)) {
+ // Trivial migration (no formal cells or inclusion).
+ if (!NotebookUtils.hasFormalCells(doc.notebook) || theory.inclusions.includes(targetBaseId)) {
changeDoc((doc) => {
- doc.theory = targetTheoryId;
+ doc.theory = targetBaseId;
+ if (targetEditorVariant) {
+ doc.editorVariant = targetEditorVariant;
+ } else {
+ delete doc.editorVariant;
+ }
});
return;
}
// Pushforward migration.
- const migration = theory.pushforwards.find((m) => m.target === targetTheoryId);
+ const migration = theory.pushforwards.find((m) => m.target === targetBaseId);
if (!migration) {
- throw new Error(`No migration defined from ${theory.id} to ${targetTheoryId}`);
+ throw new Error(`No migration defined from ${theory.id} to ${targetBaseId}`);
}
// TODO: We need a general method to propagate changes from catlog models to
// notebooks. This stop-gap solution only works because pushforward
// migration doesn't have to create/delete cells, only update types.
model = migration.migrate(model, targetTheory.theory);
changeDoc((doc) => {
- doc.theory = targetTheoryId;
+ doc.theory = targetBaseId;
+ if (targetEditorVariant) {
+ doc.editorVariant = targetEditorVariant;
+ } else {
+ delete doc.editorVariant;
+ }
for (const judgment of NotebookUtils.getFormalContent(doc.notebook)) {
if (judgment.tag === "object") {
judgment.obType = model.obType({
diff --git a/packages/frontend/src/model/model_editor.tsx b/packages/frontend/src/model/model_editor.tsx
index ad11cdf5f..59a590604 100644
--- a/packages/frontend/src/model/model_editor.tsx
+++ b/packages/frontend/src/model/model_editor.tsx
@@ -1,4 +1,5 @@
import { Match, Switch, useContext } from "solid-js";
+import { Dynamic } from "solid-js/web";
import invariant from "tiny-invariant";
import type { InstantiatedModel, ModelJudgment, MorDecl, ObDecl } from "catlog-wasm";
@@ -58,28 +59,42 @@ export function ModelCellEditor(props: FormalCellEditorProps) {
return (
- {(theory) => (
- props.changeContent((content) => f(content as ObDecl))}
- isActive={props.isActive}
- actions={props.actions}
- theory={theory()}
- />
- )}
+ {(theory) => {
+ const obDecl = () => props.content as ObDecl;
+ const editor = () =>
+ theory().modelObTypeMeta(obDecl().obType)?.editor ?? ObjectCellEditor;
+ return (
+ void) =>
+ props.changeContent((content) => f(content as ObDecl))
+ }
+ isActive={props.isActive}
+ actions={props.actions}
+ theory={theory()}
+ />
+ );
+ }}
- {(theory) => (
-
- props.changeContent((content) => f(content as MorDecl))
- }
- isActive={props.isActive}
- actions={props.actions}
- theory={theory()}
- />
- )}
+ {(theory) => {
+ const morDecl = () => props.content as MorDecl;
+ const editor = () =>
+ theory().modelMorTypeMeta(morDecl().morType)?.editor ?? MorphismCellEditor;
+ return (
+ void) =>
+ props.changeContent((content) => f(content as MorDecl))
+ }
+ isActive={props.isActive}
+ actions={props.actions}
+ theory={theory()}
+ />
+ );
+ }}
{
if (NotebookUtils.hasFormalCells(liveDoc().doc.notebook)) {
- return props.liveModel.theory()?.migrationTargets ?? [];
+ const theory = props.liveModel.theory();
+ if (!theory) return [];
+ const baseId = liveDoc().doc.theory;
+ const editorVariantIds = stdTheories.getEditorVariantIds(baseId);
+ return [baseId, ...editorVariantIds, ...theory.migrationTargets];
} else {
// If the model has no formal cells, allow any theory to be selected.
return undefined;
@@ -19,7 +23,7 @@ export function ModelInfo(props: { liveModel: LiveModelDoc }) {
return (
migrateModelDocument(props.liveModel, id, stdTheories)}
theories={selectableTheories()}
/>
diff --git a/packages/frontend/src/model/model_library.ts b/packages/frontend/src/model/model_library.ts
index 4f96632f7..54998e480 100644
--- a/packages/frontend/src/model/model_library.ts
+++ b/packages/frontend/src/model/model_library.ts
@@ -24,7 +24,7 @@ import {
import { type Api, findAndMigrate, type LiveDoc, makeLiveDoc } from "../api";
import { NotebookUtils } from "../notebook/types";
import type { Theory, TheoryLibrary } from "../theory";
-import type { LiveModelDoc, ModelDocument } from "./document";
+import { type LiveModelDoc, type ModelDocument, effectiveTheoryId } from "./document";
/** An elaborated model along with its validation status. */
export type ValidatedModel =
@@ -241,7 +241,7 @@ export class ModelLibrary {
return [theory, validatedModel];
}
- const theory = await theories.get(doc.theory);
+ const theory = await theories.get(effectiveTheoryId(doc as ModelDocument));
let validatedModel: ValidatedModel;
try {
if (this.isElaborating.has(key)) {
@@ -312,7 +312,14 @@ function elaborateAndValidateModel(
/** Does the patch to the model document affect its formal content? */
function isPatchToFormalContent(doc: Document, patch: Patch): boolean {
const path = patch.path;
- if (!(path[0] === "type" || path[0] === "theory" || path[0] === "notebook")) {
+ if (
+ !(
+ path[0] === "type" ||
+ path[0] === "theory" ||
+ path[0] === "editorVariant" ||
+ path[0] === "notebook"
+ )
+ ) {
// Ignore changes to top-level data like document name.
return false;
}
diff --git a/packages/frontend/src/model/string_diagram_morphism_cell_editor.module.css b/packages/frontend/src/model/string_diagram_morphism_cell_editor.module.css
new file mode 100644
index 000000000..5130444ac
--- /dev/null
+++ b/packages/frontend/src/model/string_diagram_morphism_cell_editor.module.css
@@ -0,0 +1,89 @@
+.morphism {
+ --add-wire-height: 1.2em;
+
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ gap: 0;
+ margin-block: calc(var(--add-wire-height) / 2);
+ transition: margin-block 0.15s;
+}
+
+.wires {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 2px;
+ min-width: 3em;
+
+ &.left {
+ align-items: flex-end;
+ }
+
+ &.right {
+ align-items: flex-start;
+ }
+}
+
+.wire {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0;
+}
+
+.wireLine {
+ width: 1.5ex;
+ height: 0;
+ border-top: 2px solid var(--color-foreground);
+ flex-shrink: 0;
+}
+
+.box {
+ border: 2px solid var(--color-foreground);
+ border-radius: 2px;
+ padding: 0.3em 0.6em;
+ min-width: 4em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--color-background);
+
+ & input {
+ text-align: center;
+ }
+}
+
+.addWire {
+ cursor: pointer;
+ opacity: 0;
+ height: 0;
+ overflow: hidden;
+ pointer-events: none;
+ transition:
+ opacity 0.15s,
+ height 0.15s;
+}
+
+.addWireButton {
+ font-size: 0.85em;
+ user-select: none;
+}
+
+/* stylelint-disable no-descending-specificity -- intentional override on interactive states */
+.morphism:hover,
+.morphism:focus-within {
+ margin-block: 0;
+
+ & .addWire {
+ opacity: 0.3;
+ height: var(--add-wire-height);
+ pointer-events: auto;
+
+ &:hover {
+ opacity: 0.7;
+ }
+ }
+}
+
+/* stylelint-enable no-descending-specificity */
diff --git a/packages/frontend/src/model/string_diagram_morphism_cell_editor.tsx b/packages/frontend/src/model/string_diagram_morphism_cell_editor.tsx
new file mode 100644
index 000000000..870d0f8e5
--- /dev/null
+++ b/packages/frontend/src/model/string_diagram_morphism_cell_editor.tsx
@@ -0,0 +1,335 @@
+import { deepEqual } from "fast-equals";
+import { Index, batch, createMemo, createSignal, useContext } from "solid-js";
+import invariant from "tiny-invariant";
+
+import { NameInput } from "catcolab-ui-components";
+import type { Modality, MorDecl, Ob, ObOp, QualifiedName } from "catlog-wasm";
+import { ObIdInput } from "../components";
+import type { CellActions } from "../notebook";
+import type { Theory } from "../theory";
+import { deepCopyJSON } from "../util/deepcopy";
+import { LiveModelContext } from "./context";
+
+import styles from "./string_diagram_morphism_cell_editor.module.css";
+
+type ActiveInput =
+ | { zone: "name" }
+ | { zone: "dom"; index: number }
+ | { zone: "cod"; index: number };
+
+/** Unwrap `App(op, ob)` to get the inner `ob`. */
+function unwrapApp(ob: Ob | null, applyOp: ObOp | undefined): Ob | null {
+ if (!ob || !applyOp) {
+ return ob;
+ }
+ if (ob.tag === "App" && deepEqual(ob.content.op, applyOp)) {
+ return ob.content.ob;
+ }
+ return null;
+}
+
+/** Wrap an `ob` with `App(op, ob)`. */
+function wrapApp(ob: Ob | null, applyOp: ObOp | undefined): Ob | null {
+ if (!ob || !applyOp) {
+ return ob;
+ }
+ return { tag: "App", content: { op: applyOp, ob } };
+}
+
+/** Extract the list of objects from a domain/codomain `Ob`. */
+function getObList(ob: Ob | null, applyOp: ObOp | undefined): Array {
+ const inner = unwrapApp(ob, applyOp);
+ if (inner?.tag === "List") {
+ return inner.content.objects;
+ }
+ return [];
+}
+
+/** A column of wire inputs, used for both domain (left) and codomain (right). */
+function WireColumn(props: {
+ obs: Array;
+ side: "left" | "right";
+ isInvalid: boolean;
+ completions: QualifiedName[] | undefined;
+ isActive: (index: number) => boolean;
+ insertWire: (index: number) => void;
+ updateOb: (index: number, ob: Ob | null) => void;
+ deleteWire: (index: number) => void;
+ activateWire: (index: number) => void;
+ activateName: () => void;
+ activateAbove: (() => void) | undefined;
+ activateBelow: (() => void) | undefined;
+ hasFocused: (() => void) | undefined;
+}) {
+ const liveModel = useContext(LiveModelContext);
+ invariant(liveModel, "Live model should be provided as context");
+
+ const wireInput = (ob: () => Ob | null, i: number) => (
+ props.updateOb(i, newOb)}
+ placeholder="..."
+ completions={props.completions}
+ idToLabel={(id) => liveModel().elaboratedModel()?.obGeneratorLabel(id)}
+ labelToId={(label) => liveModel().elaboratedModel()?.obGeneratorWithLabel(label)}
+ isInvalid={props.isInvalid}
+ isActive={props.isActive(i)}
+ deleteBackward={() => {
+ props.deleteWire(i);
+ if (props.obs.length === 0) {
+ props.activateName();
+ } else if (i > 0) {
+ props.activateWire(i - 1);
+ }
+ }}
+ deleteForward={() => {
+ props.deleteWire(i);
+ if (props.obs.length === 0) {
+ props.activateName();
+ }
+ }}
+ exitBackward={() => {
+ if (i > 0) {
+ props.activateWire(i - 1);
+ } else if (props.side === "left") {
+ props.activateAbove?.();
+ } else {
+ props.activateName();
+ }
+ }}
+ exitForward={() => {
+ if (i < props.obs.length - 1) {
+ props.activateWire(i + 1);
+ } else if (props.side === "left") {
+ props.activateName();
+ } else {
+ props.activateBelow?.();
+ }
+ }}
+ exitLeft={props.side === "right" ? props.activateName : undefined}
+ exitRight={props.side === "left" ? props.activateName : undefined}
+ interceptKeyDown={(evt) => {
+ if (evt.key === ",") {
+ props.insertWire(i + 1);
+ return true;
+ }
+ return false;
+ }}
+ hasFocused={() => {
+ props.activateWire(i);
+ props.hasFocused?.();
+ }}
+ />
+ );
+
+ return (
+
+
+ {(ob, i) => (
+
+ {props.side === "left" && wireInput(ob, i)}
+
+ {props.side === "right" && wireInput(ob, i)}
+
+ )}
+
+
{
+ props.insertWire(props.obs.length);
+ props.hasFocused?.();
+ evt.preventDefault();
+ }}
+ >
+ {props.side === "left" &&
+}
+
+ {props.side === "right" &&
+}
+
+
+ );
+}
+
+/** Editor for a morphism declaration cell in string diagram style.
+
+Renders the transition as a box with input wires on the left and output wires
+on the right, where each wire is a separate input field for a domain/codomain
+element.
+ */
+export default function StringDiagramMorphismCellEditor(props: {
+ morphism: MorDecl;
+ modifyMorphism: (f: (decl: MorDecl) => void) => void;
+ isActive: boolean;
+ actions: CellActions;
+ theory: Theory;
+}) {
+ const liveModel = useContext(LiveModelContext);
+ invariant(liveModel, "Live model should be provided as context");
+
+ const [active, setActive] = createSignal({ zone: "name" });
+
+ const morTypeMeta = () => props.theory.modelMorTypeMeta(props.morphism.morType);
+ const domApplyOp = () => morTypeMeta()?.domain?.apply;
+ const codApplyOp = () => morTypeMeta()?.codomain?.apply;
+
+ /** Rebuild a domain/codomain Ob from a list of objects. */
+ const makeObList = (objects: Array, applyOp: ObOp | undefined): Ob | null => {
+ if (!applyOp) {
+ return null;
+ }
+ const domObjType = props.theory.theory.dom(applyOp);
+ const modality: Modality =
+ domObjType?.tag === "ModeApp" ? domObjType.content.modality : "SymmetricList";
+ return wrapApp({ tag: "List", content: { modality, objects } }, applyOp);
+ };
+
+ const domType = createMemo(() => {
+ const op = domApplyOp();
+ return op === undefined
+ ? props.theory.theory.src(props.morphism.morType)
+ : props.theory.theory.dom(op);
+ });
+
+ /** The inner element type (unwrapped from ModeApp) for completions. */
+ const elementObType = createMemo(() => {
+ const dt = domType();
+ return dt?.tag === "ModeApp" ? dt.content.obType : dt;
+ });
+
+ const domObs = () => getObList(props.morphism.dom, domApplyOp());
+ const codObs = () => getObList(props.morphism.cod, codApplyOp());
+
+ const setDomObs = (objects: Array) => {
+ const ob = makeObList(objects, domApplyOp());
+ props.modifyMorphism((mor) => {
+ mor.dom = ob;
+ });
+ };
+
+ const setCodObs = (objects: Array) => {
+ const ob = makeObList(objects, codApplyOp());
+ props.modifyMorphism((mor) => {
+ mor.cod = ob;
+ });
+ };
+
+ const updateDomObs = (f: (objects: Array) => void) => {
+ const objects = deepCopyJSON(domObs());
+ f(objects);
+ setDomObs(objects);
+ };
+
+ const updateCodObs = (f: (objects: Array) => void) => {
+ const objects = deepCopyJSON(codObs());
+ f(objects);
+ setCodObs(objects);
+ };
+
+ const insertDom = (i: number) => {
+ batch(() => {
+ updateDomObs((objects) => objects.splice(i, 0, null));
+ setActive({ zone: "dom", index: i });
+ });
+ };
+
+ const insertCod = (i: number) => {
+ batch(() => {
+ updateCodObs((objects) => objects.splice(i, 0, null));
+ setActive({ zone: "cod", index: i });
+ });
+ };
+
+ const completions = () => liveModel().elaboratedModel()?.obGeneratorsWithType(elementObType());
+
+ const errors = () => {
+ const validated = liveModel().validatedModel();
+ if (validated?.tag !== "Invalid") {
+ return [];
+ }
+ return validated.errors.filter((err) => err.content === props.morphism.id);
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/frontend/src/page/document_page_sidebar.tsx b/packages/frontend/src/page/document_page_sidebar.tsx
index e08994c69..c74ee474f 100644
--- a/packages/frontend/src/page/document_page_sidebar.tsx
+++ b/packages/frontend/src/page/document_page_sidebar.tsx
@@ -196,8 +196,8 @@ function DocumentsTreeLeaf(props: {
const iconLetters = createMemo(() => {
const doc = props.doc.liveDoc.doc;
- const theoryId = doc.type === "model" ? doc.theory : undefined;
- if (theoryId && theories && props.doc.liveDoc.doc.type === "model") {
+ if (doc.type === "model" && theories) {
+ const theoryId = doc.editorVariant ?? doc.theory;
try {
const theoryMeta = theories.getMetadata(theoryId);
return theoryMeta.iconLetters;
diff --git a/packages/frontend/src/page/toolbar.tsx b/packages/frontend/src/page/toolbar.tsx
index 2bc5b9a31..d0e86b199 100644
--- a/packages/frontend/src/page/toolbar.tsx
+++ b/packages/frontend/src/page/toolbar.tsx
@@ -1,9 +1,10 @@
import { A, useNavigate } from "@solidjs/router";
import CircleHelp from "lucide-solid/icons/circle-help";
-import type { JSX } from "solid-js";
+import { type JSX, useContext } from "solid-js";
+import invariant from "tiny-invariant";
import { IconButton } from "catcolab-ui-components";
-import type { TheoryMeta } from "../theory";
+import { TheoryLibraryContext, type TheoryMeta } from "../theory";
import { DefaultAppMenu } from "./menubar";
import "./toolbar.css";
@@ -58,10 +59,14 @@ function theoryHelpTooltip(meta: TheoryMeta) {
export function TheoryHelpButton(props: { meta: TheoryMeta }) {
const navigate = useNavigate();
+ const theories = useContext(TheoryLibraryContext);
+ invariant(theories, "Library of theories must be provided as context");
+
+ const helpId = () => theories.getBaseTheoryId(props.meta.id) ?? props.meta.id;
return (
navigate(`/help/logics/${props.meta.id}`)}
+ onClick={() => navigate(`/help/logics/${helpId()}`)}
tooltip={theoryHelpTooltip(props.meta)}
>
diff --git a/packages/frontend/src/stdlib/theories.ts b/packages/frontend/src/stdlib/theories.ts
index e2466255d..0bd45b134 100644
--- a/packages/frontend/src/stdlib/theories.ts
+++ b/packages/frontend/src/stdlib/theories.ts
@@ -1,3 +1,5 @@
+import { lazy } from "solid-js";
+
import { TheoryLibrary } from "../theory";
import { compositionPattern } from "./generic_analyses";
@@ -50,6 +52,28 @@ stdTheories.add(
description: "Place/transition networks",
iconLetters: ["P", "n"],
group: "Systems",
+ editorVariants: [
+ {
+ id: "petri-net-string-diagram",
+ name: "Petri net (string diagram transitions)",
+ description: "Petri net with a string diagram style transition editor",
+ iconLetters: ["P", "n"],
+ group: "Systems",
+ editorOverrides: {
+ morEditors: [
+ {
+ morType: {
+ tag: "Hom" as const,
+ content: { tag: "Basic" as const, content: "Object" },
+ },
+ editor: lazy(
+ () => import("../model/string_diagram_morphism_cell_editor"),
+ ),
+ },
+ ],
+ },
+ },
+ ],
},
async () => (await import("./theories/petri-net")).default,
);
diff --git a/packages/frontend/src/theory/theory.ts b/packages/frontend/src/theory/theory.ts
index ca6ed0bc0..3557a81c0 100644
--- a/packages/frontend/src/theory/theory.ts
+++ b/packages/frontend/src/theory/theory.ts
@@ -1,8 +1,13 @@
+import { deepEqual } from "fast-equals";
+import type { Component } from "solid-js";
+
import type { KbdKey } from "catcolab-ui-components";
-import type { DblModel, DblTheory, MorType, ObOp, ObType } from "catlog-wasm";
+import type { DblModel, DblTheory, MorDecl, MorType, ObDecl, ObOp, ObType } from "catlog-wasm";
import type { DiagramAnalysisComponent, ModelAnalysisComponent } from "../analysis";
+import type { CellActions } from "../notebook";
import { uniqueIndexArray } from "../util/indexing";
import type { ArrowStyle } from "../visualization";
+import type { TheoryMeta } from "./theory_library";
import { MorTypeMap, ObTypeMap } from "./types";
/** A double theory configured for the frontend.
@@ -134,7 +139,49 @@ export class Theory {
/** List of IDs of theories to which models of this theory can be migrated. */
get migrationTargets(): Array {
- return this.inclusions.concat(this.pushforwards.map((m) => m.target));
+ return [...this.inclusions, ...this.pushforwards.map((m) => m.target)];
+ }
+
+ /** Create a derived theory by applying editor overrides for a named editor variant.
+
+ The derived theory shares the same underlying double theory, analyses,
+ instance types, and other configuration. Only the editor components on
+ the specified model types are replaced.
+ */
+ deriveEditorVariant(editorVariantMeta: TheoryMeta, overrides: EditorVariantOverrides): Theory {
+ // Clone modelTypes with editor overrides applied.
+ const modelTypes = this.modelTypes.map((meta): ModelTypeMeta => {
+ if (meta.tag === "ObType" && overrides.obEditors) {
+ const override = overrides.obEditors.find((o) => deepEqual(o.obType, meta.obType));
+ if (override) {
+ return { ...meta, editor: override.editor };
+ }
+ } else if (meta.tag === "MorType" && overrides.morEditors) {
+ const override = overrides.morEditors.find((o) =>
+ deepEqual(o.morType, meta.morType),
+ );
+ if (override) {
+ return { ...meta, editor: override.editor };
+ }
+ }
+ return meta;
+ });
+
+ return new Theory({
+ id: editorVariantMeta.id,
+ theory: this.theory,
+ help: this.help,
+ name: editorVariantMeta.name,
+ description: editorVariantMeta.description,
+ inclusions: this.inclusions,
+ pushforwards: this.pushforwards,
+ modelTypes,
+ modelAnalyses: this.modelAnalyses,
+ onlyFreeModels: this.onlyFreeModels,
+ instanceOfName: this.instanceOfName,
+ instanceTypes: this.instanceTypes,
+ diagramAnalyses: this.diagramAnalyses,
+ });
}
/** Get metadata for an object type as used in models. */
@@ -218,10 +265,45 @@ export type ModelTypeMeta =
| ({ tag: "ObType" } & ModelObTypeMeta)
| ({ tag: "MorType" } & ModelMorTypeMeta);
+/** Editor overrides for an editor variant of a theory.
+
+Specifies which editor components should replace the defaults for particular
+object or morphism types.
+ */
+export type EditorVariantOverrides = {
+ obEditors?: Array<{ obType: ObType; editor: Component }>;
+ morEditors?: Array<{ morType: MorType; editor: Component }>;
+};
+
+/** Props for a custom object cell editor component. */
+export type ObjectEditorProps = {
+ object: ObDecl;
+ modifyObject: (f: (decl: ObDecl) => void) => void;
+ isActive: boolean;
+ actions: CellActions;
+ theory: Theory;
+};
+
+/** Props for a custom morphism cell editor component. */
+export type MorphismEditorProps = {
+ morphism: MorDecl;
+ modifyMorphism: (f: (decl: MorDecl) => void) => void;
+ isActive: boolean;
+ actions: CellActions;
+ theory: Theory;
+};
+
/** Metadata for an object type as used in models. */
export type ModelObTypeMeta = BaseTypeMeta & {
/** Object type in the underlying double theory. */
obType: ObType;
+
+ /** Custom editor component for objects of this type.
+
+ When set, this component is used instead of the default object editor.
+ It must accept the same props interface as the default editor.
+ */
+ editor?: Component;
};
/** Metadata for a morphism type as used in models. */
@@ -244,6 +326,13 @@ export type ModelMorTypeMeta = BaseTypeMeta & {
/** Metadata for codomain of morphism of this type. */
codomain?: MorDomainMeta;
+
+ /** Custom editor component for morphisms of this type.
+
+ When set, this component is used instead of the default morphism editor.
+ It must accept the same props interface as the default editor.
+ */
+ editor?: Component;
};
/** Metadata controlling the domain or codomain of a morphism. */
diff --git a/packages/frontend/src/theory/theory_library.ts b/packages/frontend/src/theory/theory_library.ts
index 68647a0b8..1b0394259 100644
--- a/packages/frontend/src/theory/theory_library.ts
+++ b/packages/frontend/src/theory/theory_library.ts
@@ -1,4 +1,33 @@
-import { type ModelAnalysisMeta, Theory } from "./theory";
+import { type EditorVariantOverrides, type ModelAnalysisMeta, Theory } from "./theory";
+
+/** Display metadata for an editor variant of a theory.
+
+An editor variant shares the same underlying double theory but uses different
+editor components for some types (e.g., a string diagram editor for morphisms).
+ */
+export type EditorVariantMeta = {
+ /** Unique identifier of the editor variant. */
+ id: string;
+
+ /** Human-readable name for the editor variant. */
+ name: string;
+
+ /** Short description of the editor variant. */
+ description: string;
+
+ /** Two-letter icon abbreviation for the editor variant. */
+ iconLetters: [string, string];
+
+ /** Group to which the editor variant belongs. */
+ group?: string;
+
+ /** Editor component overrides for this editor variant.
+
+ Specifies which editor components replace the defaults for particular types.
+ Components can be wrapped with SolidJS `lazy()` to defer loading.
+ */
+ editorOverrides?: EditorVariantOverrides;
+};
/** Frontend metadata for a double theory.
@@ -27,6 +56,15 @@ export type TheoryMeta = {
/** Group to which the theory belongs. */
group?: string;
+
+ /** Editor variants of this theory.
+
+ Each editor variant appears as a separate selectable option in the theory
+ picker but shares the same underlying theory — only the editors differ.
+ Editor variants share the base theory's help page and are hidden from
+ the help overview.
+ */
+ editorVariants?: EditorVariantMeta[];
};
type TheoryConstructor = (meta: TheoryMeta) => Theory;
@@ -49,12 +87,31 @@ type GenericModelAnalysis = {
Theories are lazy loaded.
*/
export class TheoryLibrary {
- /** Map from theory ID to metadata about the theory. */
+ /** Map from theory ID to metadata about the theory.
+
+ Contains entries for both base theories and their editor variants.
+ */
private readonly metaMap: Map;
- /** Map from theory ID to the theory itself or the constructor for it. */
+ /** Map from theory ID to the theory itself or the constructor for it.
+
+ Only contains entries for base theories, not editor variants. Editor
+ variants are derived from their base theory on demand.
+ */
private readonly theoryMap: Map Promise)>;
+ /** Map from editor variant ID to its metadata and base theory ID. */
+ private readonly editorVariantMetaMap: Map<
+ string,
+ { editorVariant: EditorVariantMeta; baseId: string }
+ >;
+
+ /** Map from base theory ID to the IDs of its editor variants. */
+ private readonly baseToEditorVariants: Map;
+
+ /** Map from editor variant or base theory ID to its cached derived Theory. */
+ private readonly editorVariantCache: Map;
+
/** ID of the default theory for new models. */
private defaultTheoryId: string | undefined;
@@ -63,10 +120,18 @@ export class TheoryLibrary {
constructor() {
this.metaMap = new Map();
this.theoryMap = new Map();
+ this.editorVariantMetaMap = new Map();
+ this.baseToEditorVariants = new Map();
+ this.editorVariantCache = new Map();
this.genericModelAnalyses = [];
}
- /** Add a theory to the library. */
+ /** Add a theory to the library.
+
+ If the metadata includes `editorVariants`, each editor variant is
+ automatically registered as a selectable entry that shares the same
+ underlying theory.
+ */
add(meta: TheoryMeta, cons: () => Promise) {
if (!meta.id) {
throw new Error("The ID of a theory must be a non-empty string");
@@ -83,6 +148,31 @@ export class TheoryLibrary {
}
this.defaultTheoryId = meta.id;
}
+
+ // Register editor variants.
+ if (meta.editorVariants) {
+ const editorVariantIds: string[] = [];
+ for (const editorVariant of meta.editorVariants) {
+ if (this.metaMap.has(editorVariant.id)) {
+ throw new Error(`Theory with ID ${editorVariant.id} already defined`);
+ }
+ // Store editor variant metadata as a TheoryMeta entry so it
+ // appears in the theory picker alongside regular theories.
+ this.metaMap.set(editorVariant.id, {
+ id: editorVariant.id,
+ name: editorVariant.name,
+ description: editorVariant.description,
+ iconLetters: editorVariant.iconLetters,
+ group: editorVariant.group,
+ });
+ this.editorVariantMetaMap.set(editorVariant.id, {
+ editorVariant,
+ baseId: meta.id,
+ });
+ editorVariantIds.push(editorVariant.id);
+ }
+ this.baseToEditorVariants.set(meta.id, editorVariantIds);
+ }
}
/** Is there a theory with the given ID? */
@@ -93,9 +183,35 @@ export class TheoryLibrary {
/** Get a theory by ID.
A theory is instantiated and cached the first time it is retrieved.
+ For editor variants, the base theory is loaded and the editor variant is
+ derived from it by applying editor overrides.
*/
async get(id: string): Promise {
- // Attempt to retrieve cached theory.
+ // Check for cached editor variant.
+ const cachedEditorVariant = this.editorVariantCache.get(id);
+ if (cachedEditorVariant) {
+ return cachedEditorVariant;
+ }
+
+ // If this is an editor variant, load the base theory and derive.
+ const editorVariantEntry = this.editorVariantMetaMap.get(id);
+ if (editorVariantEntry !== undefined) {
+ const base = await this.get(editorVariantEntry.baseId);
+ const editorVariantMeta = this.metaMap.get(id);
+ if (!editorVariantMeta) {
+ throw new Error(`No metadata for editor variant ${id}`);
+ }
+ const editorVariant = base.deriveEditorVariant(
+ editorVariantMeta,
+ editorVariantEntry.editorVariant.editorOverrides ?? {},
+ );
+ // Generic analyses are inherited from the base via modelAnalyses,
+ // so we don't add them again here.
+ this.editorVariantCache.set(id, editorVariant);
+ return editorVariant;
+ }
+
+ // Attempt to retrieve cached base theory.
const meta = this.metaMap.get(id);
const theoryOrCons = this.theoryMap.get(id);
if (meta === undefined || theoryOrCons === undefined) {
@@ -124,6 +240,24 @@ export class TheoryLibrary {
return meta;
}
+ /** Gets the base theory ID for an editor variant, if the given ID is an editor variant. */
+ getBaseTheoryId(id: string): string | undefined {
+ return this.editorVariantMetaMap.get(id)?.baseId;
+ }
+
+ /** Whether the given ID is an editor variant of another theory. */
+ isEditorVariant(id: string): boolean {
+ return this.editorVariantMetaMap.has(id);
+ }
+
+ /** Gets the IDs of all editor variants of the given theory.
+
+ Returns an empty array if the theory has no editor variants.
+ */
+ getEditorVariantIds(id: string): string[] {
+ return this.baseToEditorVariants.get(id) ?? [];
+ }
+
/** Gets metadata for the default theory for new models.
Throws an error if no default has been set.
diff --git a/packages/notebook-types/src/v1/document.rs b/packages/notebook-types/src/v1/document.rs
index 019a14447..080cece79 100644
--- a/packages/notebook-types/src/v1/document.rs
+++ b/packages/notebook-types/src/v1/document.rs
@@ -1,6 +1,6 @@
use crate::v0;
-use crate::v0::AnalysisType;
pub use crate::v0::document::DocumentType;
+use crate::v0::AnalysisType;
use super::analysis::Analysis;
use super::api::Link;
@@ -16,6 +16,12 @@ use tsify::Tsify;
pub struct ModelDocumentContent {
pub name: String,
pub theory: String,
+ #[serde(
+ default,
+ skip_serializing_if = "Option::is_none",
+ rename = "editorVariant"
+ )]
+ pub editor_variant: Option,
pub notebook: Notebook,
pub version: String,
}
@@ -60,6 +66,7 @@ impl Document {
v0::Document::Model(old) => Document::Model(ModelDocumentContent {
name: old.name,
theory: old.theory,
+ editor_variant: None,
notebook: Notebook::migrate_from_v0(old.notebook),
version: "1".to_string(),
}),