diff --git a/src/core/gui/Button.css b/src/core/gui/Button.css index 7061b9f5..e9cf46f8 100644 --- a/src/core/gui/Button.css +++ b/src/core/gui/Button.css @@ -22,7 +22,7 @@ box-sizing: border-box; background-color: var(--control-bg-color); height: 24px; - padding: 3px 8px; + padding: 3px 5px; border: var(--control-inner-border); overflow: hidden; } @@ -45,6 +45,11 @@ color: hsl(0, 0%, 55%); } +.core_Button_inner > * { + display: inline-block; + margin: 0 3px; +} + .core_Button_center { flex: 1; text-align: left; @@ -59,11 +64,3 @@ align-content: center; font-size: 11px; } - -.core_Button_left { - padding: 0 6px 0 0; -} - -.core_Button_right { - padding: 0 0 0 6px; -} diff --git a/src/core/gui/Button.ts b/src/core/gui/Button.ts index e772b682..1cb096f4 100644 --- a/src/core/gui/Button.ts +++ b/src/core/gui/Button.ts @@ -31,11 +31,12 @@ export class Button extends Control { super(el.button({ class: "core_Button" }, inner_element), options); + this.center_element = el.span({ class: "core_Button_center" }); + if (options && options.icon_left != undefined) { inner_element.append(el.span({ class: "core_Button_left" }, icon(options.icon_left))); } - this.center_element = el.span({ class: "core_Button_center" }); inner_element.append(this.center_element); if (options && options.icon_right != undefined) { @@ -71,5 +72,6 @@ export class Button extends Control { protected set_text(text: string): void { this.center_element.textContent = text; + this.center_element.hidden = text === ""; } } diff --git a/src/core/gui/FileButton.ts b/src/core/gui/FileButton.ts index 6b7a5874..fc87486e 100644 --- a/src/core/gui/FileButton.ts +++ b/src/core/gui/FileButton.ts @@ -54,7 +54,7 @@ export class FileButton extends Control { ); } - inner_element.append(el.span({ text })); + inner_element.append(el.span({ class: "core_Button_center", text })); this.element.append(inner_element, this.input); diff --git a/src/core/observable/index.ts b/src/core/observable/index.ts index 79e933d0..c05dba11 100644 --- a/src/core/observable/index.ts +++ b/src/core/observable/index.ts @@ -6,6 +6,7 @@ import { Property } from "./property/Property"; import { DependentProperty } from "./property/DependentProperty"; import { WritableListProperty } from "./property/list/WritableListProperty"; import { SimpleWritableListProperty } from "./property/list/SimpleWritableListProperty"; +import { Observable } from "./Observable"; export function emitter(): Emitter { return new SimpleEmitter(); @@ -15,8 +16,11 @@ export function property(value: T): WritableProperty { return new SimpleProperty(value); } -export function list_property(...values: T[]): WritableListProperty { - return new SimpleWritableListProperty(...values); +export function list_property( + extract_observables?: (element: T) => Observable[], + ...elements: T[] +): WritableListProperty { + return new SimpleWritableListProperty(extract_observables, ...elements); } export function add(left: Property, right: number): Property { diff --git a/src/core/observable/property/AbstractMinimalProperty.ts b/src/core/observable/property/AbstractMinimalProperty.ts index 07a5231e..cbeb4b19 100644 --- a/src/core/observable/property/AbstractMinimalProperty.ts +++ b/src/core/observable/property/AbstractMinimalProperty.ts @@ -17,13 +17,13 @@ export abstract class AbstractMinimalProperty implements Property { observe( observer: (change: PropertyChangeEvent) => void, - options: { call_now?: boolean } = {}, + options?: { call_now?: boolean }, ): Disposable { if (!this.observers.includes(observer)) { this.observers.push(observer); } - if (options.call_now) { + if (options && options.call_now) { this.call_observer(observer, this.val); } diff --git a/src/core/observable/property/list/ListProperty.ts b/src/core/observable/property/list/ListProperty.ts index 4251b1e4..05d46776 100644 --- a/src/core/observable/property/list/ListProperty.ts +++ b/src/core/observable/property/list/ListProperty.ts @@ -39,7 +39,7 @@ export type ListReplacement = { export type ListUpdate = { readonly type: ListChangeType.Update; - readonly update: T[]; + readonly updated: T[]; readonly index: number; }; @@ -48,5 +48,8 @@ export interface ListProperty extends Property { get(index: number): T; - observe_list(observer: (change: ListPropertyChangeEvent) => void): Disposable; + observe_list( + observer: (change: ListPropertyChangeEvent) => void, + options?: { call_now?: boolean }, + ): Disposable; } diff --git a/src/core/observable/property/list/SimpleWritableListProperty.ts b/src/core/observable/property/list/SimpleWritableListProperty.ts index 4cf716cb..2da2330a 100644 --- a/src/core/observable/property/list/SimpleWritableListProperty.ts +++ b/src/core/observable/property/list/SimpleWritableListProperty.ts @@ -12,49 +12,80 @@ const logger = Logger.get("core/observable/property/list/SimpleWritableListPrope export class SimpleWritableListProperty extends AbstractProperty implements WritableListProperty { - readonly length: Property; + readonly length: Property; // TODO: update length get val(): T[] { return this.get_val(); } - set val(values: T[]) { - this.set_val(values); + set val(elements: T[]) { + this.set_val(elements); } get_val(): T[] { - return this.values; + return this.elements; } - set_val(values: T[]): T[] { - const removed = this.values.splice(0, this.values.length, ...values); + set_val(elements: T[]): T[] { + const removed = this.elements.splice(0, this.elements.length, ...elements); this.emit_list({ type: ListChangeType.Replacement, removed, - inserted: values, + inserted: elements, from: 0, removed_to: removed.length, - inserted_to: values.length, + inserted_to: elements.length, }); return removed; } private readonly _length = property(0); - private readonly values: T[]; + private readonly elements: T[]; + private readonly extract_observables?: (element: T) => Observable[]; + /** + * Internal observers which observe observables related to this list's elements so that their + * changes can be propagated via update events. + */ + private readonly element_observers: { index: number; disposables: Disposable[] }[] = []; + /** + * External observers which are observing this list. + */ private readonly list_observers: ((change: ListPropertyChangeEvent) => void)[] = []; - constructor(...values: T[]) { + /** + * @param extract_observables - Extractor function called on each element in this list. Changes + * to the returned observables will be propagated via update events. + * @param elements - Initial elements of this list. + */ + constructor(extract_observables?: (element: T) => Observable[], ...elements: T[]) { super(); this.length = this._length; - this.values = values; + this.elements = elements; + this.extract_observables = extract_observables; } - observe_list(observer: (change: ListPropertyChangeEvent) => void): Disposable { + observe_list( + observer: (change: ListPropertyChangeEvent) => void, + options?: { call_now?: true }, + ): Disposable { + if (this.element_observers.length === 0 && this.extract_observables) { + this.replace_element_observers(this.elements, 0, Infinity); + } + if (!this.list_observers.includes(observer)) { this.list_observers.push(observer); } + if (options && options.call_now) { + this.call_list_observer(observer, { + type: ListChangeType.Insertion, + inserted: this.elements, + from: 0, + to: this.elements.length, + }); + } + return { dispose: () => { const index = this.list_observers.indexOf(observer); @@ -62,6 +93,16 @@ export class SimpleWritableListProperty extends AbstractProperty if (index !== -1) { this.list_observers.splice(index, 1); } + + if (this.list_observers.length === 0) { + for (const { disposables } of this.element_observers) { + for (const disposable of disposables) { + disposable.dispose(); + } + } + + this.element_observers.splice(0, Infinity); + } }, }; } @@ -74,21 +115,21 @@ export class SimpleWritableListProperty extends AbstractProperty /* TODO */ throw new Error("not implemented"); } - update(f: (value: T[]) => T[]): void { - this.splice(0, this.values.length, ...f(this.values)); + update(f: (element: T[]) => T[]): void { + this.splice(0, this.elements.length, ...f(this.elements)); } get(index: number): T { - return this.values[index]; + return this.elements[index]; } - set(index: number, value: T): void { - const removed = [this.values[index]]; - this.values[index] = value; + set(index: number, element: T): void { + const removed = [this.elements[index]]; + this.elements[index] = element; this.emit_list({ type: ListChangeType.Replacement, removed, - inserted: [value], + inserted: [element], from: index, removed_to: index + 1, inserted_to: index + 1, @@ -96,7 +137,7 @@ export class SimpleWritableListProperty extends AbstractProperty } clear(): void { - const removed = this.values.splice(0, this.values.length); + const removed = this.elements.splice(0, this.elements.length); this.emit_list({ type: ListChangeType.Replacement, removed, @@ -111,9 +152,9 @@ export class SimpleWritableListProperty extends AbstractProperty let removed: T[]; if (delete_count == undefined) { - removed = this.values.splice(index); + removed = this.elements.splice(index); } else { - removed = this.values.splice(index, delete_count, ...items); + removed = this.elements.splice(index, delete_count, ...items); } this.emit_list({ @@ -129,14 +170,76 @@ export class SimpleWritableListProperty extends AbstractProperty } protected emit_list(change: ListPropertyChangeEvent): void { - for (const observer of this.list_observers) { - try { - observer(change); - } catch (e) { - logger.error("Observer threw error.", e); + if (this.list_observers.length && this.extract_observables) { + switch (change.type) { + case ListChangeType.Insertion: + this.replace_element_observers(change.inserted, change.from, 0); + break; + + case ListChangeType.Removal: + this.replace_element_observers([], change.from, change.removed.length); + break; + + case ListChangeType.Replacement: + this.replace_element_observers( + change.inserted, + change.from, + change.removed.length, + ); + break; } } - this.emit(this.values); + for (const observer of this.list_observers) { + this.call_list_observer(observer, change); + } + + this.emit(this.elements); + } + + private call_list_observer( + observer: (change: ListPropertyChangeEvent) => void, + change: ListPropertyChangeEvent, + ): void { + try { + observer(change); + } catch (e) { + logger.error("Observer threw error.", e); + } + } + + private replace_element_observers(new_elements: T[], from: number, amount: number): void { + let index = from; + + const removed = this.element_observers.splice( + from, + amount, + ...new_elements.map(element => { + const obj = { + index, + disposables: this.extract_observables!(element).map(observable => + observable.observe(() => { + this.emit_list({ + type: ListChangeType.Update, + updated: [element], + index: obj.index, + }); + }), + ), + }; + index++; + return obj; + }), + ); + + for (const { disposables } of removed) { + for (const disposable of disposables) { + disposable.dispose(); + } + } + + while (index < this.element_observers.length) { + this.element_observers[index].index += index; + } } } diff --git a/src/hunt_optimizer/gui/OptimizerView.css b/src/hunt_optimizer/gui/OptimizerView.css new file mode 100644 index 00000000..26c75a34 --- /dev/null +++ b/src/hunt_optimizer/gui/OptimizerView.css @@ -0,0 +1,5 @@ +.hunt_optimizer_OptimizerView { + display: flex; + align-items: stretch; + overflow: hidden; +} diff --git a/src/hunt_optimizer/gui/OptimizerView.ts b/src/hunt_optimizer/gui/OptimizerView.ts index 81e01219..995d7781 100644 --- a/src/hunt_optimizer/gui/OptimizerView.ts +++ b/src/hunt_optimizer/gui/OptimizerView.ts @@ -1,6 +1,7 @@ import { ResizableWidget } from "../../core/gui/ResizableWidget"; import { el } from "../../core/gui/dom"; import { WantedItemsView } from "./WantedItemsView"; +import "./OptimizerView.css"; export class OptimizerView extends ResizableWidget { private readonly wanted_items_view: WantedItemsView; @@ -11,12 +12,4 @@ export class OptimizerView extends ResizableWidget { this.wanted_items_view = this.disposable(new WantedItemsView()); this.element.append(this.wanted_items_view.element); } - - resize(width: number, height: number): this { - super.resize(width, height); - - this.wanted_items_view.resize(Math.min(200, width), height); - - return this; - } } diff --git a/src/hunt_optimizer/gui/WantedItemsView.css b/src/hunt_optimizer/gui/WantedItemsView.css index 7ae12daf..cb901c57 100644 --- a/src/hunt_optimizer/gui/WantedItemsView.css +++ b/src/hunt_optimizer/gui/WantedItemsView.css @@ -2,4 +2,16 @@ display: flex; flex-direction: column; align-items: stretch; + overflow: hidden; +} + +.hunt_optimizer_WantedItemsView .hunt_optimizer_WantedItemsView_table_wrapper { + flex: 1; + width: 100%; + overflow: auto; +} + + +.hunt_optimizer_WantedItemsView .hunt_optimizer_WantedItemsView_table_wrapper table { + width: 100%; } diff --git a/src/hunt_optimizer/gui/WantedItemsView.ts b/src/hunt_optimizer/gui/WantedItemsView.ts index b10d5f31..a555bf39 100644 --- a/src/hunt_optimizer/gui/WantedItemsView.ts +++ b/src/hunt_optimizer/gui/WantedItemsView.ts @@ -1,36 +1,103 @@ -import { ResizableWidget } from "../../core/gui/ResizableWidget"; import { el, Icon } from "../../core/gui/dom"; import "./WantedItemsView.css"; import { hunt_optimizer_store } from "../stores/HuntOptimizerStore"; import { Button } from "../../core/gui/Button"; import { Disposer } from "../../core/observable/Disposer"; +import { Widget } from "../../core/gui/Widget"; +import { + ListChangeType, + ListPropertyChangeEvent, +} from "../../core/observable/property/list/ListProperty"; +import { WantedItemModel } from "../model"; +import { NumberInput } from "../../core/gui/NumberInput"; -export class WantedItemsView extends ResizableWidget { +export class WantedItemsView extends Widget { private readonly tbody_element = el.tbody(); private readonly table_disposer = this.disposable(new Disposer()); constructor() { super(el.div({ class: "hunt_optimizer_WantedItemsView" })); - this.element.append(el.h2({ text: "Wanted Items" }), el.table({}, this.tbody_element)); + this.element.append( + el.h2({ text: "Wanted Items" }), + el.div( + { class: "hunt_optimizer_WantedItemsView_table_wrapper" }, + el.table({}, this.tbody_element), + ), + ); - hunt_optimizer_store.wanted_items.observe_list(this.update_table); + hunt_optimizer_store.wanted_items.observe_list(this.update_table, { call_now: true }); } - private update_table = (): void => { - this.tbody_element.append( - ...hunt_optimizer_store.wanted_items.val.map(wanted_item => { - const remove_button = this.table_disposer.add( - new Button("", { icon_left: Icon.Remove }), - ); + private update_table = (change: ListPropertyChangeEvent): void => { + switch (change.type) { + case ListChangeType.Insertion: + { + const rows = change.inserted.map(this.create_row); - return el.tr( - {}, - el.td({ text: wanted_item.amount.toString() }), - el.td({ text: wanted_item.item_type.name }), - el.td({}, remove_button.element), - ); - }), + if (change.from >= this.tbody_element.childElementCount) { + this.tbody_element.append(...rows); + } else { + for (let i = change.from; i < change.to; i++) { + this.tbody_element.children[i].insertAdjacentElement( + "afterend", + rows[i - change.from], + ); + } + } + } + break; + + case ListChangeType.Removal: + for (let i = change.from; i < change.to; i++) { + this.tbody_element.children[change.from].remove(); + } + break; + + case ListChangeType.Replacement: + { + const rows = change.inserted.map(this.create_row); + + for (let i = change.from; i < change.removed_to; i++) { + this.tbody_element.children[change.from].remove(); + } + + if (change.from >= this.tbody_element.childElementCount) { + this.tbody_element.append(...rows); + } else { + for (let i = change.from; i < change.inserted_to; i++) { + this.tbody_element.children[i].insertAdjacentElement( + "afterend", + rows[i - change.from], + ); + } + } + } + break; + + case ListChangeType.Update: + // TODO: update row + break; + } + }; + + private create_row = (wanted_item: WantedItemModel): HTMLTableRowElement => { + const amount_input = this.table_disposer.add( + new NumberInput(wanted_item.amount.val, { min: 1, step: 1 }), + ); + + this.table_disposer.add_all( + amount_input.value.bind_to(wanted_item.amount), + amount_input.value.observe(({ value }) => wanted_item.set_amount(value)), + ); + + const remove_button = this.table_disposer.add(new Button("", { icon_left: Icon.Remove })); + + return el.tr( + {}, + el.td({}, amount_input.element), + el.td({ text: wanted_item.item_type.name }), + el.td({}, remove_button.element), ); }; } diff --git a/src/hunt_optimizer/model/index.ts b/src/hunt_optimizer/model/index.ts index a8d22371..74e80fae 100644 --- a/src/hunt_optimizer/model/index.ts +++ b/src/hunt_optimizer/model/index.ts @@ -2,9 +2,26 @@ import { ItemType } from "../../core/model/items"; import { DifficultyModel, SectionIdModel } from "../../core/model"; import { Episode } from "../../core/data_formats/parsing/quest/Episode"; import { Duration } from "luxon"; +import { Property } from "../../core/observable/property/Property"; +import { WritableProperty } from "../../core/observable/property/WritableProperty"; +import { property } from "../../core/observable"; export class WantedItemModel { - constructor(readonly item_type: ItemType, readonly amount: number) {} + readonly item_type: ItemType; + readonly amount: Property; + + private readonly _amount: WritableProperty; + + constructor(item_type: ItemType, amount: number) { + this.item_type = item_type; + this._amount = property(amount); + this.amount = this._amount; + } + + set_amount(amount: number): this { + this._amount.val = amount; + return this; + } } export class OptimalResultModel { diff --git a/src/hunt_optimizer/persistence/HuntOptimizerPersister.ts b/src/hunt_optimizer/persistence/HuntOptimizerPersister.ts index 2fc50c1b..7170ea68 100644 --- a/src/hunt_optimizer/persistence/HuntOptimizerPersister.ts +++ b/src/hunt_optimizer/persistence/HuntOptimizerPersister.ts @@ -13,7 +13,7 @@ class HuntOptimizerPersister extends Persister { wanted_items.map( ({ item_type, amount }): PersistedWantedItem => ({ itemTypeId: item_type.id, - amount, + amount: amount.val, }), ), ); diff --git a/src/hunt_optimizer/stores/HuntOptimizerStore.ts b/src/hunt_optimizer/stores/HuntOptimizerStore.ts index 9744ba58..c74d330e 100644 --- a/src/hunt_optimizer/stores/HuntOptimizerStore.ts +++ b/src/hunt_optimizer/stores/HuntOptimizerStore.ts @@ -35,7 +35,9 @@ class HuntOptimizerStore { readonly wanted_items: ListProperty; readonly result: Property; - private readonly _wanted_items: WritableListProperty = list_property(); + private readonly _wanted_items: WritableListProperty = list_property( + wanted_item => [wanted_item.amount], + ); private readonly _result: WritableProperty = property( undefined, ); @@ -67,7 +69,7 @@ class HuntOptimizerStore { // Initialize this set before awaiting data, so user changes don't affect this optimization // run from this point on. const wanted_items = new Set( - this.wanted_items.val.filter(w => w.amount > 0).map(w => w.item_type), + this.wanted_items.val.filter(w => w.amount.val > 0).map(w => w.item_type), ); const methods = await hunt_method_stores.current.val.methods.promise; @@ -77,7 +79,7 @@ class HuntOptimizerStore { const constraints: { [item_name: string]: { min: number } } = {}; for (const wanted of this.wanted_items.val) { - constraints[wanted.item_type.name] = { min: wanted.amount }; + constraints[wanted.item_type.name] = { min: wanted.amount.val }; } // Add a variable to the LP model per method per difficulty per section ID.