Skip to content

Commit 4101e43

Browse files
committed
add simple constraint menu
1 parent f67a2a8 commit 4101e43

File tree

5 files changed

+580
-1
lines changed

5 files changed

+580
-1
lines changed
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { inject, injectable } from "inversify";
2+
import "./constraintMenu.css";
3+
import { IActionDispatcher, LocalModelSource, TYPES } from "sprotty";
4+
import { ConstraintRegistry } from "./constraintRegistry";
5+
6+
// Enable hover feature that is used to show validation errors.
7+
// Inline completions are enabled to allow autocompletion of keywords and inputs/label types/label values.
8+
import "monaco-editor/esm/vs/editor/contrib/hover/browser/hoverContribution";
9+
import "monaco-editor/esm/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.js";
10+
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
11+
import { LabelTypeRegistry } from "../labels/LabelTypeRegistry";
12+
import { SETTINGS } from "../settings/Settings";
13+
import { EditorModeController } from "../settings/editorMode";
14+
import { AccordionUiExtension } from "../accordionUiExtension";
15+
16+
@injectable()
17+
export class ConstraintMenu extends AccordionUiExtension {
18+
static readonly ID = "constraint-menu";
19+
private editorContainer: HTMLDivElement = document.createElement("div") as HTMLDivElement;
20+
private validationLabel: HTMLDivElement = document.createElement("div") as HTMLDivElement;
21+
private editor?: monaco.editor.IStandaloneCodeEditor;
22+
private forceReadOnly: boolean;
23+
private optionsMenu?: HTMLDivElement;
24+
private ignoreCheckboxChange = false;
25+
26+
constructor(
27+
@inject(ConstraintRegistry) private readonly constraintRegistry: ConstraintRegistry,
28+
@inject(LabelTypeRegistry) labelTypeRegistry: LabelTypeRegistry,
29+
@inject(TYPES.ModelSource) modelSource: LocalModelSource,
30+
@inject(TYPES.IActionDispatcher) private readonly dispatcher: IActionDispatcher,
31+
@inject(SETTINGS.Mode)
32+
editorModeController: EditorModeController,
33+
) {
34+
super("left", "up");
35+
this.constraintRegistry = constraintRegistry;
36+
this.forceReadOnly = editorModeController.get() !== "edit";
37+
editorModeController.registerListener(() => {
38+
this.forceReadOnly = editorModeController.isReadOnly();
39+
});
40+
constraintRegistry.onUpdate(() => {
41+
if (this.editor) {
42+
const editorText = this.editor.getValue();
43+
// Only update the editor if the constraints have changed
44+
if (editorText !== this.constraintRegistry.getConstraintsAsText()) {
45+
this.editor.setValue(this.constraintRegistry.getConstraintsAsText() || "");
46+
}
47+
}
48+
});
49+
}
50+
51+
id(): string {
52+
return ConstraintMenu.ID;
53+
}
54+
containerClass(): string {
55+
return ConstraintMenu.ID;
56+
}
57+
58+
59+
protected initializeHeaderContent(headerElement: HTMLElement): void {
60+
headerElement.id = 'constraint-menu-expand-title'
61+
headerElement.innerText = 'Constraints'
62+
headerElement.appendChild(this.buildOptionsButton());
63+
}
64+
65+
protected initializeHidableContent(contentElement: HTMLElement): void {
66+
const contentDiv = document.createElement("div");
67+
contentDiv.id = "constraint-menu-content";
68+
contentDiv.appendChild(this.buildConstraintInputWrapper());
69+
contentElement.appendChild(contentDiv)
70+
}
71+
72+
protected initializeContents(containerElement: HTMLElement): void {
73+
super.initializeContents(containerElement);
74+
containerElement.appendChild(this.buildRunButton());
75+
}
76+
77+
private buildConstraintInputWrapper(): HTMLElement {
78+
const wrapper = document.createElement("div");
79+
wrapper.id = "constraint-menu-input";
80+
wrapper.appendChild(this.editorContainer);
81+
this.validationLabel.id = "validation-label";
82+
this.validationLabel.classList.add("valid");
83+
this.validationLabel.innerText = "Valid constraints";
84+
wrapper.appendChild(this.validationLabel);
85+
const keyboardShortcutLabel = document.createElement("div");
86+
keyboardShortcutLabel.innerHTML = "Press <kbd>CTRL</kbd>+<kbd>Space</kbd> for autocompletion";
87+
wrapper.appendChild(keyboardShortcutLabel);
88+
89+
const monacoTheme = /*ThemeManager.useDarkMode ?*/ "vs-dark" //: "vs";
90+
this.editor = monaco.editor.create(this.editorContainer, {
91+
minimap: {
92+
// takes too much space, not useful for our use case
93+
enabled: false,
94+
},
95+
folding: false, // Not supported by our language definition
96+
wordBasedSuggestions: "off", // Does not really work for our use case
97+
scrollBeyondLastLine: false, // Not needed
98+
theme: monacoTheme,
99+
wordWrap: "on",
100+
//language: DSL_LANGUAGE_ID,
101+
scrollBeyondLastColumn: 0,
102+
scrollbar: {
103+
horizontal: "hidden",
104+
vertical: "auto",
105+
// avoid can not scroll page when hover monaco
106+
alwaysConsumeMouseWheel: false,
107+
},
108+
lineNumbers: "on",
109+
readOnly: this.forceReadOnly,
110+
});
111+
112+
this.editor.setValue(this.constraintRegistry.getConstraintsAsText() || "");
113+
114+
this.editor.onDidChangeModelContent(() => {
115+
if (!this.editor) {
116+
return;
117+
}
118+
119+
const model = this.editor?.getModel();
120+
if (!model) {
121+
return;
122+
}
123+
124+
this.constraintRegistry.setConstraints(model.getLinesContent());
125+
126+
const content = model.getLinesContent();
127+
const marker: monaco.editor.IMarkerData[] = [];
128+
const emptyContent = content.length == 0 || (content.length == 1 && content[0] === "");
129+
// empty content gets accepted as valid as it represents no constraints
130+
if (!emptyContent) {
131+
/*const errors = this.tree.verify(content);
132+
marker.push(
133+
...errors.map((e) => ({
134+
severity: monaco.MarkerSeverity.Error,
135+
startLineNumber: e.line,
136+
startColumn: e.startColumn,
137+
endLineNumber: e.line,
138+
endColumn: e.endColumn,
139+
message: e.message,
140+
})),
141+
);*/
142+
}
143+
144+
this.validationLabel.innerText =
145+
marker.length == 0 ? "Valid constraints" : `Invalid constraints: ${marker.length} errors`;
146+
this.validationLabel.classList.toggle("valid", marker.length == 0);
147+
148+
monaco.editor.setModelMarkers(model, "constraint", marker);
149+
});
150+
151+
return wrapper;
152+
}
153+
154+
private buildRunButton(): HTMLElement {
155+
const wrapper = document.createElement("div");
156+
wrapper.id = "run-button-container";
157+
158+
const button = document.createElement("button");
159+
button.id = "run-button";
160+
button.innerHTML = "Run";
161+
button.onclick = () => {
162+
//this.dispatcher.dispatch(AnalyzeDiagramAction.create());
163+
};
164+
165+
wrapper.appendChild(button);
166+
return wrapper;
167+
}
168+
169+
protected onBeforeShow(): void {
170+
this.resizeEditor();
171+
}
172+
173+
private resizeEditor(): void {
174+
// Resize editor to fit content.
175+
// Has ranges for height and width to prevent the editor from getting too small or too large.
176+
const e = this.editor;
177+
if (!e) {
178+
return;
179+
}
180+
181+
// For the height we can use the content height from the editor.
182+
const height = e.getContentHeight();
183+
184+
// For the width we cannot really do this.
185+
// Monaco needs about 500ms to figure out the correct width when initially showing the editor.
186+
// In the mean time the width will be too small and after the update
187+
// the window size will jump visibly.
188+
// So for the width we use this calculation to approximate the width.
189+
const maxLineLength = e
190+
.getValue()
191+
.split("\n")
192+
.reduce((max, line) => Math.max(max, line.length), 0);
193+
const width = 100 + maxLineLength * 8;
194+
195+
const clamp = (value: number, range: readonly [number, number]) =>
196+
Math.min(range[1], Math.max(range[0], value));
197+
198+
const heightRange = [200, 200] as const;
199+
const widthRange = [500, 750] as const;
200+
201+
const cHeight = clamp(height, heightRange);
202+
const cWidth = clamp(width, widthRange);
203+
204+
e.layout({ height: cHeight, width: cWidth });
205+
}
206+
207+
switchTheme(useDark: boolean): void {
208+
this.editor?.updateOptions({ theme: useDark ? "vs-dark" : "vs" });
209+
}
210+
211+
private buildOptionsButton(): HTMLElement {
212+
const btn = document.createElement("button");
213+
btn.id = "constraint-options-button";
214+
btn.title = "Filter…";
215+
btn.innerHTML = '<span class="codicon codicon-kebab-vertical"></span>';
216+
btn.onclick = () => this.toggleOptionsMenu();
217+
return btn;
218+
}
219+
220+
/** show or hide the menu, generate checkboxes on the fly */
221+
private toggleOptionsMenu(): void {
222+
if (this.optionsMenu !== undefined) {
223+
this.optionsMenu.remove();
224+
this.optionsMenu = undefined;
225+
return;
226+
}
227+
228+
// 1) create container
229+
this.optionsMenu = document.createElement("div");
230+
this.optionsMenu.id = "constraint-options-menu";
231+
232+
// 2) add the “All constraints” checkbox at the top
233+
const allConstraints = document.createElement("label");
234+
allConstraints.classList.add("options-item");
235+
236+
const allCb = document.createElement("input");
237+
allCb.type = "checkbox";
238+
allCb.value = "ALL";
239+
allCb.checked = this.constraintRegistry
240+
.getConstraintList()
241+
.map((c) => c.name)
242+
.every((c) => this.constraintRegistry.getSelectedConstraints().includes(c));
243+
244+
allCb.onchange = () => {
245+
if (!this.optionsMenu) return;
246+
247+
this.ignoreCheckboxChange = true;
248+
try {
249+
if (allCb.checked) {
250+
this.optionsMenu.querySelectorAll<HTMLInputElement>("input[type=checkbox]").forEach((cb) => {
251+
if (cb !== allCb) cb.checked = true;
252+
});
253+
/*this.dispatcher.dispatch(
254+
ChooseConstraintAction.create(this.constraintRegistry.getConstraintList().map((c) => c.name)),
255+
);*/
256+
} else {
257+
this.optionsMenu.querySelectorAll<HTMLInputElement>("input[type=checkbox]").forEach((cb) => {
258+
if (cb !== allCb) cb.checked = false;
259+
});
260+
//this.dispatcher.dispatch(ChooseConstraintAction.create([]));
261+
}
262+
} finally {
263+
this.ignoreCheckboxChange = false;
264+
}
265+
};
266+
267+
allConstraints.appendChild(allCb);
268+
allConstraints.appendChild(document.createTextNode("All constraints"));
269+
this.optionsMenu.appendChild(allConstraints);
270+
271+
// 2) pull your dynamic items
272+
const items = this.constraintRegistry.getConstraintList();
273+
274+
// 3) for each item build a checkbox
275+
items.forEach((item) => {
276+
const label = document.createElement("label");
277+
label.classList.add("options-item");
278+
279+
const cb = document.createElement("input");
280+
cb.type = "checkbox";
281+
cb.value = item.name;
282+
cb.checked = this.constraintRegistry.getSelectedConstraints().includes(cb.value);
283+
284+
cb.onchange = () => {
285+
if (this.ignoreCheckboxChange) return;
286+
287+
const checkboxes = this.optionsMenu!.querySelectorAll<HTMLInputElement>("input[type=checkbox]");
288+
const individualCheckboxes = Array.from(checkboxes).filter((cb) => cb !== allCb);
289+
const selected = individualCheckboxes.filter((cb) => cb.checked).map((cb) => cb.value);
290+
291+
allCb.checked = individualCheckboxes.every((cb) => cb.checked);
292+
293+
//this.dispatcher.dispatch(ChooseConstraintAction.create(selected));
294+
};
295+
296+
label.appendChild(cb);
297+
label.appendChild(document.createTextNode(item.name));
298+
this.optionsMenu!.appendChild(label);
299+
});
300+
301+
this.editorContainer.appendChild(this.optionsMenu);
302+
303+
// optional: click-outside handler
304+
const onClickOutside = (e: MouseEvent) => {
305+
const target = e.target as Node;
306+
if (!this.optionsMenu || this.optionsMenu.contains(target)) return;
307+
308+
const button = document.getElementById("constraint-options-button");
309+
if (button && button.contains(target)) return;
310+
311+
this.optionsMenu.remove();
312+
this.optionsMenu = undefined;
313+
document.removeEventListener("click", onClickOutside);
314+
};
315+
document.addEventListener("click", onClickOutside);
316+
}
317+
}

0 commit comments

Comments
 (0)