diff --git a/src/application/gui/ApplicationView.ts b/src/application/gui/ApplicationView.ts index e9887564..e2fa658e 100644 --- a/src/application/gui/ApplicationView.ts +++ b/src/application/gui/ApplicationView.ts @@ -1,10 +1,10 @@ import { NavigationView } from "./NavigationView"; import { MainContentView } from "./MainContentView"; -import { el } from "../../core/gui/dom"; +import { create_element } from "../../core/gui/dom"; import { ResizableView } from "../../core/gui/ResizableView"; export class ApplicationView extends ResizableView { - element = el("div", { class: "application_ApplicationView" }); + element = create_element("div", { class: "application_ApplicationView" }); private menu_view = this.disposable(new NavigationView()); private main_content_view = this.disposable(new MainContentView()); diff --git a/src/application/gui/MainContentView.ts b/src/application/gui/MainContentView.ts index 4c46ca21..c99b5d57 100644 --- a/src/application/gui/MainContentView.ts +++ b/src/application/gui/MainContentView.ts @@ -1,4 +1,4 @@ -import { el } from "../../core/gui/dom"; +import { create_element } from "../../core/gui/dom"; import { gui_store, GuiTool } from "../../core/stores/GuiStore"; import { LazyView } from "../../core/gui/LazyView"; import { ResizableView } from "../../core/gui/ResizableView"; @@ -12,7 +12,7 @@ const TOOLS: [GuiTool, () => Promise][] = [ ]; export class MainContentView extends ResizableView { - element = el("div", { class: "application_MainContentView" }); + element = create_element("div", { class: "application_MainContentView" }); private tool_views = new Map( TOOLS.map(([tool, create_view]) => [tool, this.disposable(new LazyView(create_view))]), @@ -26,7 +26,7 @@ export class MainContentView extends ResizableView { } const tool_view = this.tool_views.get(gui_store.tool.val); - if (tool_view) tool_view.visible = true; + if (tool_view) tool_view.visible.val = true; this.disposable(gui_store.tool.observe(this.tool_changed)); } @@ -43,10 +43,10 @@ export class MainContentView extends ResizableView { private tool_changed = (new_tool: GuiTool) => { for (const tool of this.tool_views.values()) { - tool.visible = false; + tool.visible.val = false; } const new_view = this.tool_views.get(new_tool); - if (new_view) new_view.visible = true; + if (new_view) new_view.visible.val = true; }; } diff --git a/src/application/gui/NavigationView.css b/src/application/gui/NavigationView.css index 75932b7c..8d6c147d 100644 --- a/src/application/gui/NavigationView.css +++ b/src/application/gui/NavigationView.css @@ -16,7 +16,7 @@ display: inline-flex; flex-direction: row; align-items: center; - font-size: 15px; + font-size: 13px; height: 100%; padding: 0 20px; color: hsl(0, 0%, 65%); diff --git a/src/application/gui/NavigationView.ts b/src/application/gui/NavigationView.ts index 5826018b..fbce0648 100644 --- a/src/application/gui/NavigationView.ts +++ b/src/application/gui/NavigationView.ts @@ -1,4 +1,4 @@ -import { el } from "../../core/gui/dom"; +import { create_element } from "../../core/gui/dom"; import "./NavigationView.css"; import { gui_store, GuiTool } from "../../core/stores/GuiStore"; import { View } from "../../core/gui/View"; @@ -10,7 +10,7 @@ const TOOLS: [GuiTool, string][] = [ ]; export class NavigationView extends View { - readonly element = el("div", { class: "application_NavigationView" }); + readonly element = create_element("div", { class: "application_NavigationView" }); readonly height = 30; @@ -22,7 +22,7 @@ export class NavigationView extends View { super(); this.element.style.height = `${this.height}px`; - this.element.onclick = this.click; + this.element.onmousedown = this.mousedown; for (const button of this.buttons.values()) { this.element.append(button.element); @@ -32,7 +32,7 @@ export class NavigationView extends View { this.disposable(gui_store.tool.observe(this.tool_changed)); } - private click(e: MouseEvent): void { + private mousedown(e: MouseEvent): void { if (e.target instanceof HTMLLabelElement && e.target.control instanceof HTMLInputElement) { gui_store.tool.val = (GuiTool as any)[e.target.control.value]; } @@ -45,10 +45,10 @@ export class NavigationView extends View { } class ToolButton extends View { - element: HTMLElement = el("span"); + element: HTMLElement = create_element("span"); - private input: HTMLInputElement = el("input"); - private label: HTMLLabelElement = el("label"); + private input: HTMLInputElement = create_element("input"); + private label: HTMLLabelElement = create_element("label"); constructor(tool: GuiTool, text: string) { super(); diff --git a/src/core/gui/Button.css b/src/core/gui/Button.css index 9d05f70a..71b3b5b8 100644 --- a/src/core/gui/Button.css +++ b/src/core/gui/Button.css @@ -2,15 +2,19 @@ display: inline-flex; flex-direction: row; align-items: stretch; + align-content: stretch; box-sizing: border-box; + height: 26px; padding: 0; border: solid 1px hsl(0, 0%, 10%); color: hsl(0, 0%, 80%); outline: none; + font-size: 13px; } .core_Button .core_Button_inner { - display: flex; + flex: 1; + display: inline-flex; flex-direction: row; align-items: center; box-sizing: border-box; diff --git a/src/core/gui/Button.ts b/src/core/gui/Button.ts index 7c7a03c7..a44905aa 100644 --- a/src/core/gui/Button.ts +++ b/src/core/gui/Button.ts @@ -1,11 +1,11 @@ -import { el } from "./dom"; +import { create_element } from "./dom"; import "./Button.css"; import { Observable } from "../observable/Observable"; import { emitter } from "../observable"; import { Control } from "./Control"; export class Button extends Control { - readonly element: HTMLButtonElement = el("button", { class: "core_Button" }); + readonly element: HTMLButtonElement = create_element("button", { class: "core_Button" }); private readonly _click = emitter(); readonly click: Observable = this._click; @@ -13,7 +13,7 @@ export class Button extends Control { constructor(text: string) { super(); - this.element.append(el("span", { class: "core_Button_inner", text })); + this.element.append(create_element("span", { class: "core_Button_inner", text })); this.enabled.observe(enabled => (this.element.disabled = !enabled)); diff --git a/src/core/gui/CheckBox.ts b/src/core/gui/CheckBox.ts index ca6dcf2e..c1bf0648 100644 --- a/src/core/gui/CheckBox.ts +++ b/src/core/gui/CheckBox.ts @@ -1,10 +1,10 @@ -import { el } from "./dom"; +import { create_element } from "./dom"; import { WritableProperty } from "../observable/WritableProperty"; import { property } from "../observable"; import { LabelledControl } from "./LabelledControl"; export class CheckBox extends LabelledControl { - readonly element: HTMLInputElement = el("input", { class: "core_CheckBox" }); + readonly element: HTMLInputElement = create_element("input", { class: "core_CheckBox" }); readonly checked: WritableProperty = property(false); diff --git a/src/core/gui/FileButton.ts b/src/core/gui/FileButton.ts index e3227f7c..1af86183 100644 --- a/src/core/gui/FileButton.ts +++ b/src/core/gui/FileButton.ts @@ -1,4 +1,4 @@ -import { el } from "./dom"; +import { create_element } from "./dom"; import "./FileButton.css"; import "./Button.css"; import { property } from "../observable"; @@ -6,14 +6,14 @@ import { Property } from "../observable/Property"; import { Control } from "./Control"; export class FileButton extends Control { - readonly element: HTMLLabelElement = el("label", { + readonly element: HTMLLabelElement = create_element("label", { class: "core_FileButton core_Button", }); private readonly _files = property([]); readonly files: Property = this._files; - private input: HTMLInputElement = el("input", { + private input: HTMLInputElement = create_element("input", { class: "core_FileButton_input core_Button_inner", }); @@ -31,7 +31,7 @@ export class FileButton extends Control { }; this.element.append( - el("span", { + create_element("span", { class: "core_FileButton_inner core_Button_inner", text, }), diff --git a/src/core/gui/Input.css b/src/core/gui/Input.css index 39797b95..bd047794 100644 --- a/src/core/gui/Input.css +++ b/src/core/gui/Input.css @@ -1,32 +1,35 @@ .core_Input { + display: inline-block; box-sizing: border-box; - border: solid 1px hsl(0, 0%, 25%); + height: 24px; + border: var(--input-border); } .core_Input .core_Input_inner { box-sizing: border-box; width: 100%; - height: 24px; + height: 100%; padding: 0 3px; - border: solid 1px hsl(0, 0%, 0%); - background-color: hsl(0, 0%, 12%); - color: hsl(0, 0%, 75%); + border: var(--input-inner-border); + background-color: var(--input-bg-color); + color: var(--input-text-color); outline: none; + font-size: 13px; } .core_Input:hover { - border-color: hsl(0, 0%, 35%); + border-color: var(--input-border-hover); } .core_Input:focus-within { - border-color: hsl(0, 0%, 45%); + border-color: var(--input-border-focus); } .core_Input.disabled { - border: solid 1px hsl(0, 0%, 20%); + border: var(--input-border-disabled); } .core_Input.disabled .core_Input_inner { - background-color: hsl(0, 0%, 15%); - color: var(--text-color-disabled); + color: var(--input-text-color-disabled); + background-color: var(--input-bg-color-disabled); } diff --git a/src/core/gui/Input.ts b/src/core/gui/Input.ts new file mode 100644 index 00000000..c6ca167f --- /dev/null +++ b/src/core/gui/Input.ts @@ -0,0 +1,83 @@ +/* eslint-disable no-dupe-class-members */ +import { LabelledControl } from "./LabelledControl"; +import { create_element } from "./dom"; +import { WritableProperty } from "../observable/WritableProperty"; +import { is_any_property, Property } from "../observable/Property"; +import "./Input.css"; + +export abstract class Input extends LabelledControl { + readonly element: HTMLElement; + + readonly value: WritableProperty; + + protected readonly input: HTMLInputElement; + + protected constructor( + value: WritableProperty, + class_name: string, + input_type: string, + input_class_name: string, + label?: string, + ) { + super(label); + + this.value = value; + + this.element = create_element("span", { class: `${class_name} core_Input` }); + + this.input = create_element("input", { + class: `${input_class_name} core_Input_inner`, + }); + this.input.type = input_type; + this.input.onchange = () => (this.value.val = this.get_input_value()); + this.set_input_value(value.val); + + this.element.append(this.input); + + this.disposables( + this.value.observe(value => this.set_input_value(value)), + + this.enabled.observe(enabled => { + this.input.disabled = !enabled; + + if (enabled) { + this.element.classList.remove("disabled"); + } else { + this.element.classList.add("disabled"); + } + }), + ); + } + + protected abstract get_input_value(): T; + + protected abstract set_input_value(value: T): void; + + protected set_attr(attr: InputAttrsOfType, value?: T | Property): void; + protected set_attr( + attr: InputAttrsOfType, + value: T | Property | undefined, + convert: (value: T) => U, + ): void; + protected set_attr( + attr: InputAttrsOfType, + value?: T | Property, + convert?: (value: T) => U, + ): void { + if (value == undefined) return; + + const input = this.input as any; + const cvt = convert ? convert : (v: T) => (v as any) as U; + + if (is_any_property(value)) { + input[attr] = cvt(value.val); + this.disposable(value.observe(v => (input[attr] = cvt(v)))); + } else { + input[attr] = cvt(value); + } + } +} + +type InputAttrsOfType = { + [K in keyof HTMLInputElement]: T extends HTMLInputElement[K] ? K : never; +}[keyof HTMLInputElement]; diff --git a/src/core/gui/Label.ts b/src/core/gui/Label.ts index 785cf6d9..c26dbd78 100644 --- a/src/core/gui/Label.ts +++ b/src/core/gui/Label.ts @@ -1,12 +1,12 @@ import { View } from "./View"; -import { el } from "./dom"; +import { create_element } from "./dom"; import { WritableProperty } from "../observable/WritableProperty"; import "./Label.css"; import { property } from "../observable"; import { Property } from "../observable/Property"; export class Label extends View { - readonly element = el("label", { class: "core_Label" }); + readonly element = create_element("label", { class: "core_Label" }); set for(id: string) { this.element.htmlFor = id; @@ -14,7 +14,7 @@ export class Label extends View { readonly enabled: WritableProperty = property(true); - constructor(text: string | Property) { + constructor(text: string | Property, options: { enabled?: boolean } = {}) { super(); if (typeof text === "string") { @@ -33,5 +33,7 @@ export class Label extends View { } }), ); + + if (options.enabled != undefined) this.enabled.val = options.enabled; } } diff --git a/src/core/gui/LabelledControl.ts b/src/core/gui/LabelledControl.ts index 9bd8b857..71cb6da4 100644 --- a/src/core/gui/LabelledControl.ts +++ b/src/core/gui/LabelledControl.ts @@ -2,7 +2,7 @@ import { Label } from "./Label"; import { Control } from "./Control"; export abstract class LabelledControl extends Control { - abstract readonly preferred_label_position: "left" | "right"; + abstract readonly preferred_label_position: "left" | "right" | "top" | "bottom"; private readonly _label_text: string; private _label?: Label; diff --git a/src/core/gui/LazyView.ts b/src/core/gui/LazyView.ts index 24f81132..0d08e2b9 100644 --- a/src/core/gui/LazyView.ts +++ b/src/core/gui/LazyView.ts @@ -1,29 +1,10 @@ import { View } from "./View"; -import { el } from "./dom"; +import { create_element } from "./dom"; import { Resizable } from "./Resizable"; import { ResizableView } from "./ResizableView"; export class LazyView extends ResizableView { - readonly element = el("div", { class: "core_LazyView" }); - - private _visible = false; - - set visible(visible: boolean) { - if (this._visible !== visible) { - this._visible = visible; - this.element.hidden = !visible; - - if (visible && !this.initialized) { - this.initialized = true; - - this.create_view().then(view => { - this.view = this.disposable(view); - this.view.resize(this.width, this.height); - this.element.append(view.element); - }); - } - } - } + readonly element = create_element("div", { class: "core_LazyView" }); private initialized = false; private view: View & Resizable | undefined; @@ -31,7 +12,21 @@ export class LazyView extends ResizableView { constructor(private create_view: () => Promise) { super(); - this.element.hidden = true; + this.visible.val = false; + + this.disposables( + this.visible.observe(visible => { + if (visible && !this.initialized) { + this.initialized = true; + + this.create_view().then(view => { + this.view = this.disposable(view); + this.view.resize(this.width, this.height); + this.element.append(view.element); + }); + } + }), + ); } resize(width: number, height: number): this { diff --git a/src/core/gui/NumberInput.css b/src/core/gui/NumberInput.css index b5e37ab0..33a725ec 100644 --- a/src/core/gui/NumberInput.css +++ b/src/core/gui/NumberInput.css @@ -1,3 +1,3 @@ .core_NumberInput .core_NumberInput_inner { - text-align: right; + padding-right: 1px; } \ No newline at end of file diff --git a/src/core/gui/NumberInput.ts b/src/core/gui/NumberInput.ts index e6b37bf8..dc133e39 100644 --- a/src/core/gui/NumberInput.ts +++ b/src/core/gui/NumberInput.ts @@ -1,65 +1,43 @@ -import "./NumberInput.css"; -import "./Input.css"; -import { el } from "./dom"; -import { WritableProperty } from "../observable/WritableProperty"; import { property } from "../observable"; -import { LabelledControl } from "./LabelledControl"; -import { is_any_property, Property } from "../observable/Property"; - -export class NumberInput extends LabelledControl { - readonly element = el("span", { class: "core_NumberInput core_Input" }); - - readonly value: WritableProperty = property(0); +import { Property } from "../observable/Property"; +import { Input } from "./Input"; +import "./NumberInput.css" +export class NumberInput extends Input { readonly preferred_label_position = "left"; - private readonly input: HTMLInputElement = el("input", { - class: "core_NumberInput_inner core_Input_inner", - }); - constructor( - value = 0, - label?: string, - min: number | Property = -Infinity, - max: number | Property = Infinity, - step: number | Property = 1, + value: number = 0, + options?: { + label?: string; + min?: number | Property; + max?: number | Property; + step?: number | Property; + }, ) { - super(label); - - this.input.type = "number"; - this.input.valueAsNumber = value; - - this.set_prop("min", min); - this.set_prop("max", max); - this.set_prop("step", step); - - this.input.onchange = () => (this.value.val = this.input.valueAsNumber); - - this.element.append(this.input); - - this.disposables( - this.value.observe(value => (this.input.valueAsNumber = value)), - - this.enabled.observe(enabled => { - this.input.disabled = !enabled; - - if (enabled) { - this.element.classList.remove("disabled"); - } else { - this.element.classList.add("disabled"); - } - }), + super( + property(value), + "core_NumberInput", + "number", + "core_NumberInput_inner", + options && options.label, ); - this.element.style.width = "50px"; + if (options) { + const { min, max, step } = options; + this.set_attr("min", min, String); + this.set_attr("max", max, String); + this.set_attr("step", step, String); + } + + this.element.style.width = "54px"; } - private set_prop(prop: "min" | "max" | "step", value: T | Property): void { - if (is_any_property(value)) { - this.input[prop] = String(value.val); - this.disposable(value.observe(v => (this.input[prop] = String(v)))); - } else { - this.input[prop] = String(value); - } + protected get_input_value(): number { + return this.input.valueAsNumber; + } + + protected set_input_value(value: number): void { + this.input.valueAsNumber = value; } } diff --git a/src/core/gui/RendererView.ts b/src/core/gui/RendererView.ts index 87d08175..ee68c3bb 100644 --- a/src/core/gui/RendererView.ts +++ b/src/core/gui/RendererView.ts @@ -1,9 +1,9 @@ import { ResizableView } from "./ResizableView"; -import { el } from "./dom"; +import { create_element } from "./dom"; import { Renderer } from "../rendering/Renderer"; export class RendererView extends ResizableView { - readonly element = el("div"); + readonly element = create_element("div"); constructor(private renderer: Renderer) { super(); diff --git a/src/core/gui/TabContainer.css b/src/core/gui/TabContainer.css index 9ed91b04..9b258da1 100644 --- a/src/core/gui/TabContainer.css +++ b/src/core/gui/TabContainer.css @@ -14,7 +14,7 @@ margin: 0 1px -1px 1px; background-color: hsl(0, 0%, 12%); color: hsl(0, 0%, 75%); - font-size: 15px; + font-size: 13px; } .core_TabContainer_Tab:hover { diff --git a/src/core/gui/TabContainer.ts b/src/core/gui/TabContainer.ts index 1add86fc..1db0e1da 100644 --- a/src/core/gui/TabContainer.ts +++ b/src/core/gui/TabContainer.ts @@ -1,5 +1,5 @@ import { View } from "./View"; -import { el } from "./dom"; +import { create_element } from "./dom"; import { LazyView } from "./LazyView"; import { Resizable } from "./Resizable"; import { ResizableView } from "./ResizableView"; @@ -16,19 +16,19 @@ type TabInfo = Tab & { tab_element: HTMLSpanElement; lazy_view: LazyView }; const BAR_HEIGHT = 28; export class TabContainer extends ResizableView { - readonly element = el("div", { class: "core_TabContainer" }); + readonly element = create_element("div", { class: "core_TabContainer" }); private tabs: TabInfo[] = []; - private bar_element = el("div", { class: "core_TabContainer_Bar" }); - private panes_element = el("div", { class: "core_TabContainer_Panes" }); + private bar_element = create_element("div", { class: "core_TabContainer_Bar" }); + private panes_element = create_element("div", { class: "core_TabContainer_Panes" }); constructor(...tabs: Tab[]) { super(); - this.bar_element.onclick = this.bar_click; + this.bar_element.onmousedown = this.bar_mousedown; for (const tab of tabs) { - const tab_element = el("span", { + const tab_element = create_element("span", { class: "core_TabContainer_Tab", text: tab.title, data: { key: tab.key }, @@ -72,7 +72,7 @@ export class TabContainer extends ResizableView { return this; } - private bar_click = (e: MouseEvent) => { + private bar_mousedown = (e: MouseEvent) => { if (e.target instanceof HTMLElement) { const key = e.target.dataset["key"]; if (key) this.activate(key); @@ -89,7 +89,7 @@ export class TabContainer extends ResizableView { tab.tab_element.classList.remove("active"); } - tab.lazy_view.visible = active; + tab.lazy_view.visible.val = active; } } } diff --git a/src/core/gui/TextArea.css b/src/core/gui/TextArea.css new file mode 100644 index 00000000..21f9ecd3 --- /dev/null +++ b/src/core/gui/TextArea.css @@ -0,0 +1,33 @@ +.core_TextArea { + box-sizing: border-box; + display: inline-block; + border: var(--input-border); +} + +.core_TextArea .core_TextArea_inner { + box-sizing: border-box; + vertical-align: top; + padding: 3px; + border: var(--input-inner-border); + background-color: var(--input-bg-color); + color: var(--input-text-color); + outline: none; + font-size: 13px; +} + +.core_TextArea:hover { + border-color: var(--input-border-hover); +} + +.core_TextArea:focus-within { + border-color: var(--input-border-focus); +} + +.core_TextArea.disabled { + border: var(--input-border-disabled); +} + +.core_TextArea.disabled .core_TextArea_inner { + color: var(--input-text-color-disabled); + background-color: var(--input-bg-color-disabled); +} diff --git a/src/core/gui/TextArea.ts b/src/core/gui/TextArea.ts new file mode 100644 index 00000000..f419bd4e --- /dev/null +++ b/src/core/gui/TextArea.ts @@ -0,0 +1,46 @@ +import { LabelledControl } from "./LabelledControl"; +import { el } from "./dom"; +import { property } from "../observable"; +import { WritableProperty } from "../observable/WritableProperty"; +import "./TextArea.css"; + +export class TextArea extends LabelledControl { + readonly element: HTMLElement = el.div({ class: "core_TextArea" }); + + readonly preferred_label_position = "left"; + + readonly value: WritableProperty; + + private readonly text_element: HTMLTextAreaElement = el.textarea({ + class: "core_TextArea_inner", + }); + + constructor( + value = "", + options?: { + label?: string; + max_length?: number; + font_family?: string; + rows?: number; + cols?: number; + }, + ) { + super(options && options.label); + + if (options) { + if (options.max_length != undefined) this.text_element.maxLength = options.max_length; + if (options.font_family != undefined) + this.text_element.style.fontFamily = options.font_family; + if (options.rows != undefined) this.text_element.rows = options.rows; + if (options.cols != undefined) this.text_element.cols = options.cols; + } + + this.value = property(value); + + this.text_element.onchange = () => (this.value.val = this.text_element.value); + + this.disposables(this.value.observe(value => (this.text_element.value = value))); + + this.element.append(this.text_element); + } +} diff --git a/src/core/gui/TextInput.ts b/src/core/gui/TextInput.ts new file mode 100644 index 00000000..74e0e953 --- /dev/null +++ b/src/core/gui/TextInput.ts @@ -0,0 +1,36 @@ +import { Input } from "./Input"; +import { Property } from "../observable/Property"; +import { property } from "../observable"; + +export class TextInput extends Input { + readonly preferred_label_position = "left"; + + constructor( + value = "", + options?: { + label?: string; + max_length?: number | Property; + }, + ) { + super( + property(value), + "core_TextInput", + "text", + "core_TextInput_inner", + options && options.label, + ); + + if (options) { + const { max_length } = options; + this.set_attr("maxLength", max_length); + } + } + + protected get_input_value(): string { + return this.input.value; + } + + protected set_input_value(value: string): void { + this.input.value = value; + } +} diff --git a/src/core/gui/ToolBar.css b/src/core/gui/ToolBar.css index d629ebd1..6ada06c7 100644 --- a/src/core/gui/ToolBar.css +++ b/src/core/gui/ToolBar.css @@ -20,3 +20,7 @@ .core_ToolBar > .core_ToolBar_group > * { margin: 0 2px; } + +.core_ToolBar .core_Input { + height: 26px; +} diff --git a/src/core/gui/ToolBar.ts b/src/core/gui/ToolBar.ts index 1c0aeb41..ac48917f 100644 --- a/src/core/gui/ToolBar.ts +++ b/src/core/gui/ToolBar.ts @@ -1,10 +1,10 @@ import { View } from "./View"; -import { el } from "./dom"; +import { create_element } from "./dom"; import "./ToolBar.css"; import { LabelledControl } from "./LabelledControl"; export class ToolBar extends View { - readonly element = el("div", { class: "core_ToolBar" }); + readonly element = create_element("div", { class: "core_ToolBar" }); readonly height = 33; constructor(...children: View[]) { @@ -14,9 +14,12 @@ export class ToolBar extends View { for (const child of children) { if (child instanceof LabelledControl) { - const group = el("div", { class: "core_ToolBar_group" }); + const group = create_element("div", { class: "core_ToolBar_group" }); - if (child.preferred_label_position === "left") { + if ( + child.preferred_label_position === "left" || + child.preferred_label_position === "top" + ) { group.append(child.label.element, child.element); } else { group.append(child.element, child.label.element); diff --git a/src/core/gui/View.ts b/src/core/gui/View.ts index 3bc31f53..e2dd2ebc 100644 --- a/src/core/gui/View.ts +++ b/src/core/gui/View.ts @@ -2,6 +2,8 @@ import { Disposable } from "../observable/Disposable"; import { Disposer } from "../observable/Disposer"; import { Observable } from "../observable/Observable"; import { bind_hidden } from "./dom"; +import { WritableProperty } from "../observable/WritableProperty"; +import { property } from "../observable"; export abstract class View implements Disposable { abstract readonly element: HTMLElement; @@ -14,8 +16,14 @@ export abstract class View implements Disposable { this.element.id = id; } + readonly visible: WritableProperty = property(true); + private disposer = new Disposer(); + constructor() { + this.disposables(this.visible.observe(visible => (this.element.hidden = !visible))); + } + dispose(): void { this.element.remove(); this.disposer.dispose(); diff --git a/src/core/gui/dom.ts b/src/core/gui/dom.ts index 3bb42a36..69a1452d 100644 --- a/src/core/gui/dom.ts +++ b/src/core/gui/dom.ts @@ -2,16 +2,41 @@ import { Disposable } from "../observable/Disposable"; import { Observable } from "../observable/Observable"; import { is_property } from "../observable/Property"; -export function el( +export const el = { + div: (attributes?: {}, ...children: HTMLElement[]): HTMLDivElement => + create_element("div", attributes, ...children), + + table: (attributes?: {}, ...children: HTMLElement[]): HTMLTableElement => + create_element("table", attributes, ...children), + + tr: (attributes?: {}, ...children: HTMLElement[]): HTMLTableRowElement => + create_element("tr", attributes, ...children), + + th: ( + attributes?: { text?: string; col_span?: number }, + ...children: HTMLElement[] + ): HTMLTableHeaderCellElement => create_element("th", attributes, ...children), + + td: ( + attributes?: { text?: string; col_span?: number }, + ...children: HTMLElement[] + ): HTMLTableCellElement => create_element("td", attributes, ...children), + + textarea: (attributes?: {}, ...children: HTMLElement[]): HTMLTextAreaElement => + create_element("textarea", attributes, ...children), +}; + +export function create_element( tag_name: string, attributes?: { class?: string; - text?: string ; + text?: string; data?: { [key: string]: string }; + col_span?: number; }, ...children: HTMLElement[] ): T { - const element = document.createElement(tag_name) as T; + const element = document.createElement(tag_name) as HTMLTableCellElement; if (attributes) { if (attributes.class) element.className = attributes.class; @@ -22,11 +47,13 @@ export function el( element.dataset[key] = val; } } + + if (attributes.col_span) element.colSpan = attributes.col_span; } element.append(...children); - return element; + return (element as HTMLElement) as T; } export function bind_hidden(element: HTMLElement, observable: Observable): Disposable { diff --git a/src/core/gui/golden_layout_theme.css b/src/core/gui/golden_layout_theme.css index 9a23367c..5f1c6a9b 100644 --- a/src/core/gui/golden_layout_theme.css +++ b/src/core/gui/golden_layout_theme.css @@ -17,7 +17,7 @@ margin: 0 1px -1px 1px; background-color: hsl(0, 0%, 12%); color: hsl(0, 0%, 75%); - font-size: 15px; + font-size: 13px; } #root .lm_tab:hover { diff --git a/src/index.css b/src/core/gui/index.css similarity index 56% rename from src/index.css rename to src/core/gui/index.css index b8475e89..cb7c7931 100644 --- a/src/index.css +++ b/src/core/gui/index.css @@ -1,11 +1,28 @@ :root { + /* Basic view variables */ + --bg-color: hsl(0, 0%, 15%); --text-color: hsl(0, 0%, 80%); --text-color-disabled: hsl(0, 0%, 55%); --border-color: hsl(0, 0%, 25%); + /* Scrollbars */ + --scrollbar-color: hsl(0, 0%, 13%); --scrollbar-thumb-color: hsl(0, 0%, 17%); + + /* Inputs */ + + --input-bg-color: hsl(0, 0%, 12%); + --input-bg-color-disabled: hsl(0, 0%, 15%); + --input-text-color: hsl(0, 0%, 75%); + --input-text-color-disabled: var(--text-color-disabled); + --input-border: solid 1px hsl(0, 0%, 25%); + --input-border-hover: hsl(0, 0%, 35%); + --input-border-focus: hsl(0, 0%, 45%); + --input-border-disabled: solid 1px hsl(0, 0%, 20%); + + --input-inner-border: solid 1px hsl(0, 0%, 5%); } * { @@ -33,9 +50,15 @@ body { user-select: none; overflow: hidden; margin: 0; - font-size: 15px; - font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, - Arial, sans-serif; + font-size: 13px; background-color: var(--bg-color); color: var(--text-color); } + +* { + font-family: Verdana, Geneva, sans-serif; +} + +#root *[hidden] { + display: none; +} diff --git a/src/core/observable/DependentProperty.ts b/src/core/observable/DependentProperty.ts index 494c494c..54dd806c 100644 --- a/src/core/observable/DependentProperty.ts +++ b/src/core/observable/DependentProperty.ts @@ -15,7 +15,7 @@ export class DependentProperty extends AbstractMinimalProperty implements private _val?: T; get val(): T { - if (this.dependency_disposables) { + if (this.dependency_disposables.length) { return this._val as T; } else { return this.f(); diff --git a/src/core/observable/Property.ts b/src/core/observable/Property.ts index 9efbc1a7..01310d8b 100644 --- a/src/core/observable/Property.ts +++ b/src/core/observable/Property.ts @@ -15,5 +15,5 @@ export function is_property(observable: Observable): observable is Propert } export function is_any_property(observable: any): observable is Property { - return (observable as any).is_property; + return observable && (observable as any).is_property; } diff --git a/src/index.ts b/src/index.ts index 1bd1e9b8..7f4525e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { ApplicationView } from "./application/gui/ApplicationView"; import { Disposable } from "./core/observable/Disposable"; -import "./index.css"; +import "./core/gui/index.css"; import { throttle } from "lodash"; import Logger from "js-logger"; diff --git a/src/quest_editor/gui/NpcCountsView.ts b/src/quest_editor/gui/NpcCountsView.ts index 40f1784b..aa877d8d 100644 --- a/src/quest_editor/gui/NpcCountsView.ts +++ b/src/quest_editor/gui/NpcCountsView.ts @@ -1,6 +1,6 @@ import { ResizableView } from "../../core/gui/ResizableView"; -import { el } from "../../core/gui/dom"; +import { create_element } from "../../core/gui/dom"; export class NpcCountsView extends ResizableView { - readonly element = el("div"); + readonly element = create_element("div"); } diff --git a/src/quest_editor/gui/QuesInfoView.css b/src/quest_editor/gui/QuesInfoView.css new file mode 100644 index 00000000..e44a7909 --- /dev/null +++ b/src/quest_editor/gui/QuesInfoView.css @@ -0,0 +1,33 @@ +.quest_editor_QuesInfoView { + box-sizing: border-box; + padding: 3px; + overflow: auto; +} + +.quest_editor_QuesInfoView table { + width: 100%; +} + +.quest_editor_QuesInfoView th { + text-align: left; +} + +.quest_editor_QuesInfoView .core_TextInput { + width: 100%; +} + +.quest_editor_QuesInfoView .core_TextArea { + width: 100%; +} + +.quest_editor_QuesInfoView textarea { + width: 100%; +} + +.quest_editor_QuesInfoView_no_quest { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} diff --git a/src/quest_editor/gui/QuesInfoView.ts b/src/quest_editor/gui/QuesInfoView.ts index 50520e43..9c7d5d5a 100644 --- a/src/quest_editor/gui/QuesInfoView.ts +++ b/src/quest_editor/gui/QuesInfoView.ts @@ -2,36 +2,78 @@ import { ResizableView } from "../../core/gui/ResizableView"; import { el } from "../../core/gui/dom"; import { quest_editor_store } from "../stores/QuestEditorStore"; import { Episode } from "../../core/data_formats/parsing/quest/Episode"; +import { NumberInput } from "../../core/gui/NumberInput"; +import { Disposer } from "../../core/observable/Disposer"; +import { TextInput } from "../../core/gui/TextInput"; +import { TextArea } from "../../core/gui/TextArea"; +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" }); - private readonly table_element = el("table"); + private readonly table_element = el.table(); private readonly episode_element: HTMLElement; - private readonly id_element: HTMLElement; - private readonly name_element: HTMLElement; + private readonly id_input = this.disposable(new NumberInput()); + private readonly name_input = this.disposable(new TextInput()); + private readonly short_description_input = this.disposable( + new TextArea("", { + max_length: 128, + font_family: '"Courier New", monospace', + cols: 25, + rows: 5, + }), + ); + private readonly long_description_input = this.disposable( + new TextArea("", { + max_length: 288, + font_family: '"Courier New", monospace', + cols: 25, + rows: 10, + }), + ); + + private readonly no_quest_element = el.div({ class: "quest_editor_QuesInfoView_no_quest" }); + private readonly no_quest_label = this.disposable( + new Label("No quest loaded.", { enabled: false }), + ); + + private readonly quest_disposer = this.disposable(new Disposer()); constructor() { super(); const quest = quest_editor_store.current_quest; - this.bind_hidden(this.table_element, quest.map(q => q == undefined)); + this.no_quest_element.append(this.no_quest_label.element); + this.bind_hidden(this.no_quest_element, quest.map(q => q != undefined)); this.table_element.append( - el("tr", {}, el("th", { text: "Episode:" }), (this.episode_element = el("td"))), - el("tr", {}, el("th", { text: "ID:" }), (this.id_element = el("td"))), - el("tr", {}, el("th", { text: "Name:" }), (this.name_element = el("td"))), + el.tr({}, el.th({ text: "Episode:" }), (this.episode_element = el.td())), + el.tr({}, el.th({ text: "ID:" }), el.td({}, this.id_input.element)), + el.tr({}, el.th({ text: "Name:" }), el.td({}, this.name_input.element)), + el.tr({}, el.th({ text: "Short description:", col_span: 2 })), + el.tr({}, el.td({ col_span: 2 }, this.short_description_input.element)), + el.tr({}, el.th({ text: "Long description:", col_span: 2 })), + el.tr({}, el.td({ col_span: 2 }, this.long_description_input.element)), ); + this.bind_hidden(this.table_element, quest.map(q => q == undefined)); - this.element.append(this.table_element); + this.element.append(this.table_element, this.no_quest_element); this.disposables( quest.observe(q => { + this.quest_disposer.dispose(); + + this.episode_element.textContent = q ? Episode[q.episode] : ""; + if (q) { - this.episode_element.textContent = Episode[q.episode]; - this.id_element.textContent = q.id.val.toString(); - this.name_element.textContent = q.name.val; + this.quest_disposer.add_all( + this.id_input.value.bind_bi(q.id), + this.name_input.value.bind_bi(q.name), + this.short_description_input.value.bind_bi(q.short_description), + this.long_description_input.value.bind_bi(q.long_description), + ); } }), ); diff --git a/src/quest_editor/gui/QuestEditorView.ts b/src/quest_editor/gui/QuestEditorView.ts index 807a4bf2..0f6480e8 100644 --- a/src/quest_editor/gui/QuestEditorView.ts +++ b/src/quest_editor/gui/QuestEditorView.ts @@ -1,5 +1,5 @@ import { ResizableView } from "../../core/gui/ResizableView"; -import { el } from "../../core/gui/dom"; +import { create_element } from "../../core/gui/dom"; import { ToolBarView } from "./ToolBarView"; import GoldenLayout, { Container, ContentItem, ItemConfigType } from "golden-layout"; import { quest_editor_ui_persister } from "../persistence/QuestEditorUiPersister"; @@ -89,11 +89,11 @@ const DEFAULT_LAYOUT_CONTENT: ItemConfigType[] = [ ]; export class QuestEditorView extends ResizableView { - readonly element = el("div", { class: "quest_editor_QuestEditorView" }); + readonly element = create_element("div", { class: "quest_editor_QuestEditorView" }); private readonly tool_bar_view = this.disposable(new ToolBarView()); - private readonly layout_element = el("div", { class: "quest_editor_gl_container" }); + private readonly layout_element = create_element("div", { class: "quest_editor_gl_container" }); private readonly layout: Promise; constructor() { diff --git a/src/quest_editor/stores/QuestEditorStore.ts b/src/quest_editor/stores/QuestEditorStore.ts index a9be6daf..8956ca46 100644 --- a/src/quest_editor/stores/QuestEditorStore.ts +++ b/src/quest_editor/stores/QuestEditorStore.ts @@ -6,14 +6,20 @@ import { parse_quest } from "../../core/data_formats/parsing/quest"; import { ArrayBufferCursor } from "../../core/data_formats/cursor/ArrayBufferCursor"; import { Endianness } from "../../core/data_formats/Endianness"; import { SimpleUndo, UndoStack } from "../../old/core/undo"; +import { WritableProperty } from "../../core/observable/WritableProperty"; import Logger = require("js-logger"); const logger = Logger.get("quest_editor/gui/QuestEditorStore"); export class QuestEditorStore { + 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; + private readonly _current_quest = property(undefined); readonly current_quest: Property = this._current_quest; @@ -74,7 +80,7 @@ export class QuestEditorStore { }; private set_quest(quest?: ObservableQuest, filename?: string): void { - // this.current_quest_filename = filename; + this._current_quest_filename.val = filename; this.undo.reset(); this.script_undo.reset(); diff --git a/src/viewer/gui/ModelView.ts b/src/viewer/gui/ModelView.ts index e01a41ed..979d27a7 100644 --- a/src/viewer/gui/ModelView.ts +++ b/src/viewer/gui/ModelView.ts @@ -1,4 +1,4 @@ -import { el } from "../../core/gui/dom"; +import { create_element } from "../../core/gui/dom"; import { ResizableView } from "../../core/gui/ResizableView"; import { ToolBar } from "../../core/gui/ToolBar"; import "./ModelView.css"; @@ -18,10 +18,10 @@ const MODEL_LIST_WIDTH = 100; const ANIMATION_LIST_WIDTH = 130; export class ModelView extends ResizableView { - readonly element = el("div", { class: "viewer_ModelView" }); + readonly element = create_element("div", { class: "viewer_ModelView" }); private tool_bar_view = this.disposable(new ToolBarView()); - private container_element = el("div", { class: "viewer_ModelView_container" }); + private container_element = create_element("div", { class: "viewer_ModelView_container" }); private model_list_view = this.disposable( new ModelSelectListView(model_store.models, model_store.current_model), ); @@ -78,20 +78,18 @@ class ToolBarView extends View { private readonly open_file_button = new FileButton("Open file...", ".nj, .njm, .xj, .xvm"); private readonly skeleton_checkbox = new CheckBox(false, "Show skeleton"); private readonly play_animation_checkbox = new CheckBox(true, "Play animation"); - private readonly animation_frame_rate_input = new NumberInput( - PSO_FRAME_RATE, - "Frame rate:", - 1, - 240, - 1, - ); - private readonly animation_frame_input = new NumberInput( - 1, - "Frame:", - 1, - model_store.animation_frame_count, - 1, - ); + private readonly animation_frame_rate_input = new NumberInput(PSO_FRAME_RATE, { + label: "Frame rate:", + min: 1, + max: 240, + step: 1, + }); + private readonly animation_frame_input = new NumberInput(1, { + label: "Frame:", + min: 1, + max: model_store.animation_frame_count, + step: 1, + }); private readonly animation_frame_count_label = new Label( model_store.animation_frame_count.map(count => `/ ${count}`), ); @@ -147,7 +145,7 @@ class ToolBarView extends View { } class ModelSelectListView extends ResizableView { - element = el("ul", { class: "viewer_ModelSelectListView" }); + element = create_element("ul", { class: "viewer_ModelSelectListView" }); set borders(borders: boolean) { if (borders) { @@ -169,7 +167,7 @@ class ModelSelectListView extends ResizableView { models.forEach((model, index) => { this.element.append( - el("li", { text: model.name, data: { index: index.toString() } }), + create_element("li", { text: model.name, data: { index: index.toString() } }), ); }); diff --git a/src/viewer/gui/TextureView.ts b/src/viewer/gui/TextureView.ts index bbfc96e4..7b611bde 100644 --- a/src/viewer/gui/TextureView.ts +++ b/src/viewer/gui/TextureView.ts @@ -1,4 +1,4 @@ -import { el } from "../../core/gui/dom"; +import { create_element } from "../../core/gui/dom"; import { ResizableView } from "../../core/gui/ResizableView"; import { FileButton } from "../../core/gui/FileButton"; import { ToolBar } from "../../core/gui/ToolBar"; @@ -8,7 +8,7 @@ import { TextureRenderer } from "../rendering/TextureRenderer"; import { gui_store, GuiTool } from "../../core/stores/GuiStore"; export class TextureView extends ResizableView { - readonly element = el("div", { class: "viewer_TextureView" }); + readonly element = create_element("div", { class: "viewer_TextureView" }); private readonly open_file_button = new FileButton("Open file...", ".xvm"); diff --git a/src/viewer/rendering/ModelRenderer.ts b/src/viewer/rendering/ModelRenderer.ts index 6fd43c54..84e41a81 100644 --- a/src/viewer/rendering/ModelRenderer.ts +++ b/src/viewer/rendering/ModelRenderer.ts @@ -36,6 +36,7 @@ export class ModelRenderer extends Renderer implements Disposable { clip: AnimationClip; action: AnimationAction; }; + private update_animation_time = true; constructor() { super(new PerspectiveCamera(75, 1, 1, 200)); @@ -201,7 +202,11 @@ export class ModelRenderer extends Renderer implements Disposable { const frame_count = nj_motion.frame_count; if (frame > frame_count) frame = 1; if (frame < 1) frame = frame_count; - this.animation.action.time = (frame - 1) / PSO_FRAME_RATE; + + if (this.update_animation_time) { + this.animation.action.time = (frame - 1) / PSO_FRAME_RATE; + } + this.schedule_render(); } }; @@ -209,7 +214,9 @@ export class ModelRenderer extends Renderer implements Disposable { private update_animation_frame(): void { if (this.animation && !this.animation.action.paused) { const time = this.animation.action.time; + this.update_animation_time = false; model_store.animation_frame.val = time * PSO_FRAME_RATE + 1; + this.update_animation_time = true; } } }