Skip to content

Commit 2f18b7b

Browse files
authored
Merge pull request #1247 from ToposInstitute/kb/equations-ui
UI for editing path equations in schemas and ologs
2 parents 4615578 + 9bb9748 commit 2f18b7b

10 files changed

Lines changed: 626 additions & 4 deletions

File tree

‎packages/document-methods/src/model.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ export const newMorphismDecl = (morType: MorType): ModelJudgment & { tag: "morph
3838
cod: null,
3939
});
4040

41+
/** Create a new equation declaration. */
42+
export const newEquationDecl = (): ModelJudgment & { tag: "equation" } => ({
43+
tag: "equation",
44+
id: v7(),
45+
name: "",
46+
lhs: null,
47+
rhs: null,
48+
});
49+
4150
/** Create a new instantiation of an existing model. */
4251
export const newInstantiatedModel = (
4352
model?: Link | null,

‎packages/document-types/src/v0/model.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ pub enum Mor {
3333
Basic(String),
3434

3535
/// Composite of morphisms.
36-
Composite(Box<Path<Ob, Mor>>),
36+
Composite(#[tsify(type = "Path<Ob, Mor>")] Box<Path<Ob, Mor>>),
3737

3838
/// Morphism between tabulated morphisms, a commutative square.
3939
TabulatorSquare {

‎packages/frontend/src/model/editors.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Component } from "solid-js";
22

33
import type { FocusHandle } from "catcolab-ui-components";
4-
import type { MorDecl, ObDecl } from "catlog-wasm";
4+
import type { EqnDecl, MorDecl, ObDecl } from "catlog-wasm";
55
import type { CellActions } from "../notebook";
66
import type { Theory } from "../theory";
77
import type { MorTypeMap, ObTypeMap } from "../theory/types";
@@ -33,3 +33,12 @@ export type MorphismEditorProps = {
3333
actions: CellActions;
3434
theory: Theory;
3535
};
36+
37+
/** Props for an equation cell editor component in a model. */
38+
export type EquationEditorProps = {
39+
equation: EqnDecl;
40+
modifyEquation: (f: (decl: EqnDecl) => void) => void;
41+
focus: FocusHandle;
42+
actions: CellActions;
43+
theory: Theory;
44+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
.decl {
2+
display: flex;
3+
flex-direction: row;
4+
align-items: center;
5+
gap: 0.125rem;
6+
margin-top: 0.25rem;
7+
margin-bottom: 0.25rem;
8+
}
9+
10+
.name {
11+
display: inline-flex;
12+
align-items: baseline;
13+
}
14+
15+
.nameSeparator {
16+
color: var(--color-gray-800);
17+
padding-left: 0.375rem;
18+
padding-right: 0.625rem;
19+
}
20+
21+
/* Lay the two sides out horizontally, but allow them to wrap onto separate
22+
lines when there is not enough horizontal space. */
23+
.equation {
24+
display: flex;
25+
flex-direction: row;
26+
flex-wrap: wrap;
27+
align-items: center;
28+
justify-content: flex-start;
29+
gap: 0.125rem 0.5rem;
30+
min-width: 0;
31+
}
32+
33+
.equals {
34+
font-size: 1.5em;
35+
text-align: center;
36+
line-height: 1;
37+
color: var(--color-gray-950);
38+
margin: 0 0.25rem;
39+
}
40+
41+
.morList {
42+
display: flex;
43+
min-width: 0;
44+
45+
ul {
46+
flex-wrap: wrap;
47+
}
48+
}
49+
50+
.separator {
51+
padding: 0 0.25em;
52+
}
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import { deepEqual } from "fast-equals";
2+
import { createEffect, createMemo, createSignal, useContext } from "solid-js";
3+
import invariant from "tiny-invariant";
4+
import { P, match } from "ts-pattern";
5+
6+
import {
7+
type FocusHandle,
8+
InlineListEditor,
9+
NameInput,
10+
useChildFocus,
11+
} from "catcolab-ui-components";
12+
import type { DblModel, Mor, Ob, QualifiedName, Uuid } from "catlog-wasm";
13+
import { removeProxyAndCopy } from "../util/remove_proxy_and_copy";
14+
import { LiveModelContext } from "./context";
15+
import type { EquationEditorProps } from "./editors";
16+
import { PathMorInput } from "./path_mor_input";
17+
18+
import styles from "./equation_cell_editor.module.css";
19+
20+
type EquationCellInput = "name" | "lhs" | "rhs";
21+
22+
/** Editor for an equation cell in a model. */
23+
export default function EquationCellEditor(props: EquationEditorProps) {
24+
const liveModel = useContext(LiveModelContext);
25+
invariant(liveModel, "Live model should be provided as context");
26+
27+
// oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted cell.
28+
const focus = useChildFocus<EquationCellInput>(props.focus, { default: "name" });
29+
30+
const completions = (): QualifiedName[] | undefined =>
31+
liveModel().elaboratedModel()?.morGenerators();
32+
33+
const lhs = createMemo(() => morToList(props.equation.lhs));
34+
const rhs = createMemo(() => morToList(props.equation.rhs));
35+
36+
// The domain of the left-hand side, used to require that the right-hand side
37+
// begins on the same object so the two sides remain parallel.
38+
const lhsDom = createMemo((): Ob | null => {
39+
const model = liveModel().elaboratedModel();
40+
const first = lhs().find((mor): mor is Mor => mor != null);
41+
if (!model || !first) {
42+
return null;
43+
}
44+
return morDom(model, first);
45+
});
46+
47+
const setLhs = (mors: Array<Mor | null>) =>
48+
props.modifyEquation((eqn) => {
49+
eqn.lhs = removeProxyAndCopy(listToMor(mors));
50+
});
51+
52+
const setRhs = (mors: Array<Mor | null>) =>
53+
props.modifyEquation((eqn) => {
54+
eqn.rhs = removeProxyAndCopy(listToMor(mors));
55+
});
56+
57+
const setName = (name: string) =>
58+
props.modifyEquation((eqn) => {
59+
eqn.name = name;
60+
});
61+
62+
return (
63+
<div class={`formal-judgment ${styles.decl}`}>
64+
<div class={styles.name}>
65+
<NameInput
66+
placeholder="Unnamed"
67+
name={props.equation.name}
68+
setName={setName}
69+
focus={focus.childFocus("name")}
70+
deleteBackward={props.actions.deleteBackward}
71+
deleteForward={props.actions.deleteForward}
72+
exitBackward={props.actions.activateAbove}
73+
exitForward={() => focus.setActiveChild("lhs")}
74+
exitUp={props.actions.activateAbove}
75+
exitDown={() => focus.setActiveChild("lhs")}
76+
exitRight={() => focus.setActiveChild("lhs")}
77+
createBelow={() => focus.setActiveChild("lhs")}
78+
/>
79+
</div>
80+
<div class={styles.nameSeparator}>:</div>
81+
<div class={styles.equation}>
82+
<MorListEditor
83+
mors={lhs()}
84+
setMors={setLhs}
85+
completions={completions()}
86+
focus={focus.childFocus("lhs")}
87+
exitBackward={() => focus.setActiveChild("name")}
88+
exitForward={() => focus.setActiveChild("rhs")}
89+
exitLeft={() => focus.setActiveChild("name")}
90+
exitRight={() => focus.setActiveChild("rhs")}
91+
deleteBackward={() => focus.setActiveChild("name")}
92+
deleteForward={() => focus.setActiveChild("rhs")}
93+
/>
94+
<div class={styles.equals}>{"="}</div>
95+
<MorListEditor
96+
mors={rhs()}
97+
setMors={setRhs}
98+
completions={completions()}
99+
baseDom={lhsDom()}
100+
focus={focus.childFocus("rhs")}
101+
exitBackward={() => focus.setActiveChild("lhs")}
102+
exitForward={props.actions.activateBelow}
103+
exitLeft={() => focus.setActiveChild("lhs")}
104+
exitRight={props.actions.activateBelow}
105+
deleteBackward={() => focus.setActiveChild("lhs")}
106+
deleteForward={props.actions.activateBelow}
107+
/>
108+
</div>
109+
</div>
110+
);
111+
}
112+
113+
function MorListEditor(props: {
114+
mors: Array<Mor | null>;
115+
setMors: (mors: Array<Mor | null>) => void;
116+
completions: QualifiedName[] | undefined;
117+
/** Required domain for the first morphism in the path, if constrained. */
118+
baseDom?: Ob | null;
119+
focus: FocusHandle;
120+
exitBackward: () => void;
121+
exitForward: () => void;
122+
exitLeft: () => void;
123+
exitRight: () => void;
124+
deleteBackward: () => void;
125+
deleteForward: () => void;
126+
}) {
127+
const liveModel = useContext(LiveModelContext);
128+
invariant(liveModel, "Live model should be provided as context");
129+
130+
const [editMors, setEditMors] = createSignal<Array<Mor | null> | null>(null);
131+
132+
const mors = () => editMors() ?? props.mors;
133+
134+
const setMors = (mors: Array<Mor | null>) => {
135+
setEditMors(mors);
136+
props.setMors(mors);
137+
};
138+
139+
createEffect(() => {
140+
if (!props.focus.hasFocus()) {
141+
setEditMors(null);
142+
}
143+
});
144+
145+
// The object that a morphism at `index` must have as its domain in order to
146+
// extend the path: the codomain of the preceding morphism, or `baseDom` for
147+
// the first morphism. `null` means there is no constraint.
148+
const requiredDom = (index: number): Ob | null => {
149+
const model = liveModel().elaboratedModel();
150+
if (!model) {
151+
return null;
152+
}
153+
const items = mors();
154+
for (let i = index - 1; i >= 0; i--) {
155+
const prev = items[i];
156+
if (prev != null) {
157+
return morCod(model, prev);
158+
}
159+
}
160+
return props.baseDom ?? null;
161+
};
162+
163+
// Morphism generators whose domain matches the required domain at `index`.
164+
const morCompletions = (index: number): Uuid[] | undefined => {
165+
const all = props.completions;
166+
if (!all) {
167+
return all;
168+
}
169+
const dom = requiredDom(index);
170+
if (dom === null) {
171+
return all;
172+
}
173+
const model = liveModel().elaboratedModel();
174+
return all.filter((id) => {
175+
const gen = model?.morPresentation(id);
176+
return gen != null && obsEqual(gen.dom, dom);
177+
});
178+
};
179+
180+
// Object generators on which the identity morphism composes at `index`, i.e.
181+
// those equal to the required domain (since `id(X)` has domain and codomain X).
182+
const obCompletions = (index: number): Uuid[] | undefined => {
183+
const model = liveModel().elaboratedModel();
184+
const all = model?.obGenerators();
185+
if (!all) {
186+
return all;
187+
}
188+
const dom = requiredDom(index);
189+
if (dom === null) {
190+
return all;
191+
}
192+
return all.filter((id) => obsEqual(basicOb(id), dom));
193+
};
194+
195+
return (
196+
<div class={styles.morList}>
197+
<InlineListEditor<Mor>
198+
items={mors()}
199+
setItems={setMors}
200+
insertKey=";"
201+
startDelimiter={""}
202+
endDelimiter={""}
203+
separator={() => <div class={styles.separator}>{";"}</div>}
204+
focus={props.focus}
205+
exitBackward={props.exitBackward}
206+
exitForward={props.exitForward}
207+
exitLeft={props.exitLeft}
208+
exitRight={props.exitRight}
209+
deleteBackward={props.deleteBackward}
210+
deleteForward={props.deleteForward}
211+
>
212+
{(mor, setMor, options, index) => (
213+
<PathMorInput
214+
mor={mor()}
215+
setMor={setMor}
216+
placeholder="..."
217+
morCompletions={morCompletions(index)}
218+
obCompletions={obCompletions(index)}
219+
{...options}
220+
/>
221+
)}
222+
</InlineListEditor>
223+
</div>
224+
);
225+
}
226+
227+
/** Build a basic object from its generator id. */
228+
function basicOb(id: Uuid): Ob {
229+
return { tag: "Basic", content: id };
230+
}
231+
232+
/** Domain of a morphism in the model, or `null` if it cannot be computed. */
233+
function morDom(model: DblModel, mor: Mor): Ob | null {
234+
if (model.hasMor(mor)) {
235+
return model.dom(mor);
236+
} else {
237+
return null;
238+
}
239+
}
240+
241+
/** Codomain of a morphism in the model, or `null` if it cannot be computed. */
242+
function morCod(model: DblModel, mor: Mor): Ob | null {
243+
if (model.hasMor(mor)) {
244+
return model.cod(mor);
245+
} else {
246+
return null;
247+
}
248+
}
249+
250+
/** Structural equality of objects, sufficient for comparing model objects. */
251+
function obsEqual(a: Ob | null, b: Ob | null): boolean {
252+
if (a === null || b === null) {
253+
return a === b;
254+
}
255+
return deepEqual(a, b);
256+
}
257+
258+
/** Convert the stored morphism into the editable list shape. */
259+
function morToList(mor: Mor | null): Array<Mor | null> {
260+
if (mor === null) {
261+
return [];
262+
}
263+
const seq = match(mor)
264+
.with({ tag: "Composite", content: { tag: "Seq", content: P.select() } }, (xs) => xs)
265+
.otherwise(() => null);
266+
return seq ?? [mor];
267+
}
268+
269+
/** Convert the editable list into the stored equation side. */
270+
function listToMor(mors: Array<Mor | null>): Mor | null {
271+
const present = mors.filter((mor): mor is Mor => mor !== null);
272+
if (present.length === 0) {
273+
return null;
274+
}
275+
if (present.length === 1) {
276+
return present[0] ?? null;
277+
}
278+
return {
279+
tag: "Composite",
280+
content: {
281+
tag: "Seq",
282+
content: present,
283+
},
284+
};
285+
}

0 commit comments

Comments
 (0)