diff --git a/studio/index.html b/studio/index.html index c67b54b..ee2ae97 100644 --- a/studio/index.html +++ b/studio/index.html @@ -16,6 +16,7 @@ + @@ -48,6 +49,13 @@
+
+
+ + +
+
+
diff --git a/studio/src/main.ts b/studio/src/main.ts index cfecb33..60e4102 100644 --- a/studio/src/main.ts +++ b/studio/src/main.ts @@ -52,6 +52,8 @@ export const RUN_MARKERS = "nlp++ run"; const SAVE_AFTER_MS = 500; // Whether Debug was ticked last time, in this browser. const DEBUG_KEY = "nlp-studio.debug"; +// Whether the text pane was hidden last time, in this browser. +const TEXT_KEY = "nlp-studio.text-pane"; function remembered(key: string): string | null { try { @@ -74,6 +76,13 @@ function listedTree(file: TreeFile): ListedTree { return { name: file.name, pass: file.pass, passName: file.passName, bytes: file.size }; } +// Input files in the order the text list shows them: the input folder's own files first, +// then each folder's. +function byFolder(a: string, b: string): number { + const depth = (p: string) => (p.split("/").length > 2 ? 1 : 0); + return depth(a) - depth(b) || a.localeCompare(b, undefined, { numeric: true }); +} + function darkTheme(): boolean { const set = document.documentElement.dataset.theme; return set ? set === "dark" : window.matchMedia("(prefers-color-scheme: dark)").matches; @@ -103,6 +112,10 @@ export class Studio { passList: Pass[] = []; account: Account | null = null; readonly editor: monaco.editor.IStandaloneCodeEditor; + // The text a run reads, beside the editor: the same model as the input file, so an + // edit in either is the other's too, and is kept, reverted and downloaded the same. + readonly textEditor: monaco.editor.IStandaloneCodeEditor; + private textChoices = ""; readonly panel: RunPanel; readonly drafts = DraftStore.browser(); private readonly models = new Map(); @@ -153,6 +166,34 @@ export class Studio { }); monaco.editor.onDidChangeMarkers(() => this.showProblems()); + this.textEditor = monaco.editor.create(byId("text-editor"), { + automaticLayout: true, + fontSize: 13, + wordWrap: "on", + minimap: { enabled: false }, + scrollBeyondLastLine: false, + lineNumbersMinChars: 3, + theme: darkTheme() ? THEMES.dark : THEMES.light, + }); + this.textEditor.addAction({ + id: "nlp.runAnalyzer", + label: "Run Analyzer", + keybindings: [monaco.KeyCode.F5], + run: () => void this.run(), + }); + byId("text-pick").addEventListener("change", (event) => { + this.inputPath = (event.target as HTMLSelectElement).value || undefined; + this.showRunTarget(); + }); + const toggle = byId("text-toggle"); + toggle.setAttribute("aria-pressed", remembered(TEXT_KEY) === "hidden" ? "false" : "true"); + toggle.addEventListener("click", () => { + const shown = toggle.getAttribute("aria-pressed") !== "true"; + toggle.setAttribute("aria-pressed", String(shown)); + rememberSetting(TEXT_KEY, shown ? "shown" : "hidden"); + this.showText(); + }); + this.panel = new RunPanel(byId("results"), { open: (path, at) => this.openPath(path, at), openTree: (name) => void this.openTree(name), @@ -929,6 +970,48 @@ export class Studio { run.disabled = this.running || !name; run.textContent = this.running ? "Running…" : "Run"; run.classList.toggle("busy", this.running); + this.showText(); + } + + // The text pane: every input file to choose from, grouped by folder, and the chosen one + // to read and edit. Shown while the analyzer has input and the Text button is pressed. + private showText(): void { + const inputs = [...this.models.keys()].filter((p) => p.startsWith("input/")).sort(byFolder); + const shown = inputs.length > 0 && byId("text-toggle").getAttribute("aria-pressed") === "true"; + byId("text-pane").hidden = !shown; + byId("text-toggle").hidden = inputs.length === 0; + document.querySelector(".layout")?.classList.toggle("with-text", shown); + + const pick = byId("text-pick"); + const choices = inputs.join("\n"); + if (choices !== this.textChoices) { + this.textChoices = choices; + pick.replaceChildren(); + const groups = new Map(); + for (const path of inputs) { + const rel = path.slice("input/".length); + const cut = rel.lastIndexOf("/"); + const folder = cut < 0 ? "" : rel.slice(0, cut); + const option = new Option(cut < 0 ? rel : rel.slice(cut + 1), path); + if (this.handed.has(path)) option.textContent += " (handed in)"; + if (!folder) { + pick.append(option); + continue; + } + let group = groups.get(folder); + if (!group) { + group = document.createElement("optgroup"); + (group as HTMLOptGroupElement).label = folder; + groups.set(folder, group); + pick.append(group); + } + group.append(option); + } + } + pick.value = this.inputPath ?? ""; + pick.disabled = this.running; + const model = this.inputPath ? this.models.get(this.inputPath) ?? null : null; + if (this.textEditor.getModel() !== model) this.textEditor.setModel(model); } // ---- The file list --------------------------------------------------------------- diff --git a/studio/src/styles.css b/studio/src/styles.css index c8c67bf..ded2f6c 100644 --- a/studio/src/styles.css +++ b/studio/src/styles.css @@ -4,7 +4,12 @@ #analyzer { max-width: min(280px, 40vw); text-overflow: ellipsis; } .layout { flex: 1; display: grid; grid-template-columns: minmax(200px, 280px) 1fr; min-height: 0; } -@media (max-width: 720px) { .layout { grid-template-columns: 1fr; grid-template-rows: 30vh 1fr; } } +/* The text a run reads, beside the editor, while there is one to show. */ +.layout.with-text { grid-template-columns: minmax(200px, 280px) minmax(0, 1.4fr) minmax(260px, 1fr); } +@media (max-width: 720px) { + .layout, .layout.with-text { grid-template-columns: 1fr; grid-template-rows: 30vh 1fr; } + .layout.with-text { grid-template-rows: 30vh 1fr 40vh; } +} .files { overflow: auto; padding: 8px 10px; background: var(--panel); border-right: 1px solid var(--line); } /* The lists are @visualtext/analyzer-views (its style.css), in the studio's colours. */ @@ -48,6 +53,12 @@ button.commit { border-color: var(--accent); color: var(--on-ink); font-weight: .editor-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; } .path { padding: 5px 12px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--line); background: var(--panel); } .editor { flex: 1; min-height: 0; } +.text-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; border-left: 1px solid var(--line); } +.text-pane[hidden] { display: none; } +.text-bar { display: flex; align-items: center; gap: 8px; padding: 3px 8px 3px 12px; border-bottom: 1px solid var(--line); + background: var(--panel); font-size: 12px; } +.text-bar select { flex: 1; min-width: 0; } +#text-toggle[aria-pressed="true"] { background: var(--on); color: var(--on-ink); } .problems { max-height: 22vh; overflow: auto; border-top: 1px solid var(--line); background: var(--panel); display: flex; flex-direction: column; } .problems button { border: 0; border-radius: 0; text-align: left; padding: 4px 12px; background: none; font-size: 12.5px; }