diff --git a/ui/core/components/individual_sim_ui/apl_actions.ts b/ui/core/components/individual_sim_ui/apl_actions.ts index fd96b27e56..e3aae8e38d 100644 --- a/ui/core/components/individual_sim_ui/apl_actions.ts +++ b/ui/core/components/individual_sim_ui/apl_actions.ts @@ -1,119 +1,173 @@ import { - APLListItem, APLAction, APLActionCastSpell, APLActionSequence, APLActionWait, + APLValue, } from '../../proto/apl.js'; -import { ActionID, Spec } from '../../proto/common.js'; import { EventID } from '../../typed_event.js'; import { Input, InputConfig } from '../input.js'; -import { ActionId } from '../../proto_utils/action_id.js'; import { Player } from '../../player.js'; -import { stringComparator } from '../../utils.js'; +import { TextDropdownPicker } from '../dropdown_picker.js'; import * as AplHelpers from './apl_helpers.js'; +import * as AplValues from './apl_values.js'; -export class APLActionCastSpellPicker extends Input, APLActionCastSpell> { - private readonly spellIdPicker: AplHelpers.APLActionIDPicker; +export interface APLActionPickerConfig extends InputConfig, APLAction> { +} - constructor(parent: HTMLElement, player: Player, config: InputConfig, APLActionCastSpell>) { - super(parent, 'apl-action-cast-spell-picker-root', player, config); +export type APLActionType = APLAction['action']['oneofKind']; - this.spellIdPicker = new AplHelpers.APLActionIDPicker(this.rootElem, player, { - defaultLabel: 'Spell', - values: [], - changedEvent: (player: Player) => player.rotationChangeEmitter, - getValue: () => ActionId.fromProto(this.getSourceValue().spellId || ActionID.create()), - setValue: (eventID: EventID, player: Player, newValue: ActionId) => { - this.getSourceValue().spellId = newValue.toProto(); - player.rotationChangeEmitter.emit(eventID); - }, - }); +export class APLActionPicker extends Input, APLAction> { - this.init(); + private typePicker: TextDropdownPicker, APLActionType>; - const updateValues = async () => { - const playerStats = player.getCurrentStats(); - const spellPromises = Promise.all(playerStats.spells.map(spell => ActionId.fromProto(spell).fill())); - const cooldownPromises = Promise.all(playerStats.cooldowns.map(cd => ActionId.fromProto(cd).fill())); + private readonly actionDiv: HTMLElement; + private currentType: APLActionType; + private actionPicker: Input, any>|null; - let [spells, cooldowns] = await Promise.all([spellPromises, cooldownPromises]); - spells = spells.sort((a, b) => stringComparator(a.name, b.name)) - cooldowns = cooldowns.sort((a, b) => stringComparator(a.name, b.name)) + private readonly conditionPicker: AplValues.APLValuePicker; + + constructor(parent: HTMLElement, player: Player, config: APLActionPickerConfig) { + super(parent, 'apl-action-picker-root', player, config); + + this.conditionPicker = new AplValues.APLValuePicker(this.rootElem, this.modObject, { + changedEvent: (player: Player) => player.rotationChangeEmitter, + getValue: (player: Player) => this.getSourceValue().condition, + setValue: (eventID: EventID, player: Player, newValue: APLValue|undefined) => { + this.getSourceValue().condition = newValue; + player.rotationChangeEmitter.emit(eventID); + }, + }); - const values = [...spells, ...cooldowns].map(actionId => { + this.actionDiv = document.createElement('div'); + this.actionDiv.classList.add('apl-action-picker-action'); + this.rootElem.appendChild(this.actionDiv); + + const allActionTypes = Object.keys(actionTypeFactories) as Array>; + this.typePicker = new TextDropdownPicker(this.actionDiv, player, { + defaultLabel: 'Action', + values: allActionTypes.map(actionType => { return { - value: actionId, + value: actionType, + label: actionTypeFactories[actionType].label, }; - }); - this.spellIdPicker.setOptions(values); - }; - updateValues(); - player.currentStatsEmitter.on(updateValues); + }), + equals: (a, b) => a == b, + changedEvent: (player: Player) => player.rotationChangeEmitter, + getValue: (player: Player) => this.getSourceValue().action.oneofKind, + setValue: (eventID: EventID, player: Player, newValue: APLActionType) => { + const action = this.getSourceValue(); + if (action.action.oneofKind == newValue) { + return; + } + if (newValue) { + const factory = actionTypeFactories[newValue]; + const obj: any = { oneofKind: newValue }; + obj[newValue] = factory.newValue(); + action.action = obj; + } else { + action.action = { + oneofKind: newValue, + }; + } + player.rotationChangeEmitter.emit(eventID); + }, + }); + + this.currentType = undefined; + this.actionPicker = null; + + this.init(); } getInputElem(): HTMLElement | null { return this.rootElem; } - getInputValue(): APLActionCastSpell { - return APLActionCastSpell.create({ - spellId: this.spellIdPicker.getInputValue(), + getInputValue(): APLAction { + const actionType = this.typePicker.getInputValue(); + return APLAction.create({ + condition: this.conditionPicker.getInputValue(), + action: { + oneofKind: actionType, + ...((() => { + const val: any = {}; + if (actionType && this.actionPicker) { + val[actionType] = this.actionPicker.getInputValue(); + } + return val; + })()), + }, }) } - setInputValue(newValue: APLActionCastSpell) { + setInputValue(newValue: APLAction) { if (!newValue) { return; } - this.spellIdPicker.setInputValue(ActionId.fromProto(newValue.spellId || ActionID.create())); - } -} -export class APLActionSequencePicker extends Input, APLActionSequence> { + this.conditionPicker.setInputValue(newValue.condition || APLValue.create()); - constructor(parent: HTMLElement, player: Player, config: InputConfig, APLActionSequence>) { - super(parent, 'apl-action-sequence-picker-root', player, config); - this.init(); - } + const newActionType = newValue.action.oneofKind; + this.updateActionPicker(newActionType); - getInputElem(): HTMLElement | null { - return this.rootElem; + if (newActionType) { + this.actionPicker!.setInputValue((newValue.action as any)[newActionType]); + } } - getInputValue(): APLActionSequence { - return APLActionSequence.create({ - }) - } - - setInputValue(newValue: APLActionSequence) { - if (!newValue) { + private updateActionPicker(newActionType: APLActionType) { + const actionType = this.currentType; + if (newActionType == actionType) { return; } - } -} + this.currentType = newActionType; -export class APLActionWaitPicker extends Input, APLActionWait> { + if (this.actionPicker) { + this.actionPicker.rootElem.remove(); + this.actionPicker = null; + } - constructor(parent: HTMLElement, player: Player, config: InputConfig, APLActionWait>) { - super(parent, 'apl-action-wait-picker-root', player, config); - this.init(); - } + if (!newActionType) { + return; + } - getInputElem(): HTMLElement | null { - return this.rootElem; + this.typePicker.setInputValue(newActionType); + + const factory = actionTypeFactories[newActionType]; + this.actionPicker = factory.factory(this.actionDiv, this.modObject, { + changedEvent: (player: Player) => player.rotationChangeEmitter, + getValue: () => (this.getSourceValue().action as any)[newActionType] || factory.newValue(), + setValue: (eventID: EventID, player: Player, newValue: any) => { + (this.getSourceValue().action as any)[newActionType] = newValue; + player.rotationChangeEmitter.emit(eventID); + }, + }); } +} - getInputValue(): APLActionWait { - return APLActionWait.create({ - }) - } +type ActionTypeConfig = { + label: string, + newValue: () => object, + factory: (parent: HTMLElement, player: Player, config: InputConfig, any>) => Input, any>, +}; + +function inputBuilder(label: string, newValue: () => T, fields: Array>): ActionTypeConfig { + return { + label: label, + newValue: newValue, + factory: AplHelpers.aplInputBuilder(newValue, fields), + }; +} - setInputValue(newValue: APLActionWait) { - if (!newValue) { - return; - } - } -} \ No newline at end of file +export const actionTypeFactories: Record, ActionTypeConfig> = { + ['castSpell']: inputBuilder('Cast', APLActionCastSpell.create, [ + AplHelpers.actionIdFieldConfig('spellId', 'all_spells'), + ]), + ['sequence']: inputBuilder('Sequence', APLActionSequence.create, [ + ]), + ['wait']: inputBuilder('Wait', APLActionWait.create, [ + ]), +}; \ No newline at end of file diff --git a/ui/core/components/individual_sim_ui/apl_helpers.ts b/ui/core/components/individual_sim_ui/apl_helpers.ts index c0a7c3babe..e06f1c22c0 100644 --- a/ui/core/components/individual_sim_ui/apl_helpers.ts +++ b/ui/core/components/individual_sim_ui/apl_helpers.ts @@ -1,15 +1,65 @@ import { ActionId } from '../../proto_utils/action_id.js'; import { Player } from '../../player.js'; -import { DropdownPicker, DropdownPickerConfig, TextDropdownPicker } from '../dropdown_picker.js'; +import { EventID, TypedEvent } from '../../typed_event.js'; +import { stringComparator } from '../../utils.js'; +import { DropdownPicker, DropdownPickerConfig, DropdownValueConfig, TextDropdownPicker } from '../dropdown_picker.js'; +import { Input, InputConfig } from '../input.js'; +import { ActionID } from '../../proto/common.js'; +export type ACTION_ID_SET = 'all_spells' | 'dots'; -export interface APLActionIDPickerConfig extends Omit, 'equals' | 'setOptionContent'> { +export interface APLActionIDPickerConfig extends Omit, 'defaultLabel' | 'equals' | 'setOptionContent' | 'values' | 'getValue' | 'setValue'> { + actionIdSet: ACTION_ID_SET, + getValue: (obj: ModObject) => ActionID, + setValue: (eventID: EventID, obj: ModObject, newValue: ActionID) => void, } +const actionIdSets: Record) => Promise>>, +}> = { + ['all_spells']: { + defaultLabel: 'Spell', + getActionIDs: async (player) => { + const playerStats = player.getCurrentStats(); + const spellPromises = Promise.all(playerStats.spells.map(spell => ActionId.fromProto(spell).fill())); + const cooldownPromises = Promise.all(playerStats.cooldowns.map(cd => ActionId.fromProto(cd).fill())); + + let [spells, cooldowns] = await Promise.all([spellPromises, cooldownPromises]); + spells = spells.sort((a, b) => stringComparator(a.name, b.name)) + cooldowns = cooldowns.sort((a, b) => stringComparator(a.name, b.name)) + + return [...spells, ...cooldowns].map(actionId => { + return { + value: actionId, + }; + }); + }, + }, + ['dots']: { + defaultLabel: 'DoT Spell', + getActionIDs: async (player) => { + const playerStats = player.getCurrentStats(); + let spells = await Promise.all(playerStats.spells.map(spell => ActionId.fromProto(spell).fill())); + spells = spells.sort((a, b) => stringComparator(a.name, b.name)) + + return spells.map(actionId => { + return { + value: actionId, + }; + }); + }, + }, +}; + export class APLActionIDPicker extends DropdownPicker, ActionId> { constructor(parent: HTMLElement, player: Player, config: APLActionIDPickerConfig>) { + const actionIdSet = actionIdSets[config.actionIdSet]; super(parent, player, { ...config, + getValue: (player) => ActionId.fromProto(config.getValue(player)), + setValue: (eventID: EventID, player: Player, newValue: ActionId) => config.setValue(eventID, player, newValue.toProto()), + defaultLabel: actionIdSet.defaultLabel, equals: (a, b) => ((a == null) == (b == null)) && (!a || a.equals(b!)), setOptionContent: (button, valueConfig) => { const actionId = valueConfig.value; @@ -22,6 +72,112 @@ export class APLActionIDPicker extends DropdownPicker, ActionId> { const textElem = document.createTextNode(actionId.name); button.appendChild(textElem); }, + values: [], + }); + + const getActionIDs = actionIdSet.getActionIDs; + const updateValues = async () => { + const values = await getActionIDs(player); + this.setOptions(values); + }; + updateValues(); + player.currentStatsEmitter.on(updateValues); + } +} + +export interface APLPickerBuilderFieldConfig { + field: F, + newValue: () => T[F], + factory: (parent: HTMLElement, player: Player, config: InputConfig, T[F]>) => Input, T[F]> +} + +export interface APLPickerBuilderConfig extends InputConfig, T> { + newValue: () => T, + fields: Array>, +} + +export interface APLPickerBuilderField extends APLPickerBuilderFieldConfig { + picker: Input, T[F]>, +} + +export class APLPickerBuilder extends Input, T> { + private readonly config: APLPickerBuilderConfig; + private readonly fieldPickers: Array>; + + constructor(parent: HTMLElement, modObject: Player, config: APLPickerBuilderConfig) { + super(parent, 'apl-picker-builder-root', modObject, config); + this.config = config; + + const openSpan = document.createElement('span'); + openSpan.textContent = '('; + this.rootElem.appendChild(openSpan); + + this.fieldPickers = config.fields.map(fieldConfig => APLPickerBuilder.makeFieldPicker(this, fieldConfig)); + + const closeSpan = document.createElement('span'); + closeSpan.textContent = ')'; + this.rootElem.appendChild(closeSpan); + + this.init(); + } + + private static makeFieldPicker(builder: APLPickerBuilder, fieldConfig: APLPickerBuilderFieldConfig): APLPickerBuilderField { + const field: F = fieldConfig.field; + return { + ...fieldConfig, + picker: fieldConfig.factory(builder.rootElem, builder.modObject, { + changedEvent: (player: Player) => player.rotationChangeEmitter, + getValue: () => { + const source = builder.getSourceValue(); + if (!source[field]) { + source[field] = fieldConfig.newValue(); + } + return source[field]; + }, + setValue: (eventID: EventID, player: Player, newValue: any) => { + builder.getSourceValue()[field] = newValue; + player.rotationChangeEmitter.emit(eventID); + }, + }), + }; + } + + getInputElem(): HTMLElement { + return this.rootElem; + } + + getInputValue(): T { + const val = this.config.newValue(); + this.fieldPickers.forEach(pickerData => { + val[pickerData.field as keyof T] = pickerData.picker.getInputValue(); + }); + return val; + } + + setInputValue(newValue: T) { + this.fieldPickers.forEach(pickerData => { + pickerData.picker.setInputValue(newValue[pickerData.field as keyof T]); }); } +} + +export function actionIdFieldConfig(field: string, actionIdSet: ACTION_ID_SET): APLPickerBuilderFieldConfig { + return { + field: field, + newValue: () => ActionID.create(), + factory: (parent, player, config) => new APLActionIDPicker(parent, player, { + ...config, + actionIdSet: actionIdSet, + }), + }; +} + +export function aplInputBuilder(newValue: () => T, fields: Array>): (parent: HTMLElement, player: Player, config: InputConfig, any>) => Input, any> { + return (parent, player, config) => { + return new APLPickerBuilder(parent, player, { + ...config, + newValue: newValue, + fields: fields, + }) + } } \ No newline at end of file diff --git a/ui/core/components/individual_sim_ui/apl_rotation_picker.ts b/ui/core/components/individual_sim_ui/apl_rotation_picker.ts index cac6e5861b..d81e13b8c7 100644 --- a/ui/core/components/individual_sim_ui/apl_rotation_picker.ts +++ b/ui/core/components/individual_sim_ui/apl_rotation_picker.ts @@ -1,16 +1,12 @@ -import { ActionID } from '../../proto/common.js'; -import { EventID } from '../../typed_event.js'; +import { Tooltip } from 'bootstrap'; + +import { EventID, TypedEvent } from '../../typed_event.js'; import { Player } from '../../player.js'; import { BooleanPicker } from '../boolean_picker.js'; -import { DropdownPicker, DropdownPickerConfig, TextDropdownPicker } from '../dropdown_picker.js'; import { ListItemPickerConfig, ListPicker } from '../list_picker.js'; -import { StringPicker } from '../string_picker.js'; import { APLListItem, APLAction, - APLActionCastSpell, - APLActionSequence, - APLActionWait, APLRotation, } from '../../proto/apl.js'; @@ -18,7 +14,7 @@ import { Component } from '../component.js'; import { Input, InputConfig } from '../input.js'; import { SimUI } from '../../sim_ui.js'; -import * as AplActions from './apl_actions.js'; +import { APLActionPicker } from './apl_actions.js'; export class APLRotationPicker extends Component { constructor(parent: HTMLElement, simUI: SimUI, modPlayer: Player) { @@ -39,7 +35,7 @@ export class APLRotationPicker extends Component { action: {}, }), copyItem: (oldItem: APLListItem) => APLListItem.clone(oldItem), - newItemPicker: (parent: HTMLElement, listPicker: ListPicker, APLListItem>, index: number, config: ListItemPickerConfig, APLListItem>) => new APLListItemPicker(parent, modPlayer, index, config), + newItemPicker: (parent: HTMLElement, listPicker: ListPicker, APLListItem>, index: number, config: ListItemPickerConfig, APLListItem>) => new APLListItemPicker(parent, modPlayer, config), inlineMenuBar: true, }); @@ -49,10 +45,8 @@ export class APLRotationPicker extends Component { class APLListItemPicker extends Input, APLListItem> { private readonly player: Player; - private readonly itemIndex: number; - private readonly hidePicker: Input; - private readonly notesPicker: Input; + private readonly hidePicker: Input, boolean>; private readonly actionPicker: APLActionPicker; private getItem(): APLListItem { @@ -61,35 +55,22 @@ class APLListItemPicker extends Input, APLListItem> { }); } - constructor(parent: HTMLElement, player: Player, itemIndex: number, config: ListItemPickerConfig, APLListItem>) { - super(parent, 'apl-list-item-picker-root', player, config); + constructor(parent: HTMLElement, player: Player, config: ListItemPickerConfig, APLListItem>) { + super(parent, 'apl-list-item-picker-root', player, { + ...config, + enableWhen: () => !this.getItem().hide, + }); this.player = player; - this.itemIndex = itemIndex; - this.hidePicker = new BooleanPicker(this.rootElem, null, { - label: 'Hide', - labelTooltip: 'Ignores this APL action.', - inline: true, + this.hidePicker = new HidePicker(ListPicker.getItemHeaderElem(this), player, { changedEvent: () => this.player.rotationChangeEmitter, getValue: () => this.getItem().hide, - setValue: (eventID: EventID, _: null, newValue: boolean) => { + setValue: (eventID: EventID, player: Player, newValue: boolean) => { this.getItem().hide = newValue; this.player.rotationChangeEmitter.emit(eventID); }, }); - this.notesPicker = new StringPicker(this.rootElem, null, { - label: 'Notes', - labelTooltip: 'Description for this action. The sim will ignore this value, it\'s just to allow self-documentation.', - inline: true, - changedEvent: () => this.player.rotationChangeEmitter, - getValue: () => this.getItem().notes, - setValue: (eventID: EventID, _: null, newValue: string) => { - this.getItem().notes = newValue; - this.player.rotationChangeEmitter.emit(eventID); - }, - }); - this.actionPicker = new APLActionPicker(this.rootElem, this.player, { changedEvent: () => this.player.rotationChangeEmitter, getValue: () => this.getItem().action!, @@ -108,7 +89,6 @@ class APLListItemPicker extends Input, APLListItem> { getInputValue(): APLListItem { const item = APLListItem.create({ hide: this.hidePicker.getInputValue(), - notes: this.notesPicker.getInputValue(), action: this.actionPicker.getInputValue(), }); return item; @@ -119,128 +99,49 @@ class APLListItemPicker extends Input, APLListItem> { return; } this.hidePicker.setInputValue(newValue.hide); - this.notesPicker.setInputValue(newValue.notes); this.actionPicker.setInputValue(newValue.action || APLAction.create()); } } -export interface APLActionPickerConfig extends InputConfig, APLAction> { -} - -type APLActionType = APLAction['action']['oneofKind']; +class HidePicker extends Input, boolean> { + private readonly inputElem: HTMLElement; + private readonly iconElem: HTMLElement; + private tooltip: Tooltip; -class APLActionPicker extends Input, APLAction> { + constructor(parent: HTMLElement, modObject: Player, config: InputConfig, boolean>) { + super(parent, 'hide-picker-root', modObject, config); - private typePicker: TextDropdownPicker, APLActionType>; + this.inputElem = ListPicker.makeActionElem('hide-picker-button', 'Enable/Disable', 'fa-eye'); + this.iconElem = this.inputElem.childNodes[0] as HTMLElement; + this.rootElem.appendChild(this.inputElem); + this.tooltip = Tooltip.getOrCreateInstance(this.inputElem); - private currentType: APLActionType; - private actionPicker: Input, any>|null; - - constructor(parent: HTMLElement, player: Player, config: APLActionPickerConfig) { - super(parent, 'apl-action-picker-root', player, config); + this.init(); - const allActionTypes = Object.keys(APLActionPicker.actionTypeFactories) as Array>; - this.typePicker = new TextDropdownPicker(this.rootElem, player, { - defaultLabel: 'Action', - values: allActionTypes.map(actionType => { - return { - value: actionType, - label: APLActionPicker.actionTypeFactories[actionType].label, - }; - }), - equals: (a, b) => a == b, - changedEvent: (player: Player) => player.rotationChangeEmitter, - getValue: (player: Player) => this.getSourceValue().action.oneofKind, - setValue: (eventID: EventID, player: Player, newValue: APLActionType) => { - const action = this.getSourceValue(); - if (action.action.oneofKind == newValue) { - return; - } - if (newValue) { - const factory = APLActionPicker.actionTypeFactories[newValue]; - const obj: any = { oneofKind: newValue }; - obj[newValue] = factory.newValue(); - action.action = obj; - } else { - action.action = { - oneofKind: newValue, - }; - } - player.rotationChangeEmitter.emit(eventID); - }, + this.inputElem.addEventListener('click', event => { + this.setInputValue(!this.getInputValue()); + this.inputChanged(TypedEvent.nextEventID()); }); - - this.currentType = undefined; - this.actionPicker = null; - - this.init(); - //player.rotationChangeEmitter.on(() => this.updateActionPicker(this.typePicker.getInputValue())); } - getInputElem(): HTMLElement | null { - return this.rootElem; + getInputElem(): HTMLElement { + return this.inputElem; } - getInputValue(): APLAction { - const actionType = this.typePicker.getInputValue(); - return APLAction.create({ - action: { - oneofKind: actionType, - ...((() => { - if (!actionType || !this.actionPicker) return; - const val: any = {}; - val[actionType] = this.actionPicker.getInputValue(); - return val; - })()), - }, - }) - } - - setInputValue(newValue: APLAction) { - if (!newValue) { - return; - } - - const newActionType = newValue.action.oneofKind; - this.updateActionPicker(newActionType); - - if (newActionType) { - this.actionPicker!.setInputValue((newValue.action as any)[newActionType]); - } + getInputValue(): boolean { + return this.iconElem.classList.contains('fa-eye-slash'); } - private updateActionPicker(newActionType: APLActionType) { - const actionType = this.currentType; - if (newActionType == actionType) { - return; + setInputValue(newValue: boolean) { + if (newValue) { + this.iconElem.classList.add('fa-eye-slash'); + this.iconElem.classList.remove('fa-eye'); + this.tooltip.setContent({'.tooltip-inner': 'Enable Action'}); + } else { + this.iconElem.classList.add('fa-eye'); + this.iconElem.classList.remove('fa-eye-slash'); + //this.inputElem.setAttribute('data-bs-title', 'Disable Action'); + this.tooltip.setContent({'.tooltip-inner': 'Disable Action'}); } - this.currentType = newActionType; - - if (this.actionPicker) { - this.actionPicker.rootElem.remove(); - this.actionPicker = null; - } - - if (!newActionType) { - return; - } - - this.typePicker.setInputValue(newActionType); - - const factory = APLActionPicker.actionTypeFactories[newActionType]; - this.actionPicker = new factory.factory(this.rootElem, this.modObject, { - changedEvent: (player: Player) => player.rotationChangeEmitter, - getValue: () => (this.getSourceValue().action as any)[newActionType] || factory.newValue(), - setValue: (eventID: EventID, player: Player, newValue: any) => { - (this.getSourceValue().action as any)[newActionType] = newValue; - player.rotationChangeEmitter.emit(eventID); - }, - }); } - - private static actionTypeFactories: Record, { label: string, newValue: () => object, factory: new (parent: HTMLElement, player: Player, config: InputConfig, any>) => Input, any> }> = { - ['castSpell']: { label: 'Cast', newValue: APLActionCastSpell.create, factory: AplActions.APLActionCastSpellPicker }, - ['sequence']: { label: 'Sequence', newValue: APLActionSequence.create, factory: AplActions.APLActionSequencePicker }, - ['wait']: { label: 'Wait', newValue: APLActionWait.create, factory: AplActions.APLActionWaitPicker }, - }; -} +} \ No newline at end of file diff --git a/ui/core/components/individual_sim_ui/apl_values.ts b/ui/core/components/individual_sim_ui/apl_values.ts new file mode 100644 index 0000000000..1ea9e2693f --- /dev/null +++ b/ui/core/components/individual_sim_ui/apl_values.ts @@ -0,0 +1,291 @@ +import { + APLValue, + APLValueAnd, + APLValueOr, + APLValueNot, + APLValueCompare, + APLValueCompare_ComparisonOperator as ComparisonOperator, + APLValueConst, + APLValueDotIsActive, +} from '../../proto/apl.js'; + +import { EventID, TypedEvent } from '../../typed_event.js'; +import { Input, InputConfig } from '../input.js'; +import { Player } from '../../player.js'; +import { TextDropdownPicker, TextDropdownValueConfig } from '../dropdown_picker.js'; +import { ListItemPickerConfig, ListPicker, ListItemAction } from '../list_picker.js'; + +import * as AplHelpers from './apl_helpers.js'; + +export class APLValueConstValuePicker extends Input, string> { + private readonly inputElem: HTMLInputElement; + + constructor(parent: HTMLElement, modObject: Player, config: InputConfig, string>) { + super(parent, 'apl-value-const-picker-root', modObject, config); + + this.inputElem = document.createElement('input'); + this.inputElem.type = 'text'; + this.rootElem.appendChild(this.inputElem); + + this.init(); + + this.inputElem.addEventListener('change', event => { + this.inputChanged(TypedEvent.nextEventID()); + }); + this.inputElem.addEventListener('input', event => { + this.updateSize(); + }); + this.updateSize(); + } + + getInputElem(): HTMLElement { + return this.inputElem; + } + + getInputValue(): string { + return this.inputElem.value; + } + + setInputValue(newValue: string) { + this.inputElem.value = newValue; + this.updateSize(); + } + + private updateSize() { + const newSize = Math.max(3, this.inputElem.value.length); + if (this.inputElem.size != newSize) + this.inputElem.size = newSize; + } +} + +export interface APLValuePickerConfig extends InputConfig, APLValue|undefined> { +} + +export type APLValueType = APLValue['value']['oneofKind']; + +export class APLValuePicker extends Input, APLValue|undefined> { + + private typePicker: TextDropdownPicker, APLValueType>; + + private currentType: APLValueType; + private valuePicker: Input, any>|null; + + constructor(parent: HTMLElement, player: Player, config: APLValuePickerConfig) { + super(parent, 'apl-value-picker-root', player, config); + + const allValueTypes = Object.keys(valueTypeFactories) as Array>; + this.typePicker = new TextDropdownPicker(this.rootElem, player, { + defaultLabel: 'No Condition', + values: [{ + value: undefined, + label: 'None', + } as TextDropdownValueConfig].concat(allValueTypes.map(valueType => { + return { + value: valueType, + label: valueTypeFactories[valueType].label, + }; + })), + equals: (a, b) => a == b, + changedEvent: (player: Player) => player.rotationChangeEmitter, + getValue: (player: Player) => this.getSourceValue()?.value.oneofKind, + setValue: (eventID: EventID, player: Player, newValue: APLValueType) => { + const sourceValue = this.getSourceValue(); + if (sourceValue?.value.oneofKind == newValue) { + return; + } + + if (newValue) { + const factory = valueTypeFactories[newValue]; + const obj: any = { oneofKind: newValue }; + obj[newValue] = factory.newValue(); + if (sourceValue) { + sourceValue.value = obj; + } else { + const newSourceValue = APLValue.create(); + newSourceValue.value = obj; + this.setSourceValue(eventID, newSourceValue); + } + } else { + this.setSourceValue(eventID, newValue); + } + player.rotationChangeEmitter.emit(eventID); + }, + }); + + this.currentType = undefined; + this.valuePicker = null; + + this.init(); + } + + getInputElem(): HTMLElement | null { + return this.rootElem; + } + + getInputValue(): APLValue|undefined { + const valueType = this.typePicker.getInputValue(); + if (!valueType) { + return undefined; + } else { + return APLValue.create({ + value: { + oneofKind: valueType, + ...((() => { + const val: any = {}; + if (valueType && this.valuePicker) { + val[valueType] = this.valuePicker.getInputValue(); + } + return val; + })()), + }, + }) + } + } + + setInputValue(newValue: APLValue|undefined) { + const newValueType = newValue?.value.oneofKind; + this.updateValuePicker(newValueType); + + if (newValueType && newValue) { + this.valuePicker!.setInputValue((newValue.value as any)[newValueType]); + } + } + + private updateValuePicker(newValueType: APLValueType) { + const valueType = this.currentType; + if (newValueType == valueType) { + return; + } + this.currentType = newValueType; + + if (this.valuePicker) { + this.valuePicker.rootElem.remove(); + this.valuePicker = null; + } + + if (!newValueType) { + return; + } + + this.typePicker.setInputValue(newValueType); + + const factory = valueTypeFactories[newValueType]; + this.valuePicker = factory.factory(this.rootElem, this.modObject, { + changedEvent: (player: Player) => player.rotationChangeEmitter, + getValue: () => { + const sourceVal = this.getSourceValue(); + return sourceVal ? (sourceVal.value as any)[newValueType] || factory.newValue() : factory.newValue(); + }, + setValue: (eventID: EventID, player: Player, newValue: any) => { + const sourceVal = this.getSourceValue(); + if (sourceVal) { + (sourceVal.value as any)[newValueType] = newValue; + } + player.rotationChangeEmitter.emit(eventID); + }, + }); + } +} + +type ValueTypeConfig = { + label: string, + newValue: () => T, + factory: (parent: HTMLElement, player: Player, config: InputConfig, T>) => Input, T>, +}; + +function inputBuilder(label: string, newValue: () => T, fields: Array>): ValueTypeConfig { + return { + label: label, + newValue: newValue, + factory: AplHelpers.aplInputBuilder(newValue, fields), + }; +} + +const valueTypeFactories: Record, ValueTypeConfig> = { + ['const']: inputBuilder('Const', APLValueConst.create, [ + { + field: 'val', + newValue: () => '', + factory: (parent, player, config) => new APLValueConstValuePicker(parent, player, config), + }, + ]), + ['and']: inputBuilder('All of', APLValueAnd.create, [ + { + field: 'vals', + newValue: () => [], + factory: (parent, player, config) => new ListPicker, APLValue|undefined>(parent, player, { + ...config, + // Override setValue to replace undefined elements with default messages. + setValue: (eventID: EventID, player: Player, newValue: Array) => { + config.setValue(eventID, player, newValue.map(val => val || APLValue.create())); + }, + + itemLabel: 'Value', + newItem: APLValue.create, + copyItem: (oldValue: APLValue|undefined) => oldValue ? APLValue.clone(oldValue) : oldValue, + newItemPicker: (parent: HTMLElement, listPicker: ListPicker, APLValue|undefined>, index: number, config: ListItemPickerConfig, APLValue|undefined>) => new APLValuePicker(parent, player, config), + horizontalLayout: true, + allowedActions: ['create', 'delete'], + }), + }, + ]), + ['or']: inputBuilder('Any of', APLValueOr.create, [ + { + field: 'vals', + newValue: () => [], + factory: (parent, player, config) => new ListPicker, APLValue|undefined>(parent, player, { + ...config, + // Override setValue to replace undefined elements with default messages. + setValue: (eventID: EventID, player: Player, newValue: Array) => { + config.setValue(eventID, player, newValue.map(val => val || APLValue.create())); + }, + + itemLabel: 'Value', + newItem: APLValue.create, + copyItem: (oldValue: APLValue|undefined) => oldValue ? APLValue.clone(oldValue) : oldValue, + newItemPicker: (parent: HTMLElement, listPicker: ListPicker, APLValue|undefined>, index: number, config: ListItemPickerConfig, APLValue|undefined>) => new APLValuePicker(parent, player, config), + horizontalLayout: true, + allowedActions: ['create', 'delete'], + }), + }, + ]), + ['not']: inputBuilder('Not', APLValueNot.create, [ + { + field: 'val', + newValue: APLValue.create, + factory: (parent, player, config) => new APLValuePicker(parent, player, config), + }, + ]), + ['cmp']: inputBuilder('Compare', APLValueCompare.create, [ + { + field: 'lhs', + newValue: APLValue.create, + factory: (parent, player, config) => new APLValuePicker(parent, player, config), + }, + { + field: 'op', + newValue: () => ComparisonOperator.OpEq, + factory: (parent, player, config) => new TextDropdownPicker(parent, player, { + ...config, + defaultLabel: 'None', + equals: (a, b) => a == b, + values: [ + { value: ComparisonOperator.OpEq, label: '==' }, + { value: ComparisonOperator.OpNe, label: '!=' }, + { value: ComparisonOperator.OpGe, label: '>=' }, + { value: ComparisonOperator.OpGt, label: '>' }, + { value: ComparisonOperator.OpLe, label: '<=' }, + { value: ComparisonOperator.OpLt, label: '<' }, + ], + }), + }, + { + field: 'rhs', + newValue: APLValue.create, + factory: (parent, player, config) => new APLValuePicker(parent, player, config), + }, + ]), + ['dotIsActive']: inputBuilder('Dot Is Active', APLValueDotIsActive.create, [ + AplHelpers.actionIdFieldConfig('spellId', 'dots'), + ]), +}; \ No newline at end of file diff --git a/ui/core/components/input.ts b/ui/core/components/input.ts index 9ee92aca3d..00c101d150 100644 --- a/ui/core/components/input.ts +++ b/ui/core/components/input.ts @@ -117,6 +117,10 @@ export abstract class Input extends Component { return this.inputConfig.getValue(this.modObject); } + protected setSourceValue(eventID: EventID, newValue: T) { + this.inputConfig.setValue(eventID, this.modObject, newValue); + } + // Child classes should call this method when the value in the input element changes. inputChanged(eventID: EventID) { this.inputConfig.setValue(eventID, this.modObject, this.getInputValue()); diff --git a/ui/core/components/list_picker.ts b/ui/core/components/list_picker.ts index a4548d29de..b2e7d776c7 100644 --- a/ui/core/components/list_picker.ts +++ b/ui/core/components/list_picker.ts @@ -4,6 +4,8 @@ import { swap } from '../utils.js'; import { Input, InputConfig } from './input.js'; +export type ListItemAction = 'create' | 'delete' | 'move' | 'copy'; + export interface ListPickerConfig extends InputConfig> { title?: string, titleTooltip?: string, @@ -13,6 +15,10 @@ export interface ListPickerConfig extends InputConfig, index: number, config: ListItemPickerConfig) => Input, inlineMenuBar?: boolean, hideUi?: boolean, + horizontalLayout?: boolean, + + // If set, only actions included in the list are allowed. Otherwise, all actions are allowed. + allowedActions?: Array, } export interface ListItemPickerConfig extends InputConfig { @@ -43,24 +49,43 @@ export class ListPicker extends Input${config.title}` : '' }
- `; if (this.config.hideUi) { this.rootElem.classList.add('hide-ui'); } + if (this.config.horizontalLayout) { + this.config.inlineMenuBar = true; + this.rootElem.classList.add('horizontal'); + } if (this.config.titleTooltip) Tooltip.getOrCreateInstance(this.rootElem.querySelector('.list-picker-title') as HTMLElement); this.itemsDiv = this.rootElem.getElementsByClassName('list-picker-items')[0] as HTMLElement; - const newItemButton = this.rootElem.getElementsByClassName('list-picker-new-button')[0] as HTMLElement; - newItemButton.addEventListener('click', event => { - const newItem = this.config.newItem(); - const newList = this.config.getValue(this.modObject).concat([newItem]); - this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList); - }); + if (this.actionEnabled('create')) { + let newItemButton = null; + let newButtonTooltip: Tooltip|null = null; + if (this.config.horizontalLayout) { + newItemButton = ListPicker.makeActionElem('link-success', `New ${config.itemLabel}`, 'fa-plus') + newButtonTooltip = Tooltip.getOrCreateInstance(newItemButton); + } else { + newItemButton = document.createElement('button'); + newItemButton.classList.add('btn', 'btn-primary'); + newItemButton.textContent = `New ${config.itemLabel}`; + } + newItemButton.classList.add('list-picker-new-button'); + newItemButton.addEventListener('click', event => { + const newItem = this.config.newItem(); + const newList = this.config.getValue(this.modObject).concat([newItem]); + this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList); + if (newButtonTooltip) { + newButtonTooltip.hide(); + } + }); + this.rootElem.appendChild(newItemButton); + } this.init(); } @@ -89,6 +114,10 @@ export class ListPicker extends Input this.itemPickerPairs[i].picker.setInputValue(val)) } + private actionEnabled(action: ListItemAction): boolean { + return !this.config.allowedActions || this.config.allowedActions.includes(action); + } + private addNewPicker() { const index = this.itemPickerPairs.length; const itemContainer = document.createElement('div'); @@ -96,77 +125,29 @@ export class ListPicker extends Input - ${this.config.itemLabel && !this.config.inlineMenuBar ? `
${this.config.itemLabel} ${this.itemPickerPairs.length + 1}
` : ''} - - - - - - - - - - - - - - ${!this.config.inlineMenuBar ? itemHTML : ''} - `; - - const upButton = itemContainer.getElementsByClassName('list-picker-item-up')[0] as HTMLElement; - const upButtonTooltip = Tooltip.getOrCreateInstance(upButton); - - upButton.addEventListener('click', event => { - if (index == 0) { - return; - } - - const newList = this.config.getValue(this.modObject); - swap(newList, index, index - 1); - this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList); - upButtonTooltip.hide(); - }); + const itemElem = document.createElement('div'); + itemElem.classList.add('list-picker-item'); - const downButton = itemContainer.getElementsByClassName('list-picker-item-down')[0] as HTMLElement; - const downButtonTooltip = Tooltip.getOrCreateInstance(downButton); + const itemHeader = document.createElement('div'); + itemHeader.classList.add('list-picker-item-header'); + const itemHTML = '
'; - downButton.addEventListener('click', event => { - if (index == this.itemPickerPairs.length - 1) { - return; + if (this.config.inlineMenuBar) { + itemContainer.appendChild(itemElem); + itemContainer.appendChild(itemHeader); + } else { + itemContainer.appendChild(itemHeader); + itemContainer.appendChild(itemElem); + if (this.config.itemLabel) { + const itemLabel = document.createElement('h6'); + itemLabel.classList.add('list-picker-item-title'); + itemLabel.textContent = `${this.config.itemLabel} ${this.itemPickerPairs.length + 1}`; + itemHeader.appendChild(itemLabel); } + } - const newList = this.config.getValue(this.modObject); - swap(newList, index, index + 1); - this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList); - downButtonTooltip.hide(); - }); - - const copyButton = itemContainer.getElementsByClassName('list-picker-item-copy')[0] as HTMLElement; - const copyButtonTooltip = Tooltip.getOrCreateInstance(copyButton); - - copyButton.addEventListener('click', event => { - const newList = this.config.getValue(this.modObject) - newList.push(this.config.copyItem(newList[index])); - this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList); - copyButtonTooltip.hide(); - }); - - const deleteButton = itemContainer.getElementsByClassName('list-picker-item-delete')[0] as HTMLElement; - const deleteButtonTooltip = Tooltip.getOrCreateInstance(deleteButton); - - deleteButton.addEventListener('click', event => { - const newList = this.config.getValue(this.modObject); - newList.splice(index, 1); - this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList); - deleteButtonTooltip.hide(); - }); - - const itemElem = itemContainer.getElementsByClassName('list-picker-item')[0] as HTMLElement; const itemPicker = this.config.newItemPicker(itemElem, this, index, { changedEvent: this.config.changedEvent, getValue: (modObj: ModObject) => this.getSourceValue()[index], @@ -176,8 +157,90 @@ export class ListPicker extends Input { + if (index == 0) { + return; + } + + const newList = this.config.getValue(this.modObject); + swap(newList, index, index - 1); + this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList); + upButtonTooltip.hide(); + }); + + const downButton = ListPicker.makeActionElem('list-picker-item-down', 'Move Down', 'fa-angle-down'); + itemHeader.appendChild(downButton); + const downButtonTooltip = Tooltip.getOrCreateInstance(downButton); + + downButton.addEventListener('click', event => { + if (index == this.itemPickerPairs.length - 1) { + return; + } + + const newList = this.config.getValue(this.modObject); + swap(newList, index, index + 1); + this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList); + downButtonTooltip.hide(); + }); + } + + if (this.actionEnabled('copy')) { + const copyButton = ListPicker.makeActionElem('list-picker-item-copy', `Copy to New ${this.config.itemLabel}`, 'fa-copy'); + itemHeader.appendChild(copyButton); + const copyButtonTooltip = Tooltip.getOrCreateInstance(copyButton); + + copyButton.addEventListener('click', event => { + const newList = this.config.getValue(this.modObject) + newList.push(this.config.copyItem(newList[index])); + this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList); + copyButtonTooltip.hide(); + }); + } + + if (this.actionEnabled('delete')) { + const deleteButton = ListPicker.makeActionElem('list-picker-item-delete', `Delete ${this.config.itemLabel}`, 'fa-times'); + deleteButton.classList.add('link-danger'); + itemHeader.appendChild(deleteButton); + const deleteButtonTooltip = Tooltip.getOrCreateInstance(deleteButton); + + deleteButton.addEventListener('click', event => { + const newList = this.config.getValue(this.modObject); + newList.splice(index, 1); + this.config.setValue(TypedEvent.nextEventID(), this.modObject, newList); + deleteButtonTooltip.hide(); + }); + } this.itemPickerPairs.push({ elem: itemContainer, picker: itemPicker }); } + + static makeActionElem(cssClass: string, title: string, iconCssClass: string): HTMLElement { + const actionElem = document.createElement('a'); + actionElem.classList.add('list-picker-item-action', cssClass); + actionElem.href = 'javascript:void(0)'; + actionElem.setAttribute('role', 'button'); + actionElem.setAttribute('data-bs-toggle', 'tooltip'); + actionElem.setAttribute('data-bs-title', title); + + const icon = document.createElement('i'); + icon.classList.add('fa', 'fa-xl', iconCssClass); + actionElem.appendChild(icon); + + return actionElem; + } + + static getItemHeaderElem(itemPicker: Input): HTMLElement { + const itemElem = itemPicker.rootElem.parentElement!; + const headerElem = (itemElem.nextElementSibling || itemElem.previousElementSibling); + if (!headerElem?.classList.contains('list-picker-item-header')) { + throw new Error('Could not find list item header'); + } + return headerElem as HTMLElement; + } } diff --git a/ui/scss/core/components/_dropdown_picker.scss b/ui/scss/core/components/_dropdown_picker.scss index f75ce94b58..8e9577c4ba 100644 --- a/ui/scss/core/components/_dropdown_picker.scss +++ b/ui/scss/core/components/_dropdown_picker.scss @@ -6,4 +6,9 @@ .dropdown-picker-button, .dropdown-picker-item>.dropdown-item { display: flex; align-items: center; + padding: 2px; +} + +.dropdown-picker-root .dropdown { + width: auto; } \ No newline at end of file diff --git a/ui/scss/core/components/_list_picker.scss b/ui/scss/core/components/_list_picker.scss index 9aedfa6b35..4c97793741 100644 --- a/ui/scss/core/components/_list_picker.scss +++ b/ui/scss/core/components/_list_picker.scss @@ -79,3 +79,14 @@ display: none !important; } } + +.list-picker-root.horizontal { + flex-direction: row; + align-items: center; + + .list-picker-items { + display: flex; + flex-wrap: wrap; + align-items: center; + } +} diff --git a/ui/scss/core/components/individual_sim_ui/_apl_helpers.scss b/ui/scss/core/components/individual_sim_ui/_apl_helpers.scss index 48782712d2..c054a1eb3b 100644 --- a/ui/scss/core/components/individual_sim_ui/_apl_helpers.scss +++ b/ui/scss/core/components/individual_sim_ui/_apl_helpers.scss @@ -5,4 +5,10 @@ height: 1rem; margin: 2px; background-size: cover; +} + +.apl-picker-builder-root { + flex-direction: row; + align-items: center; + width: auto; } \ No newline at end of file diff --git a/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss b/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss index a830d10d80..268246e036 100644 --- a/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss +++ b/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss @@ -26,6 +26,8 @@ .apl-list-item-picker-root { display: flex; align-items: center; + flex-direction: row; + margin: 5px; &> :not(:last-child) { margin-right: map.get($spacers, 2); @@ -36,11 +38,38 @@ } } -.apl-action-picker-root { +.apl-action-picker-root, .apl-value-picker-root { flex-direction: row; margin: 2px; .input-root { margin: 0; + width: auto; + align-items: center; } +} +.apl-list-item-picker-root > .apl-action-picker-root { + flex-direction: column; +} + +.apl-value-picker-root * { + font-size: calc(var(--bs-btn-font-size) - 0.2rem); +} + +.apl-list-item-picker > * > * > .list-picker-item { + flex-grow: 1; +} + +.apl-action-picker-action { + display: flex; + flex-direction: row; + align-items: center; +} + +.apl-value-const-picker-root > input { + text-align: center; +} + +.hide-picker-root { + margin: 0; } \ No newline at end of file diff --git a/ui/scss/shared/_bootstrap_style_overrides.scss b/ui/scss/shared/_bootstrap_style_overrides.scss index cfa5397cb1..7eb6f59c75 100644 --- a/ui/scss/shared/_bootstrap_style_overrides.scss +++ b/ui/scss/shared/_bootstrap_style_overrides.scss @@ -66,6 +66,10 @@ color: $link-danger-color !important; } +.link-success { + color: $success !important; +} + .modal { .modal-dialog { .modal-header,