From 4e3889667600580250b77792a9f600ee9352a82c Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Mon, 26 Aug 2019 19:19:19 +0200 Subject: [PATCH] The ASM editor view has been ported to the new GUI system. --- src/core/gui/View.ts | 4 + src/core/gui/dom.ts | 9 +- src/core/gui/index.css | 3 - src/core/undo/SimpleUndo.ts | 23 +- src/old/core/undo.ts | 238 -------------- .../ui/AssemblyEditorComponent.css | 4 - .../ui/AssemblyEditorComponent.tsx | 306 ------------------ .../quest_editor/ui/NpcCountsComponent.css | 8 - .../quest_editor/ui/NpcCountsComponent.tsx | 43 --- .../quest_editor/ui/QuestEditorComponent.css | 10 - .../quest_editor/ui/QuestEditorComponent.tsx | 200 ------------ src/quest_editor/gui/AsmEditorView.ts | 73 +++++ src/quest_editor/gui/QuesInfoView.css | 1 + src/quest_editor/gui/QuesInfoView.ts | 4 +- src/quest_editor/gui/QuestEditorView.ts | 35 +- src/quest_editor/gui/QuestRendererView.ts | 5 +- .../scripting/AssemblyAnalyser.ts | 20 +- src/quest_editor/stores/AsmEditorStore.ts | 190 +++++++++++ src/quest_editor/stores/QuestEditorStore.ts | 3 - src/quest_editor/stores/asm_syntax.ts | 52 +++ typedefs/static_files.d.ts | 1 - 21 files changed, 382 insertions(+), 850 deletions(-) delete mode 100644 src/old/core/undo.ts delete mode 100644 src/old/quest_editor/ui/AssemblyEditorComponent.css delete mode 100644 src/old/quest_editor/ui/AssemblyEditorComponent.tsx delete mode 100644 src/old/quest_editor/ui/NpcCountsComponent.css delete mode 100644 src/old/quest_editor/ui/NpcCountsComponent.tsx delete mode 100644 src/old/quest_editor/ui/QuestEditorComponent.css delete mode 100644 src/old/quest_editor/ui/QuestEditorComponent.tsx create mode 100644 src/quest_editor/gui/AsmEditorView.ts create mode 100644 src/quest_editor/stores/AsmEditorStore.ts create mode 100644 src/quest_editor/stores/asm_syntax.ts delete mode 100644 typedefs/static_files.d.ts diff --git a/src/core/gui/View.ts b/src/core/gui/View.ts index 99def902..4af5d1e0 100644 --- a/src/core/gui/View.ts +++ b/src/core/gui/View.ts @@ -24,6 +24,10 @@ export abstract class View implements Disposable { this.disposables(this.visible.observe(({ value }) => (this.element.hidden = !value))); } + focus(): void { + this.element.focus(); + } + dispose(): void { this.element.remove(); this.disposer.dispose(); diff --git a/src/core/gui/dom.ts b/src/core/gui/dom.ts index 47cde39e..ac5b292e 100644 --- a/src/core/gui/dom.ts +++ b/src/core/gui/dom.ts @@ -3,8 +3,10 @@ import { Observable } from "../observable/Observable"; import { is_property } from "../observable/Property"; export const el = { - div: (attributes?: {}, ...children: HTMLElement[]): HTMLDivElement => - create_element("div", attributes, ...children), + div: ( + attributes?: { class?: string; tab_index?: number }, + ...children: HTMLElement[] + ): HTMLDivElement => create_element("div", attributes, ...children), table: (attributes?: {}, ...children: HTMLElement[]): HTMLTableElement => create_element("table", attributes, ...children), @@ -30,6 +32,7 @@ export function create_element( tag_name: string, attributes?: { class?: string; + tab_index?: number; text?: string; data?: { [key: string]: string }; col_span?: number; @@ -49,6 +52,8 @@ export function create_element( } if (attributes.col_span) element.colSpan = attributes.col_span; + + if (attributes.tab_index) element.tabIndex = attributes.tab_index; } element.append(...children); diff --git a/src/core/gui/index.css b/src/core/gui/index.css index cb7c7931..689b98df 100644 --- a/src/core/gui/index.css +++ b/src/core/gui/index.css @@ -53,9 +53,6 @@ body { font-size: 13px; background-color: var(--bg-color); color: var(--text-color); -} - -* { font-family: Verdana, Geneva, sans-serif; } diff --git a/src/core/undo/SimpleUndo.ts b/src/core/undo/SimpleUndo.ts index a1c29e54..85bfd45f 100644 --- a/src/core/undo/SimpleUndo.ts +++ b/src/core/undo/SimpleUndo.ts @@ -1,18 +1,19 @@ import { Undo } from "./Undo"; import { Action } from "./Action"; import { Property } from "../observable/Property"; -import { property } from "../observable"; +import { map, property } from "../observable"; import { NOOP_UNDO } from "./noop_undo"; import { undo_manager } from "./UndoManager"; +import { WritableProperty } from "../observable/WritableProperty"; /** * Simply contains a single action. `can_undo` and `can_redo` must be managed manually. */ export class SimpleUndo implements Undo { - private readonly action: Action; + readonly action: WritableProperty; constructor(description: string, undo: () => void, redo: () => void) { - this.action = { description, undo, redo }; + this.action = property({ description, undo, redo }); } make_current(): void { @@ -29,17 +30,21 @@ export class SimpleUndo implements Undo { readonly can_redo = property(false); - readonly first_undo: Property = this.can_undo.map(can_undo => - can_undo ? this.action : undefined, + readonly first_undo: Property = map( + (action, can_undo) => (can_undo ? action : undefined), + this.action, + this.can_undo, ); - readonly first_redo: Property = this.can_redo.map(can_redo => - can_redo ? this.action : undefined, + readonly first_redo: Property = map( + (action, can_redo) => (can_redo ? action : undefined), + this.action, + this.can_redo, ); undo(): boolean { if (this.can_undo) { - this.action.undo(); + this.action.val.undo(); return true; } else { return false; @@ -48,7 +53,7 @@ export class SimpleUndo implements Undo { redo(): boolean { if (this.can_redo) { - this.action.redo(); + this.action.val.redo(); return true; } else { return false; diff --git a/src/old/core/undo.ts b/src/old/core/undo.ts deleted file mode 100644 index a869ff31..00000000 --- a/src/old/core/undo.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { computed, observable, IObservableArray, action } from "mobx"; - -export class Action { - constructor( - readonly description: string, - readonly undo: () => void, - readonly redo: () => void, - ) {} -} - -class UndoManager { - @observable current?: Undo; - - @computed - get can_undo(): boolean { - return this.current ? this.current.can_undo : false; - } - - @computed - get can_redo(): boolean { - return this.current ? this.current.can_redo : false; - } - - @computed - get first_undo(): Action | undefined { - return this.current && this.current.first_undo; - } - - @computed - get first_redo(): Action | undefined { - return this.current && this.current.first_redo; - } - - undo(): boolean { - return this.current ? this.current.undo() : false; - } - - redo(): boolean { - return this.current ? this.current.redo() : false; - } -} - -export const undo_manager = new UndoManager(); - -interface Undo { - make_current(): void; - - ensure_not_current(): void; - - readonly can_undo: boolean; - - readonly can_redo: boolean; - - /** - * The first action that will be undone when calling undo(). - */ - readonly first_undo: Action | undefined; - - /** - * The first action that will be redone when calling redo(). - */ - readonly first_redo: Action | undefined; - - undo(): boolean; - - redo(): boolean; - - reset(): void; -} - -/** - * Simply contains a single action. `can_undo` and `can_redo` must be managed manually. - */ -export class SimpleUndo implements Undo { - @observable.ref action: Action; - - constructor(description: string, undo: () => void, redo: () => void) { - this.action = new Action(description, undo, redo); - } - - @action - make_current(): void { - undo_manager.current = this; - } - - @action - ensure_not_current(): void { - if (undo_manager.current === this) { - undo_manager.current = undefined; - } - } - - @observable _can_undo = false; - - get can_undo(): boolean { - return this._can_undo; - } - - set can_undo(can_undo: boolean) { - this._can_undo = can_undo; - } - - @observable _can_redo = false; - - get can_redo(): boolean { - return this._can_redo; - } - - set can_redo(can_redo: boolean) { - this._can_redo = can_redo; - } - - @computed get first_undo(): Action | undefined { - return this.can_undo ? this.action : undefined; - } - - @computed get first_redo(): Action | undefined { - return this.can_redo ? this.action : undefined; - } - - @action - undo(): boolean { - if (this.can_undo) { - this.action.undo(); - return true; - } else { - return false; - } - } - - @action - redo(): boolean { - if (this.can_redo) { - this.action.redo(); - return true; - } else { - return false; - } - } - - @action - reset(): void { - this._can_undo = false; - this._can_redo = false; - } -} - -/** - * Full-fledged linear undo/redo implementation. - */ -export class UndoStack implements Undo { - @observable private readonly stack: IObservableArray = observable.array([], { - deep: false, - }); - /** - * The index where new actions are inserted. - */ - @observable private index = 0; - - @action - make_current(): void { - undo_manager.current = this; - } - - @action - ensure_not_current(): void { - if (undo_manager.current === this) { - undo_manager.current = undefined; - } - } - - @computed get can_undo(): boolean { - return this.index > 0; - } - - @computed get can_redo(): boolean { - return this.index < this.stack.length; - } - - /** - * The first action that will be undone when calling undo(). - */ - @computed get first_undo(): Action | undefined { - return this.can_undo ? this.stack[this.index - 1] : undefined; - } - - /** - * The first action that will be redone when calling redo(). - */ - @computed get first_redo(): Action | undefined { - return this.can_redo ? this.stack[this.index] : undefined; - } - - @action - push_action(description: string, undo: () => void, redo: () => void): void { - this.push(new Action(description, undo, redo)); - } - - @action - push(action: Action): void { - this.stack.splice(this.index, this.stack.length - this.index, action); - this.index++; - } - - /** - * Pop an action off the stack without undoing. - */ - @action - pop(): Action | undefined { - return this.stack.splice(--this.index, 1)[0]; - } - - @action - undo(): boolean { - if (this.can_undo) { - this.stack[--this.index].undo(); - return true; - } else { - return false; - } - } - - @action - redo(): boolean { - if (this.can_redo) { - this.stack[this.index++].redo(); - return true; - } else { - return false; - } - } - - @action - reset(): void { - this.stack.clear(); - this.index = 0; - } -} diff --git a/src/old/quest_editor/ui/AssemblyEditorComponent.css b/src/old/quest_editor/ui/AssemblyEditorComponent.css deleted file mode 100644 index 63dcfec2..00000000 --- a/src/old/quest_editor/ui/AssemblyEditorComponent.css +++ /dev/null @@ -1,4 +0,0 @@ -.main { - width: 100%; - height: 100%; -} diff --git a/src/old/quest_editor/ui/AssemblyEditorComponent.tsx b/src/old/quest_editor/ui/AssemblyEditorComponent.tsx deleted file mode 100644 index 408ae932..00000000 --- a/src/old/quest_editor/ui/AssemblyEditorComponent.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { autorun } from "mobx"; -import { editor, languages, MarkerSeverity, MarkerTag, Position } from "monaco-editor"; -import React, { Component, createRef, ReactNode } from "react"; -import { AutoSizer } from "react-virtualized"; -import { AssemblyAnalyser } from "../../../quest_editor/scripting/AssemblyAnalyser"; -import { quest_editor_store } from "../stores/QuestEditorStore"; -import { Action } from "../../core/undo"; -import styles from "./AssemblyEditorComponent.css"; -import CompletionList = languages.CompletionList; -import ITextModel = editor.ITextModel; -import IStandaloneCodeEditor = editor.IStandaloneCodeEditor; -import SignatureHelp = languages.SignatureHelp; -import IMarkerData = editor.IMarkerData; - -const ASM_SYNTAX: languages.IMonarchLanguage = { - defaultToken: "invalid", - - tokenizer: { - root: [ - // Strings. - [/"([^"\\]|\\.)*$/, "string.invalid"], // Unterminated string. - [/"/, { token: "string.quote", bracket: "@open", next: "@string" }], - - // Registers. - [/r\d+/, "predefined"], - - // Labels. - [/[^\s]+:/, "tag"], - - // Numbers. - [/0x[0-9a-fA-F]+/, "number.hex"], - [/-?\d+(\.\d+)?(e-?\d+)?/, "number.float"], - [/-?[0-9]+/, "number"], - - // Section markers. - [/\.[^\s]+/, "keyword"], - - // Identifiers. - [/[a-z][a-z0-9_=<>!]*/, "identifier"], - - // Whitespace. - [/[ \t\r\n]+/, "white"], - // [/\/\*/, "comment", "@comment"], - [/\/\/.*$/, "comment"], - - // Delimiters. - [/,/, "delimiter"], - ], - - // comment: [ - // [/[^/*]+/, "comment"], - // [/\/\*/, "comment", "@push"], // Nested comment. - // [/\*\//, "comment", "@pop"], - // [/[/*]/, "comment"], - // ], - - string: [ - [/[^\\"]+/, "string"], - [/\\(?:[n\\"])/, "string.escape"], - [/\\./, "string.escape.invalid"], - [/"/, { token: "string.quote", bracket: "@close", next: "@pop" }], - ], - }, -}; - -const assembly_analyser = new AssemblyAnalyser(); - -languages.register({ id: "psoasm" }); - -languages.setMonarchTokensProvider("psoasm", ASM_SYNTAX); - -languages.registerCompletionItemProvider("psoasm", { - provideCompletionItems(model, position): CompletionList { - const text = model.getValueInRange({ - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: 1, - endColumn: position.column, - }); - return assembly_analyser.provide_completion_items(text); - }, -}); - -languages.registerSignatureHelpProvider("psoasm", { - signatureHelpTriggerCharacters: [" ", ","], - - signatureHelpRetriggerCharacters: [", "], - - provideSignatureHelp( - _model: ITextModel, - position: Position, - ): Promise { - return assembly_analyser.provide_signature_help(position.lineNumber, position.column); - }, -}); - -languages.setLanguageConfiguration("psoasm", { - indentationRules: { - increaseIndentPattern: /^\s*\d+:/, - decreaseIndentPattern: /^\s*(\d+|\.)/, - }, - autoClosingPairs: [{ open: '"', close: '"' }], - surroundingPairs: [{ open: '"', close: '"' }], - comments: { - lineComment: "//", - }, -}); - -editor.defineTheme("phantasmal-world", { - base: "vs-dark", - inherit: true, - rules: [ - { token: "", foreground: "e0e0e0", background: "#181818" }, - { token: "tag", foreground: "99bbff" }, - { token: "keyword", foreground: "d0a0ff", fontStyle: "bold" }, - { token: "predefined", foreground: "bbffbb" }, - { token: "number", foreground: "ffffaa" }, - { token: "number.hex", foreground: "ffffaa" }, - { token: "string", foreground: "88ffff" }, - { token: "string.escape", foreground: "8888ff" }, - ], - colors: { - "editor.background": "#181818", - "editor.lineHighlightBackground": "#202020", - }, -}); - -export class AssemblyEditorComponent extends Component { - render(): ReactNode { - return ( -
- - {({ width, height }) => } - -
- ); - } -} - -type MonacoProps = { - width: number; - height: number; -}; - -class MonacoComponent extends Component { - private div_ref = createRef(); - private editor?: IStandaloneCodeEditor; - private disposers: (() => void)[] = []; - - render(): ReactNode { - return
; - } - - componentDidMount(): void { - if (this.div_ref.current) { - this.editor = editor.create(this.div_ref.current, { - theme: "phantasmal-world", - scrollBeyondLastLine: false, - autoIndent: true, - fontSize: 14, - wordBasedSuggestions: false, - wordWrap: "on", - wrappingIndent: "indent", - }); - - this.disposers.push( - this.dispose, - autorun(this.update_model), - autorun(this.update_model_markers), - ); - } - } - - componentWillUnmount(): void { - for (const disposer of this.disposers.splice(0, this.disposers.length)) { - disposer(); - } - } - - shouldComponentUpdate(): boolean { - return false; - } - - UNSAFE_componentWillReceiveProps(props: MonacoProps): void { - if ( - (this.props.width !== props.width || this.props.height !== props.height) && - this.editor - ) { - this.editor.layout(props); - } - } - - private update_model = () => { - const quest = quest_editor_store.current_quest; - - if (quest && this.editor) { - const assembly = assembly_analyser.disassemble(quest); - const model = editor.createModel(assembly.join("\n"), "psoasm"); - - quest_editor_store.script_undo.action = new Action( - "Text edits", - () => { - if (this.editor) { - this.editor.trigger("undo stack", "undo", undefined); - } - }, - () => { - if (this.editor) { - this.editor.trigger("redo stack", "redo", undefined); - } - }, - ); - - let initial_version = model.getAlternativeVersionId(); - let current_version = initial_version; - let last_version = initial_version; - - const disposable = model.onDidChangeContent(e => { - const version = model.getAlternativeVersionId(); - - if (version < current_version) { - // Undoing. - quest_editor_store.script_undo.can_redo = true; - - if (version === initial_version) { - quest_editor_store.script_undo.can_undo = false; - } - } else { - // Redoing. - if (version <= last_version) { - if (version === last_version) { - quest_editor_store.script_undo.can_redo = false; - } - } else { - quest_editor_store.script_undo.can_redo = false; - - if (current_version > last_version) { - last_version = current_version; - } - } - - quest_editor_store.script_undo.can_undo = true; - } - - current_version = version; - - assembly_analyser.update_assembly(e.changes); - }); - - this.disposers.push(() => disposable.dispose()); - this.editor.setModel(model); - this.editor.updateOptions({ readOnly: false }); - } else if (this.editor) { - this.editor.updateOptions({ readOnly: true }); - } - }; - - private update_model_markers = () => { - if (!this.editor) return; - - // Reference warnings and errors here to make sure we get mobx updates. - assembly_analyser.warnings.length; - assembly_analyser.errors.length; - - const model = this.editor.getModel(); - if (!model) return; - - editor.setModelMarkers( - model, - "psoasm", - assembly_analyser.warnings - .map( - (warning): IMarkerData => ({ - severity: MarkerSeverity.Hint, - message: warning.message, - startLineNumber: warning.line_no, - endLineNumber: warning.line_no, - startColumn: warning.col, - endColumn: warning.col + warning.length, - tags: [MarkerTag.Unnecessary], - }), - ) - .concat( - assembly_analyser.errors.map( - (error): IMarkerData => ({ - severity: MarkerSeverity.Error, - message: error.message, - startLineNumber: error.line_no, - endLineNumber: error.line_no, - startColumn: error.col, - endColumn: error.col + error.length, - }), - ), - ), - ); - }; - - private dispose = () => { - if (this.editor) { - this.editor.dispose(); - const model = this.editor.getModel(); - if (model) model.dispose(); - this.editor = undefined; - } - }; -} diff --git a/src/old/quest_editor/ui/NpcCountsComponent.css b/src/old/quest_editor/ui/NpcCountsComponent.css deleted file mode 100644 index 1df368c1..00000000 --- a/src/old/quest_editor/ui/NpcCountsComponent.css +++ /dev/null @@ -1,8 +0,0 @@ -.main { - height: 100%; - overflow: auto; -} - -.main > table { - margin: 5px; -} diff --git a/src/old/quest_editor/ui/NpcCountsComponent.tsx b/src/old/quest_editor/ui/NpcCountsComponent.tsx deleted file mode 100644 index 7473eb35..00000000 --- a/src/old/quest_editor/ui/NpcCountsComponent.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React, { Component, ReactNode } from "react"; -import styles from "./NpcCountsComponent.css"; -import { npc_data, NpcType } from "../../../core/data_formats/parsing/quest/npc_types"; -import { quest_editor_store } from "../stores/QuestEditorStore"; -import { observer } from "mobx-react"; - -@observer -export class NpcCountsComponent extends Component { - render(): ReactNode { - const quest = quest_editor_store.current_quest; - const npc_counts = new Map(); - - if (quest) { - for (const npc of quest.npcs) { - const val = npc_counts.get(npc.type) || 0; - npc_counts.set(npc.type, val + 1); - } - } - - const extra_canadines = (npc_counts.get(NpcType.Canane) || 0) * 8; - - // Sort by canonical order. - const sorted_npc_counts = [...npc_counts].sort((a, b) => a[0] - b[0]); - - const npc_count_rows = sorted_npc_counts.map(([npc_type, count]) => { - const extra = npc_type === NpcType.Canadine ? extra_canadines : 0; - return ( - - {npc_data(npc_type).name}: - {count + extra} - - ); - }); - - return ( -
- - {npc_count_rows} -
-
- ); - } -} diff --git a/src/old/quest_editor/ui/QuestEditorComponent.css b/src/old/quest_editor/ui/QuestEditorComponent.css deleted file mode 100644 index 3c55e109..00000000 --- a/src/old/quest_editor/ui/QuestEditorComponent.css +++ /dev/null @@ -1,10 +0,0 @@ -.main { - display: flex; - flex-direction: column; -} - -.content { - flex: 1; - display: flex; - overflow: hidden; -} diff --git a/src/old/quest_editor/ui/QuestEditorComponent.tsx b/src/old/quest_editor/ui/QuestEditorComponent.tsx deleted file mode 100644 index a71bb56d..00000000 --- a/src/old/quest_editor/ui/QuestEditorComponent.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import GoldenLayout, { ContentItem, ItemConfigType } from "golden-layout"; -import Logger from "js-logger"; -import { observer } from "mobx-react"; -import React, { Component, createRef, FocusEvent, ReactNode } from "react"; -import { quest_editor_ui_persister } from "../../../quest_editor/persistence/QuestEditorUiPersister"; -import { quest_editor_store } from "../stores/QuestEditorStore"; -import { AssemblyEditorComponent } from "./AssemblyEditorComponent"; -import { EntityInfoComponent } from "./EntityInfoComponent"; -import styles from "./QuestEditorComponent.css"; -import { QuestInfoComponent } from "./QuestInfoComponent"; -import { QuestRendererComponent } from "./QuestRendererComponent"; -import { Toolbar } from "./Toolbar"; -import { NpcCountsComponent } from "./NpcCountsComponent"; -import { AddObjectComponent } from "./AddObjectComponent"; - -const logger = Logger.get("ui/quest_editor/QuestEditorComponent"); - -// Don't change these ids, as they are persisted in the user's browser. -const CMP_TO_NAME = new Map([ - [QuestInfoComponent, "quest_info"], - [NpcCountsComponent, "npc_counts"], - [QuestRendererComponent, "quest_renderer"], - [AssemblyEditorComponent, "assembly_editor"], - [EntityInfoComponent, "entity_info"], - [AddObjectComponent, "add_object"], -]); - -const DEFAULT_LAYOUT_CONFIG = { - settings: { - showPopoutIcon: false, - }, - dimensions: { - headerHeight: 28, - }, - labels: { - close: "Close", - maximise: "Maximise", - minimise: "Minimise", - popout: "Open in new window", - }, -}; - -const DEFAULT_LAYOUT_CONTENT: ItemConfigType[] = [ - { - type: "row", - content: [ - { - type: "stack", - width: 3, - content: [ - { - title: "Info", - type: "react-component", - component: CMP_TO_NAME.get(QuestInfoComponent), - isClosable: false, - }, - { - title: "NPC Counts", - type: "react-component", - component: CMP_TO_NAME.get(NpcCountsComponent), - isClosable: false, - }, - ], - }, - { - type: "stack", - width: 9, - content: [ - { - title: "3D View", - type: "react-component", - component: CMP_TO_NAME.get(QuestRendererComponent), - isClosable: false, - }, - { - title: "Script", - type: "react-component", - component: CMP_TO_NAME.get(AssemblyEditorComponent), - isClosable: false, - }, - ], - }, - { - title: "Entity", - type: "react-component", - component: CMP_TO_NAME.get(EntityInfoComponent), - isClosable: false, - width: 2, - }, - ], - }, -]; - -@observer -export class QuestEditorComponent extends Component { - private layout_element = createRef(); - private layout?: GoldenLayout; - - componentDidMount(): void { - quest_editor_store.undo.make_current(); - - window.addEventListener("resize", this.resize); - - setTimeout(async () => { - if (this.layout_element.current && !this.layout) { - const content = await quest_editor_ui_persister.load_layout_config( - [...CMP_TO_NAME.values()], - DEFAULT_LAYOUT_CONTENT, - ); - - const config: GoldenLayout.Config = { - ...DEFAULT_LAYOUT_CONFIG, - content, - }; - - try { - this.layout = new GoldenLayout(config, this.layout_element.current); - } catch (e) { - logger.warn("Couldn't initialize golden layout with persisted layout.", e); - - this.layout = new GoldenLayout( - { - ...DEFAULT_LAYOUT_CONFIG, - content: DEFAULT_LAYOUT_CONTENT, - }, - this.layout_element.current, - ); - } - - for (const [component, name] of CMP_TO_NAME) { - this.layout.registerComponent(name, component); - } - - this.layout.on("stateChanged", () => { - if (this.layout) { - quest_editor_ui_persister.persist_layout_config( - this.layout.toConfig().content, - ); - } - }); - - this.layout.on("stackCreated", (stack: ContentItem) => { - stack.on("activeContentItemChanged", (item: ContentItem) => { - if ("component" in item.config) { - if ( - item.config.component === CMP_TO_NAME.get(AssemblyEditorComponent) - ) { - quest_editor_store.script_undo.make_current(); - } else { - quest_editor_store.undo.make_current(); - } - } - }); - }); - - this.layout.init(); - } - }, 0); - } - - componentWillUnmount(): void { - quest_editor_store.undo.ensure_not_current(); - - window.removeEventListener("resize", this.resize); - - if (this.layout) { - this.layout.destroy(); - this.layout = undefined; - } - } - - render(): ReactNode { - return ( -
- -
-
- ); - } - - private focus = (e: FocusEvent) => { - const scrip_editor_element = document.getElementById("qe-ScriptEditorComponent"); - - if ( - scrip_editor_element && - scrip_editor_element.compareDocumentPosition(e.target) & - Node.DOCUMENT_POSITION_CONTAINED_BY - ) { - quest_editor_store.script_undo.make_current(); - } else { - quest_editor_store.undo.make_current(); - } - }; - - private resize = () => { - if (this.layout) { - this.layout.updateSize(); - } - }; -} diff --git a/src/quest_editor/gui/AsmEditorView.ts b/src/quest_editor/gui/AsmEditorView.ts new file mode 100644 index 00000000..776aaf89 --- /dev/null +++ b/src/quest_editor/gui/AsmEditorView.ts @@ -0,0 +1,73 @@ +import { ResizableView } from "../../core/gui/ResizableView"; +import { el } from "../../core/gui/dom"; +import { editor } from "monaco-editor"; +import { asm_editor_store } from "../stores/AsmEditorStore"; +import IStandaloneCodeEditor = editor.IStandaloneCodeEditor; + +editor.defineTheme("phantasmal-world", { + base: "vs-dark", + inherit: true, + rules: [ + { token: "", foreground: "e0e0e0", background: "#181818" }, + { token: "tag", foreground: "99bbff" }, + { token: "keyword", foreground: "d0a0ff", fontStyle: "bold" }, + { token: "predefined", foreground: "bbffbb" }, + { token: "number", foreground: "ffffaa" }, + { token: "number.hex", foreground: "ffffaa" }, + { token: "string", foreground: "88ffff" }, + { token: "string.escape", foreground: "8888ff" }, + ], + colors: { + "editor.background": "#181818", + "editor.lineHighlightBackground": "#202020", + }, +}); + +export class AsmEditorView extends ResizableView { + readonly element = el.div(); + + private readonly editor: IStandaloneCodeEditor = this.disposable( + editor.create(this.element, { + theme: "phantasmal-world", + scrollBeyondLastLine: false, + autoIndent: true, + fontSize: 13, + wordBasedSuggestions: false, + wordWrap: "on", + wrappingIndent: "indent", + }), + ); + + constructor() { + super(); + + this.disposables( + asm_editor_store.did_undo.observe(({ value: source }) => { + this.editor.trigger(source, "undo", undefined); + }), + + asm_editor_store.did_redo.observe(({ value: source }) => { + this.editor.trigger(source, "redo", undefined); + }), + + asm_editor_store.model.observe( + ({ value: model }) => { + this.editor.updateOptions({ readOnly: model == undefined }); + this.editor.setModel(model || null); + }, + { call_now: true }, + ), + + this.editor.onDidFocusEditorWidget(() => asm_editor_store.undo.make_current()), + ); + } + + focus(): void { + this.editor.focus(); + } + + resize(width: number, height: number): this { + this.editor.layout({ width, height }); + return this; + } +} diff --git a/src/quest_editor/gui/QuesInfoView.css b/src/quest_editor/gui/QuesInfoView.css index e44a7909..f72f0ef8 100644 --- a/src/quest_editor/gui/QuesInfoView.css +++ b/src/quest_editor/gui/QuesInfoView.css @@ -2,6 +2,7 @@ box-sizing: border-box; padding: 3px; overflow: auto; + outline: none; } .quest_editor_QuesInfoView table { diff --git a/src/quest_editor/gui/QuesInfoView.ts b/src/quest_editor/gui/QuesInfoView.ts index a3eb652f..0c20f723 100644 --- a/src/quest_editor/gui/QuesInfoView.ts +++ b/src/quest_editor/gui/QuesInfoView.ts @@ -10,7 +10,7 @@ import "./QuesInfoView.css"; import { Label } from "../../core/gui/Label"; export class QuesInfoView extends ResizableView { - readonly element = el.div({ class: "quest_editor_QuesInfoView" }); + readonly element = el.div({ class: "quest_editor_QuesInfoView", tab_index: -1 }); private readonly table_element = el.table(); private readonly episode_element: HTMLElement; @@ -65,6 +65,8 @@ export class QuesInfoView extends ResizableView { this.element.append(this.table_element, this.no_quest_element); + this.element.addEventListener("focus", () => quest_editor_store.undo.make_current(), true); + this.disposables( quest.observe(({ value: q }) => { this.quest_disposer.dispose_all(); diff --git a/src/quest_editor/gui/QuestEditorView.ts b/src/quest_editor/gui/QuestEditorView.ts index 819bfd89..aba8d270 100644 --- a/src/quest_editor/gui/QuestEditorView.ts +++ b/src/quest_editor/gui/QuestEditorView.ts @@ -8,17 +8,17 @@ import "golden-layout/src/css/goldenlayout-base.css"; import "../../core/gui/golden_layout_theme.css"; import { NpcCountsView } from "./NpcCountsView"; import { QuestRendererView } from "./QuestRendererView"; -import { quest_editor_store } from "../stores/QuestEditorStore"; +import { AsmEditorView } from "./AsmEditorView"; import Logger = require("js-logger"); const logger = Logger.get("quest_editor/gui/QuestEditorView"); // Don't change these values, as they are persisted in the user's browser. -const VIEW_TO_NAME = new Map([ +const VIEW_TO_NAME = new Map ResizableView, string>([ [QuesInfoView, "quest_info"], [NpcCountsView, "npc_counts"], [QuestRendererView, "quest_renderer"], - // [AssemblyEditorView, "assembly_editor"], + [AsmEditorView, "asm_editor"], // [EntityInfoView, "entity_info"], // [AddObjectView, "add_object"], ]); @@ -71,12 +71,12 @@ const DEFAULT_LAYOUT_CONTENT: ItemConfigType[] = [ componentName: VIEW_TO_NAME.get(QuestRendererView), isClosable: false, }, - // { - // title: "Script", - // type: "component", - // componentName: Component.AssemblyEditor, - // isClosable: false, - // }, + { + title: "Script", + type: "component", + componentName: VIEW_TO_NAME.get(AsmEditorView), + isClosable: false, + }, ], }, // { @@ -98,6 +98,8 @@ export class QuestEditorView extends ResizableView { private readonly layout_element = create_element("div", { class: "quest_editor_gl_container" }); private readonly layout: Promise; + private readonly sub_views = new Map(); + constructor() { super(); @@ -120,6 +122,12 @@ export class QuestEditorView extends ResizableView { dispose(): void { super.dispose(); this.layout.then(layout => layout.destroy()); + + for (const view of this.sub_views.values()) { + view.dispose(); + } + + this.sub_views.clear(); } private async init_golden_layout(): Promise { @@ -145,6 +153,7 @@ export class QuestEditorView extends ResizableView { private attempt_gl_init(config: GoldenLayout.Config): GoldenLayout { const layout = new GoldenLayout(config, this.layout_element); + const self = this; try { for (const [view_ctor, name] of VIEW_TO_NAME) { @@ -159,6 +168,7 @@ export class QuestEditorView extends ResizableView { view.resize(container.width, container.height); + self.sub_views.set(name, view); container.getElement().append(view.element); }); } @@ -172,11 +182,8 @@ export class QuestEditorView extends ResizableView { layout.on("stackCreated", (stack: ContentItem) => { stack.on("activeContentItemChanged", (item: ContentItem) => { if ("componentName" in item.config) { - // if (item.config.componentName === VIEW_TO_NAME.get(AssemblyEditorView)) { - // quest_editor_store.script_undo.make_current(); - // } else { - // quest_editor_store.undo.make_current(); - // } + const view = this.sub_views.get(item.config.componentName); + if (view) view.focus(); } }); }); diff --git a/src/quest_editor/gui/QuestRendererView.ts b/src/quest_editor/gui/QuestRendererView.ts index d3374e3a..8d79a848 100644 --- a/src/quest_editor/gui/QuestRendererView.ts +++ b/src/quest_editor/gui/QuestRendererView.ts @@ -3,9 +3,10 @@ import { el } from "../../core/gui/dom"; import { RendererView } from "../../core/gui/RendererView"; import { QuestRenderer } from "../rendering/QuestRenderer"; import { gui_store, GuiTool } from "../../core/stores/GuiStore"; +import { quest_editor_store } from "../stores/QuestEditorStore"; export class QuestRendererView extends ResizableView { - readonly element = el.div({ class: "quest_editor_QuestRendererView" }); + readonly element = el.div({ class: "quest_editor_QuestRendererView", tab_index: -1 }); private renderer_view = this.disposable(new RendererView(new QuestRenderer())); @@ -14,6 +15,8 @@ export class QuestRendererView extends ResizableView { this.element.append(this.renderer_view.element); + this.element.addEventListener("focus", () => quest_editor_store.undo.make_current(), true); + this.renderer_view.start_rendering(); this.disposables( diff --git a/src/quest_editor/scripting/AssemblyAnalyser.ts b/src/quest_editor/scripting/AssemblyAnalyser.ts index 91884021..83fd9101 100644 --- a/src/quest_editor/scripting/AssemblyAnalyser.ts +++ b/src/quest_editor/scripting/AssemblyAnalyser.ts @@ -21,6 +21,7 @@ import CompletionItem = languages.CompletionItem; import IModelContentChange = editor.IModelContentChange; import SignatureHelp = languages.SignatureHelp; import ParameterInformation = languages.ParameterInformation; +import { Disposable } from "../../core/observable/Disposable"; const INSTRUCTION_SUGGESTIONS = OPCODES.filter(opcode => opcode != null).map(opcode => { return ({ @@ -48,19 +49,25 @@ const KEYWORD_SUGGESTIONS = [ }, ] as CompletionItem[]; -export class AssemblyAnalyser { - readonly _warnings: WritableProperty = property([]); - readonly warnings: Property = this._warnings; +export class AssemblyAnalyser implements Disposable { + readonly _issues: WritableProperty<{ + warnings: AssemblyWarning[]; + errors: AssemblyError[]; + }> = property({ warnings: [], errors: [] }); - readonly _errors: WritableProperty = property([]); - readonly errors: Property = this._errors; + readonly issues: Property<{ + warnings: AssemblyWarning[]; + errors: AssemblyError[]; + }> = this._issues; private worker = new AssemblyWorker(); private quest?: QuestModel; + private promises = new Map< number, { resolve: (result: any) => void; reject: (error: Error) => void } >(); + private message_id = 0; constructor() { @@ -139,8 +146,7 @@ export class AssemblyAnalyser { ...message.object_code, ); this.quest.set_map_designations(message.map_designations); - this._warnings.val = message.warnings; - this._errors.val = message.errors; + this._issues.val = { warnings: message.warnings, errors: message.errors }; } break; case OutputMessageType.SignatureHelp: diff --git a/src/quest_editor/stores/AsmEditorStore.ts b/src/quest_editor/stores/AsmEditorStore.ts new file mode 100644 index 00000000..33f00815 --- /dev/null +++ b/src/quest_editor/stores/AsmEditorStore.ts @@ -0,0 +1,190 @@ +import { editor, languages, MarkerSeverity, MarkerTag, Position } from "monaco-editor"; +import { AssemblyAnalyser } from "../scripting/AssemblyAnalyser"; +import { Disposable } from "../../core/observable/Disposable"; +import { Disposer } from "../../core/observable/Disposer"; +import { SimpleUndo } from "../../core/undo/SimpleUndo"; +import { QuestModel } from "../model/QuestModel"; +import { quest_editor_store } from "./QuestEditorStore"; +import { ASM_SYNTAX } from "./asm_syntax"; +import { AssemblyError, AssemblyWarning } from "../scripting/assembly"; +import { Observable } from "../../core/observable/Observable"; +import { emitter, property } from "../../core/observable"; +import { WritableProperty } from "../../core/observable/WritableProperty"; +import SignatureHelp = languages.SignatureHelp; +import ITextModel = editor.ITextModel; +import CompletionList = languages.CompletionList; +import IMarkerData = editor.IMarkerData; + +const assembly_analyser = new AssemblyAnalyser(); + +languages.register({ id: "psoasm" }); + +languages.setMonarchTokensProvider("psoasm", ASM_SYNTAX); + +languages.registerCompletionItemProvider("psoasm", { + provideCompletionItems(model, position): CompletionList { + const text = model.getValueInRange({ + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: 1, + endColumn: position.column, + }); + return assembly_analyser.provide_completion_items(text); + }, +}); + +languages.registerSignatureHelpProvider("psoasm", { + signatureHelpTriggerCharacters: [" ", ","], + + signatureHelpRetriggerCharacters: [", "], + + provideSignatureHelp( + _model: ITextModel, + position: Position, + ): Promise { + return assembly_analyser.provide_signature_help(position.lineNumber, position.column); + }, +}); + +languages.setLanguageConfiguration("psoasm", { + indentationRules: { + increaseIndentPattern: /^\s*\d+:/, + decreaseIndentPattern: /^\s*(\d+|\.)/, + }, + autoClosingPairs: [{ open: '"', close: '"' }], + surroundingPairs: [{ open: '"', close: '"' }], + comments: { + lineComment: "//", + }, +}); + +export class AsmEditorStore implements Disposable { + private readonly _model: WritableProperty = property(undefined); + readonly model = this._model; + + private readonly _did_undo = emitter(); + readonly did_undo: Observable = this._did_undo; + + private readonly _did_redo = emitter(); + readonly did_redo: Observable = this._did_redo; + + readonly undo = new SimpleUndo( + "Text edits", + () => this._did_undo.emit({ value: "asm undo" }), + () => this._did_redo.emit({ value: "asm undo" }), + ); + + private readonly disposer = new Disposer(); + private readonly model_disposer = this.disposer.add(new Disposer()); + + constructor() { + this.disposer.add_all( + quest_editor_store.current_quest.observe(({ value }) => this.update_model(value), { + call_now: true, + }), + + assembly_analyser.issues.observe(({ value }) => this.update_model_markers(value), { + call_now: true, + }), + ); + } + + dispose(): void { + this.disposer.dispose(); + } + + private update_model(quest?: QuestModel): void { + this.model_disposer.dispose_all(); + + if (quest) { + const assembly = assembly_analyser.disassemble(quest); + const model = this.model_disposer.add( + editor.createModel(assembly.join("\n"), "psoasm"), + ); + + let initial_version = model.getAlternativeVersionId(); + let current_version = initial_version; + let last_version = initial_version; + + this.model_disposer.add( + model.onDidChangeContent(e => { + const version = model.getAlternativeVersionId(); + + if (version < current_version) { + // Undoing. + this.undo.can_redo.val = true; + + if (version === initial_version) { + this.undo.can_undo.val = false; + } + } else { + // Redoing. + if (version <= last_version) { + if (version === last_version) { + this.undo.can_redo.val = false; + } + } else { + this.undo.can_redo.val = false; + + if (current_version > last_version) { + last_version = current_version; + } + } + + this.undo.can_undo.val = true; + } + + current_version = version; + + assembly_analyser.update_assembly(e.changes); + }), + ); + + this._model.val = model; + } else { + this._model.val = undefined; + } + } + + private update_model_markers({ + warnings, + errors, + }: { + warnings: AssemblyWarning[]; + errors: AssemblyError[]; + }): void { + const model = this.model.val; + if (!model) return; + + editor.setModelMarkers( + model, + "psoasm", + warnings + .map( + (warning): IMarkerData => ({ + severity: MarkerSeverity.Hint, + message: warning.message, + startLineNumber: warning.line_no, + endLineNumber: warning.line_no, + startColumn: warning.col, + endColumn: warning.col + warning.length, + tags: [MarkerTag.Unnecessary], + }), + ) + .concat( + errors.map( + (error): IMarkerData => ({ + severity: MarkerSeverity.Error, + message: error.message, + startLineNumber: error.line_no, + endLineNumber: error.line_no, + startColumn: error.col, + endColumn: error.col + error.length, + }), + ), + ), + ); + } +} + +export const asm_editor_store = new AsmEditorStore(); diff --git a/src/quest_editor/stores/QuestEditorStore.ts b/src/quest_editor/stores/QuestEditorStore.ts index bd7d95da..9bccb4cb 100644 --- a/src/quest_editor/stores/QuestEditorStore.ts +++ b/src/quest_editor/stores/QuestEditorStore.ts @@ -17,7 +17,6 @@ import { Disposable } from "../../core/observable/Disposable"; import { Disposer } from "../../core/observable/Disposer"; import { gui_store, GuiTool } from "../../core/stores/GuiStore"; import { UndoStack } from "../../core/undo/UndoStack"; -import { SimpleUndo } from "../../core/undo/SimpleUndo"; import { TranslateEntityAction } from "../actions/TranslateEntityAction"; import { EditShortDescriptionAction } from "../actions/EditShortDescriptionAction"; import { EditLongDescriptionAction } from "../actions/EditLongDescriptionAction"; @@ -31,7 +30,6 @@ export class QuestEditorStore implements Disposable { readonly debug: WritableProperty = property(false); readonly undo = new UndoStack(); - readonly script_undo = new SimpleUndo("Text edits", () => {}, () => {}); private readonly _current_quest_filename = property(undefined); readonly current_quest_filename: Property = this._current_quest_filename; @@ -175,7 +173,6 @@ export class QuestEditorStore implements Disposable { private async set_quest(quest?: QuestModel, filename?: string): Promise { this.undo.reset(); - this.script_undo.reset(); this._current_area.val = undefined; this._selected_entity.val = undefined; diff --git a/src/quest_editor/stores/asm_syntax.ts b/src/quest_editor/stores/asm_syntax.ts new file mode 100644 index 00000000..71989693 --- /dev/null +++ b/src/quest_editor/stores/asm_syntax.ts @@ -0,0 +1,52 @@ +import { languages } from "monaco-editor"; + +export const ASM_SYNTAX: languages.IMonarchLanguage = { + defaultToken: "invalid", + + tokenizer: { + root: [ + // Strings. + [/"([^"\\]|\\.)*$/, "string.invalid"], // Unterminated string. + [/"/, { token: "string.quote", bracket: "@open", next: "@string" }], + + // Registers. + [/r\d+/, "predefined"], + + // Labels. + [/[^\s]+:/, "tag"], + + // Numbers. + [/0x[0-9a-fA-F]+/, "number.hex"], + [/-?\d+(\.\d+)?(e-?\d+)?/, "number.float"], + [/-?[0-9]+/, "number"], + + // Section markers. + [/\.[^\s]+/, "keyword"], + + // Identifiers. + [/[a-z][a-z0-9_=<>!]*/, "identifier"], + + // Whitespace. + [/[ \t\r\n]+/, "white"], + // [/\/\*/, "comment", "@comment"], + [/\/\/.*$/, "comment"], + + // Delimiters. + [/,/, "delimiter"], + ], + + // comment: [ + // [/[^/*]+/, "comment"], + // [/\/\*/, "comment", "@push"], // Nested comment. + // [/\*\//, "comment", "@pop"], + // [/[/*]/, "comment"], + // ], + + string: [ + [/[^\\"]+/, "string"], + [/\\(?:[n\\"])/, "string.escape"], + [/\\./, "string.escape.invalid"], + [/"/, { token: "string.quote", bracket: "@close", next: "@pop" }], + ], + }, +}; diff --git a/typedefs/static_files.d.ts b/typedefs/static_files.d.ts deleted file mode 100644 index cbe652db..00000000 --- a/typedefs/static_files.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "*.css";