feat(slots UI): better slot UI

This commit is contained in:
Rachel Powers
2024-04-24 21:19:13 -07:00
parent eb4463c8ab
commit c87d3f8bd8
10 changed files with 205 additions and 309 deletions

View File

@@ -1,5 +1,5 @@
use crate::{
device::{Device, DeviceTemplate},
device::{Device, DeviceTemplate, SlotOccupant, SlotOccupantTemplate},
grammar::{BatchMode, LogicType, SlotLogicType},
interpreter::{self, FrozenIC, ICError, LineError},
network::{CableConnectionType, Connection, FrozenNetwork, Network},
@@ -747,6 +747,32 @@ impl VM {
Ok(())
}
pub fn set_slot_occupant(
&mut self,
id: u32,
index: usize,
template: SlotOccupantTemplate,
) -> Result<(), VMError> {
let Some(device) = self.devices.get(&id) else {
return Err(VMError::UnknownId(id));
};
let mut device_ref = device.borrow_mut();
let slot = device_ref
.slots
.get_mut(index)
.ok_or(ICError::SlotIndexOutOfRange(index as f64))?;
if let Some(id) = template.id.as_ref() {
self.id_space.use_id(*id)?;
}
let occupant = SlotOccupant::from_template(template, || self.id_space.next());
slot.occupant = Some(occupant);
Ok(())
}
pub fn save_vm_state(&self) -> FrozenVM {
FrozenVM {
ics: self.ics.values().map(|ic| ic.borrow().into()).collect(),

View File

@@ -3,7 +3,7 @@ mod utils;
mod types;
use ic10emu::{
device::{Device, DeviceTemplate},
device::{Device, DeviceTemplate, SlotOccupantTemplate},
grammar::{LogicType, SlotLogicType},
vm::{FrozenVM, VMError, VM},
};
@@ -489,6 +489,12 @@ impl VMRef {
Ok(self.vm.borrow_mut().remove_device(id)?)
}
#[wasm_bindgen(js_name = "setSlotOccupant", skip_typescript)]
pub fn set_slot_occupant(&self, id: u32, index: usize, template: JsValue) -> Result<(), JsError> {
let template: SlotOccupantTemplate = serde_wasm_bindgen::from_value(template)?;
Ok(self.vm.borrow_mut().set_slot_occupant(id, index, template)?)
}
#[wasm_bindgen(js_name = "saveVMState", skip_typescript)]
pub fn save_vm_state(&self) -> JsValue {
let state = self.vm.borrow().save_vm_state();

View File

@@ -177,6 +177,7 @@ export interface FrozenVM {
export interface VMRef {
addDeviceFromTemplate(template: DeviceTemplate): number;
setSlotOccupant(id: number, index: number, template: SlotOccupantTemplate);
saveVMState(): FrozenVM;
restoreVMState(state: FrozenVM): void;
}

View File

@@ -79,7 +79,7 @@ export class App extends BaseElement {
window.App.set(this);
}
protected createRenderRoot(): HTMLElement | DocumentFragment {
createRenderRoot(): HTMLElement | DocumentFragment {
const root = super.createRenderRoot();
root.addEventListener("app-share-session", this._handleShare.bind(this));
root.addEventListener("app-open-file", this._handleOpenFile.bind(this));

View File

@@ -94,6 +94,22 @@ export const VMDeviceMixin = <T extends Constructor<LitElement>>(
return root;
}
disconnectedCallback(): void {
window.VM.get().then((vm) =>
vm.removeEventListener(
"vm-device-modified",
this._handleDeviceModified.bind(this),
),
);
window.VM.get().then((vm) =>
vm.removeEventListener(
"vm-devices-update",
this._handleDevicesModified.bind(this),
),
);
}
_handleDeviceModified(e: CustomEvent) {
const id = e.detail;
if (this.deviceID === id) {
@@ -182,7 +198,6 @@ export const VMDeviceMixin = <T extends Constructor<LitElement>>(
this.pins = pins;
}
}
}
return VMDeviceMixinClass as Constructor<VMDeviceMixinInterface> & T;
};
@@ -208,6 +223,17 @@ export const VMActiveICMixin = <T extends Constructor<LitElement>>(
return root;
}
disconnectedCallback(): void {
window.VM.get().then((vm) =>
vm.removeEventListener("vm-run-ic", this._handleDeviceModified.bind(this)),
);
window.App.app.session.removeEventListener(
"session-active-ic",
this._handleActiveIC.bind(this),
);
}
_handleActiveIC(e: CustomEvent) {
const id = e.detail;
if (this.deviceID !== id) {
@@ -223,22 +249,33 @@ export const VMActiveICMixin = <T extends Constructor<LitElement>>(
export declare class VMDeviceDBMixinInterface {
deviceDB: DeviceDB;
_handleDeviceDBLoad(e: CustomEvent): void
_handleDeviceDBLoad(e: CustomEvent): void;
postDBSetUpdate(): void;
}
export const VMDeviceDBMixin = <T extends Constructor<LitElement>>(superClass: T) => {
export const VMDeviceDBMixin = <T extends Constructor<LitElement>>(
superClass: T,
) => {
class VMDeviceDBMixinClass extends superClass {
connectedCallback(): void {
const root = super.connectedCallback();
window.VM.vm.addEventListener(
"vm-device-db-loaded",
this._handleDeviceDBLoad.bind(this),
);
this.deviceDB = window.VM.vm.db!;
if (typeof window.VM.vm.db !== "undefined") {
this.deviceDB = window.VM.vm.db!;
}
return root;
}
disconnectedCallback(): void {
window.VM.vm.removeEventListener(
"vm-device-db-loaded",
this._handleDeviceDBLoad.bind(this),
)
}
_handleDeviceDBLoad(e: CustomEvent) {
this.deviceDB = e.detail;
}
@@ -249,11 +286,14 @@ export const VMDeviceDBMixin = <T extends Constructor<LitElement>>(superClass: T
return this._deviceDB;
}
postDBSetUpdate(): void { }
@state()
set deviceDB(val: DeviceDB) {
this._deviceDB = val;
this.postDBSetUpdate();
}
}
return VMDeviceDBMixinClass as Constructor<VMDeviceDBMixinInterface> & T
}
return VMDeviceDBMixinClass as Constructor<VMDeviceDBMixinInterface> & T;
};

View File

@@ -1,17 +1,15 @@
import { html, css, HTMLTemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { html, css, HTMLTemplateResult, PropertyValueMap } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { BaseElement, defaultCss } from "components";
import SlInput from "@shoelace-style/shoelace/dist/components/input/input.js";
import { structuralEqual } from "../../utils";
import SlDrawer from "@shoelace-style/shoelace/dist/components/drawer/drawer.js";
import type { DeviceDB, DeviceDBEntry } from "virtual_machine/device_db";
import { repeat } from "lit/directives/repeat.js";
import { cache } from "lit/directives/cache.js";
import { default as uFuzzy } from "@leeoniya/ufuzzy";
import { when } from "lit/directives/when.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { VMSlotAddDialog } from "./slot_add_dialog";
import "./add_device"
import { SlotModifyEvent } from "./slot";
@customElement("vm-device-list")
export class VMDeviceList extends BaseElement {
@@ -49,14 +47,20 @@ export class VMDeviceList extends BaseElement {
}
connectedCallback(): void {
const root = super.connectedCallback();
super.connectedCallback();
window.VM.get().then((vm) =>
vm.addEventListener(
"vm-devices-update",
this._handleDevicesUpdate.bind(this),
),
);
return root;
}
protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
this.renderRoot.querySelector(".device-list").addEventListener(
"device-modify-slot",
this._showDeviceSlotDialog.bind(this),
);
}
_handleDevicesUpdate(e: CustomEvent) {
@@ -92,11 +96,20 @@ export class VMDeviceList extends BaseElement {
<vm-add-device-button class="ms-auto"></vm-add-device-button>
</div>
<div class="device-list">${deviceCards}</div>
<vm-slot-add-dialog></vm-slot-add-dialog>
`;
return result;
}
@query("vm-slot-add-dialog") slotDialog: VMSlotAddDialog;
_showDeviceSlotDialog(
e: CustomEvent<SlotModifyEvent>,
) {
this.slotDialog.show(e.detail.deviceID, e.detail.slotIndex);
}
get filteredDeviceIds() {
if (typeof this._filteredDeviceIds !== "undefined") {
return this._filteredDeviceIds;
@@ -162,239 +175,3 @@ export class VMDeviceList extends BaseElement {
}
}
@customElement("vm-add-device-button")
export class VMAddDeviceButton extends BaseElement {
static styles = [
...defaultCss,
css`
.add-device-drawer {
--size: 36rem;
}
.card {
margin-top: var(--sl-spacing-small);
margin-right: var(--sl-spacing-small);
}
.card + .card {
}
`,
];
@query("sl-drawer") drawer: SlDrawer;
@query(".device-search-input") searchInput: SlInput;
private _deviceDB: DeviceDB;
private _strutures: Map<string, DeviceDBEntry> = new Map();
private _datapoints: [string, string][] = [];
private _haystack: string[] = [];
get deviceDB() {
return this._deviceDB;
}
@state()
set deviceDB(val: DeviceDB) {
this._deviceDB = val;
this._strutures = new Map(
Object.values(this.deviceDB.db)
.filter((entry) => this.deviceDB.structures.includes(entry.name), this)
.filter(
(entry) => this.deviceDB.logic_enabled.includes(entry.name),
this,
)
.map((entry) => [entry.name, entry]),
);
const datapoints: [string, string][] = [];
for (const entry of this._strutures.values()) {
datapoints.push(
[entry.title, entry.name],
[entry.name, entry.name],
[entry.desc, entry.name],
);
}
const haystack: string[] = datapoints.map((data) => data[0]);
this._datapoints = datapoints;
this._haystack = haystack;
this.performSearch();
}
private _filter: string = "";
get filter() {
return this._filter;
}
@state()
set filter(val: string) {
this._filter = val;
this.performSearch();
}
private _searchResults: {
entry: DeviceDBEntry;
haystackEntry: string;
ranges: number[];
}[];
private filterTimeout: number | undefined;
performSearch() {
if (this._filter) {
const uf = new uFuzzy({});
const [_idxs, info, order] = uf.search(
this._haystack,
this._filter,
0,
1e3,
);
const filtered = order?.map((infoIdx) => ({
name: this._datapoints[info.idx[infoIdx]][1],
haystackEntry: this._haystack[info.idx[infoIdx]],
ranges: info.ranges[infoIdx],
}));
const unique = [...new Set(filtered.map((obj) => obj.name))].map(
(result) => {
return filtered.find((obj) => obj.name === result);
},
);
this._searchResults = unique.map(({ name, haystackEntry, ranges }) => ({
entry: this._strutures.get(name)!,
haystackEntry,
ranges,
}));
} else {
// return everything
this._searchResults = [...this._strutures.values()].map((st) => ({
entry: st,
haystackEntry: st.title,
ranges: [],
}));
}
}
connectedCallback(): void {
const root = super.connectedCallback();
window.VM.get().then((vm) =>
vm.addEventListener(
"vm-device-db-loaded",
this._handleDeviceDBLoad.bind(this),
),
);
return root;
}
_handleDeviceDBLoad(e: CustomEvent) {
this.deviceDB = e.detail;
}
renderSearchResults() {
return when(
typeof this._searchResults !== "undefined" && this._searchResults.length < 20,
() =>
repeat(
this._searchResults ?? [],
(result) => result.entry.name,
(result) =>
cache(html`
<vm-device-template
prefab_name=${result.entry.name}
class="card"
@add-device-template=${this._handleDeviceAdd}
>
</vm-device-template>
`),
),
() => html`
<div class="p-2">
<p class="p-2">
<sl-format-number
.value=${this._searchResults.length}
></sl-format-number>
results, filter more to get cards
</p>
<div class="flex flex-row flex-wrap">
${[
...this._searchResults.slice(0, 50),
{ entry: { title: "", name: "" }, haystackEntry: "...", ranges: [] },
].map((result) => {
const hay = result.haystackEntry.slice(0, 15);
const ranges = result.ranges.filter((pos) => pos < 20);
const key = result.entry.name;
return html`<div class="p-2 text-neutral-200/80 italic cursor-pointer" key=${key} @click=${this._handleHaystackClick}>
${result.entry.title} (<small class="text-sm">
${unsafeHTML(uFuzzy.highlight(hay, ranges))}
</small>)
</div>`;
})}
</div>
</div>
`,
);
}
_handleHaystackClick(e: Event) {
const div = e.currentTarget as HTMLDivElement;
const key = div.getAttribute("key");
this.filter = key;
this.searchInput.value = key;
}
_handleDeviceAdd() {
this.drawer.hide();
}
render() {
return html`
<sl-button
variant="neutral"
outline
pill
@click=${this._handleAddButtonClick}
>
Add Device
</sl-button>
<sl-drawer class="add-device-drawer" placement="bottom" no-header>
<sl-input
class="device-search-input"
autofocus
placeholder="Search For Device"
clearable
@sl-input=${this._handleSearchInput}
>
<span slot="prefix">Search Structures</span>
<sl-icon slot="suffix" name="search"></sl-icon>
</sl-input>
<div class="flex flex-row overflow-auto">${this.renderSearchResults()}</div>
<sl-button
slot="footer"
variant="primary"
@click=${() => {
this.drawer.hide();
}}
>
Close
</sl-button>
</sl-drawer>
`;
}
_handleSearchInput(e: CustomEvent) {
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
}
const that = this;
this.filterTimeout = setTimeout(() => {
that.filter = that.searchInput.value;
that.filterTimeout = undefined;
}, 200);
}
_handleAddButtonClick() {
this.drawer.show();
(this.drawer.querySelector(".device-search-input") as SlInput).select();
}
}

View File

@@ -13,11 +13,20 @@ import "@shoelace-style/shoelace/dist/components/badge/badge.js";
import "@shoelace-style/shoelace/dist/components/option/option.js";
import "@shoelace-style/shoelace/dist/components/drawer/drawer.js";
import "@shoelace-style/shoelace/dist/components/icon/icon.js";
import "@shoelace-style/shoelace/dist/components/format-number/format-number.js";
import "./template"
import "./card"
import "./device_list"
import "./add_device"
import "./slot_add_dialog"
import "./slot"
import { VmDeviceTemplate } from "./template";
import { VMDeviceCard } from "./card";
import { VMAddDeviceButton, VMDeviceList } from "./device_list";
import { VMDeviceList } from "./device_list";
import { VMAddDeviceButton } from "./add_device";
import { VMSlotAddDialog } from "./slot_add_dialog";
export { VMDeviceCard, VmDeviceTemplate, VMDeviceList, VMAddDeviceButton };
export { VMDeviceCard, VmDeviceTemplate, VMDeviceList, VMAddDeviceButton, VMSlotAddDialog };

View File

@@ -1,4 +1,4 @@
import { html, css, HTMLTemplateResult } from "lit";
import { html, css } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { BaseElement, defaultCss } from "components";
import { VMDeviceDBMixin, VMDeviceMixin } from "virtual_machine/base_device";
@@ -17,6 +17,11 @@ import SlDialog from "@shoelace-style/shoelace/dist/components/dialog/dialog.com
import { VMDeviceCard } from "./card";
import { when } from "lit/directives/when.js";
export interface SlotModifyEvent {
deviceID: number;
slotIndex: number;
}
@customElement("vm-device-slot")
export class VMDeviceSlot extends VMDeviceMixin(VMDeviceDBMixin(BaseElement)) {
@property({ type: Number }) slotIndex: number;
@@ -65,9 +70,9 @@ export class VMDeviceSlot extends VMDeviceMixin(VMDeviceDBMixin(BaseElement)) {
}
}
slotOcccupantTemplate(): { name: string, typ: SlotType} | undefined {
slotOcccupantTemplate(): { name: string; typ: SlotType } | undefined {
if (this.deviceDB) {
const entry = this.deviceDB.db[this.prefabName]
const entry = this.deviceDB.db[this.prefabName];
return entry?.slots[this.slotIndex];
} else {
return undefined;
@@ -78,20 +83,24 @@ export class VMDeviceSlot extends VMDeviceMixin(VMDeviceDBMixin(BaseElement)) {
const inputIdBase = `vmDeviceSlot${this.deviceID}Slot${this.slotIndex}Head`;
const slot = this.slots[this.slotIndex];
const slotImg = this.slotOccupantImg();
const img = html`<img class="w-10 h-10" src="${slotImg}" onerror="this.src = '${VMDeviceCard.transparentImg}'" />`;
const img = html`<img
class="w-10 h-10"
src="${slotImg}"
onerror="this.src = '${VMDeviceCard.transparentImg}'"
/>`;
const template = this.slotOcccupantTemplate();
return html`
<div class="flex flex-row me-2">
<div
class="relative shrink-0 border border-neutral-200/40 rounded-lg p-1
hover:ring-2 hover:ring-purple-500 hover:ring-offset-1
hover:ring-offset-purple-500 cursor-pointer me-2"
hover:ring-2 hover:ring-purple-500 hover:ring-offset-1
hover:ring-offset-purple-500 cursor-pointer me-2"
@click=${this._handleSlotClick}
>
<div
class="absolute top-0 left-0 ml-1 mt-1 text-xs
text-neutral-200/90 font-mono bg-neutral-500/40 rounded pl-1 pr-1"
class="absolute top-0 left-0 ml-1 mt-1 text-xs
text-neutral-200/90 font-mono bg-neutral-500/40 rounded pl-1 pr-1"
>
<small>${this.slotIndex}</small>
</div>
@@ -100,33 +109,32 @@ export class VMDeviceSlot extends VMDeviceMixin(VMDeviceDBMixin(BaseElement)) {
</sl-tooltip>
${when(
typeof slot.occupant !== "undefined",
() => html`<div
class="absolute bottom-0 right-0 mr-1 mb-1 text-xs
text-neutral-200/90 font-mono bg-neutral-500/40 rounded pl-1 pr-1"
>
<small>${slot.occupant.quantity}/${slot.occupant.max_quantity}</small>
</div>`
() =>
html`<div
class="absolute bottom-0 right-0 mr-1 mb-1 text-xs
text-neutral-200/90 font-mono bg-neutral-500/40 rounded pl-1 pr-1"
>
<small
>${slot.occupant.quantity}/${slot.occupant
.max_quantity}</small
>
</div>`,
)}
<div></div>
</div>
<div class="flex flex-col justify-end">
<div class="text-sm mt-auto mb-auto">
${when(
typeof slot.occupant !== "undefined",
() => html`
<span>
${this.slotOccupantPrefabName()}
</span>
`,
() => html`
<span>
${template?.name}
</span>
`,
)}
${when(
typeof slot.occupant !== "undefined",
() => html` <span> ${this.slotOccupantPrefabName()} </span> `,
() => html` <span> ${template?.name} </span> `,
)}
</div>
<div class="text-neutral-400 text-xs mt-auto flex flex-col mb-1">
<div><strong class="mt-auto mb-auto">Type:</strong><span class="p-1">${slot.typ}</span></div>
<div>
<strong class="mt-auto mb-auto">Type:</strong
><span class="p-1">${slot.typ}</span>
</div>
</div>
</div>
${when(
@@ -146,15 +154,20 @@ export class VMDeviceSlot extends VMDeviceMixin(VMDeviceDBMixin(BaseElement)) {
</sl-input>
</div>
`,
() => html`
`,
() => html``,
)}
</div>
`;
}
_handleSlotClick(e: Event) {
console.log(e, e.currentTarget, e.target);
_handleSlotClick(_e: Event) {
this.dispatchEvent(
new CustomEvent<SlotModifyEvent>("device-modify-slot", {
bubbles: true,
composed: true,
detail: { deviceID: this.deviceID, slotIndex: this.slotIndex },
}),
);
}
renderFields() {
@@ -165,19 +178,22 @@ export class VMDeviceSlot extends VMDeviceMixin(VMDeviceDBMixin(BaseElement)) {
return html`
<div class="slot-fields">
${fields.map(
([name, field], _index, _fields) => html`
<sl-input
id="${inputIdBase}${name}"
key="${name}"
value="${displayNumber(field.value)}"
size="small"
@sl-change=${this._handleChangeSlotField}
>
<span slot="prefix">${name}</span>
<sl-copy-button slot="suffix" from="${inputIdBase}${name}.value"></sl-copy-button>
<span slot="suffix">${field.field_type}</span>
</sl-input>
`,
([name, field], _index, _fields) => html`
<sl-input
id="${inputIdBase}${name}"
key="${name}"
value="${displayNumber(field.value)}"
size="small"
@sl-change=${this._handleChangeSlotField}
>
<span slot="prefix">${name}</span>
<sl-copy-button
slot="suffix"
from="${inputIdBase}${name}.value"
></sl-copy-button>
<span slot="suffix">${field.field_type}</span>
</sl-input>
`,
)}
</div>
`;
@@ -188,8 +204,12 @@ export class VMDeviceSlot extends VMDeviceMixin(VMDeviceDBMixin(BaseElement)) {
const field = input.getAttribute("key")! as SlotLogicType;
const val = parseNumber(input.value);
window.VM.get().then((vm) => {
if (!vm.setDeviceSlotField(this.deviceID, this.slotIndex, field, val, true)) {
input.value = this.device.getSlotField(this.slotIndex, field).toString();
if (
!vm.setDeviceSlotField(this.deviceID, this.slotIndex, field, val, true)
) {
input.value = this.device
.getSlotField(this.slotIndex, field)
.toString();
}
this.updateDevice();
});
@@ -198,10 +218,10 @@ export class VMDeviceSlot extends VMDeviceMixin(VMDeviceDBMixin(BaseElement)) {
render() {
return html`
<ic10-details class="slot-card">
<div class="slot-header w-full" slot="summary">${this.renderHeader()}</div>
<div class="slot-body">
${this.renderFields()}
<div class="slot-header w-full" slot="summary">
${this.renderHeader()}
</div>
<div class="slot-body">${this.renderFields()}</div>
</ic10-details>
`;
}

View File

@@ -55,7 +55,7 @@ export class VmDeviceTemplate extends VMDeviceDBMixin(BaseElement) {
padding: var(--sl-spacing-small) var(--sl-spacing-medium);
}
sl-tab-group::part(base) {
height: 14rem;
height: 18rem;
overflow-y: auto;
}
`,
@@ -223,7 +223,7 @@ export class VmDeviceTemplate extends VMDeviceDBMixin(BaseElement) {
const device = this.dbDevice;
return html`
<sl-card class="template-card">
<div class="header" slot="header">
<div class="header h-20 w-96" slot="header">
<sl-tooltip content="${device?.name}">
<img
class="image me-2"

View File

@@ -4,11 +4,13 @@ import {
FrozenVM,
LogicType,
SlotLogicType,
SlotOccupantTemplate,
VMRef,
init,
} from "ic10emu_wasm";
import { DeviceDB } from "./device_db";
import "./base_device";
import "./device";
import { App } from "app";
export interface ToastMessage {
variant: "warning" | "danger" | "success" | "primary" | "neutral";
@@ -394,6 +396,21 @@ class VirtualMachine extends EventTarget {
}
}
setDeviceSlotOccupant(id: number, index: number, template: SlotOccupantTemplate): boolean {
const device = this._devices.get(id);
if (typeof device !== "undefined") {
try {
console.log("setting slot occupant", template);
this.ic10vm.setSlotOccupant(id, index, template);
this.updateDevice(device);
return true;
} catch (err) {
this.handleVmError(err);
}
}
return false;
}
saveVMState(): FrozenVM {
return this.ic10vm.saveVMState();
}