Implement UI for managing Multi Actions

This commit is contained in:
ninjadev64 committed 2024-04-07 21:48:27 +01:00
1 parent c623ababf9
commit 71049a4804
17 files changed
+282 -147

No files matched your search

+18
View File
@@ -167,6 +167,24 @@ pub async fn clear_slot(context: Context) -> Result<(), Error> {
Ok(())
}
#[tauri::command]
pub async fn remove_instance(context: ActionContext) -> Result<(), Error> {
let mut locks = lock_mutexes().await;
let slot = get_slot(&(&context).into(), &mut locks).await?;
for (index, instance) in slot.iter().enumerate() {
if instance.context == context {
let _ = crate::events::outbound::will_appear::will_disappear(instance, slot.len() > 1).await;
slot.remove(index);
break;
}
}
save_profile(&context.device, &mut locks).await?;
Ok(())
}
#[tauri::command]
pub async fn make_info(app: tauri::AppHandle, plugin: String) -> Result<crate::plugins::info_param::Info, Error> {
let mut path = app.path_resolver().app_config_dir().unwrap();
+2 -2
View File
@@ -44,7 +44,7 @@ pub async fn dial_rotate(device: &str, index: u8, ticks: i16) -> Result<(), anyh
let context = ActionContext {
device: device.to_owned(),
profile,
controller: "Keypad".to_owned(),
controller: "Encoder".to_owned(),
position: index,
index: 0,
};
@@ -77,7 +77,7 @@ pub async fn dial_press(device: &str, event: &'static str, index: u8) -> Result<
let context = ActionContext {
device: device.to_owned(),
profile,
controller: "Keypad".to_owned(),
controller: "Encoder".to_owned(),
position: index,
index: 0,
};
+4
View File
@@ -56,6 +56,7 @@ pub async fn key_down(device: &str, key: u8) -> Result<(), anyhow::Error> {
tokio::time::sleep(Duration::from_millis(100)).await;
instance.current_state = (instance.current_state + 1) % (instance.states.len() as u16);
send_to_plugin(
&instance.action.plugin,
&KeyEvent {
@@ -70,6 +71,9 @@ pub async fn key_down(device: &str, key: u8) -> Result<(), anyhow::Error> {
tokio::time::sleep(Duration::from_millis(100)).await;
}
save_profile(device, &mut locks).await?;
let _ = crate::events::frontend::update_state(crate::APP_HANDLE.get().unwrap(), context, &mut locks).await;
}
Ok(())
+1
View File
@@ -32,6 +32,7 @@ async fn main() {
frontend::create_instance,
frontend::move_slot,
frontend::clear_slot,
frontend::remove_instance,
frontend::get_profiles,
frontend::get_selected_profile,
frontend::set_selected_profile,
+2 -2
View File
@@ -94,7 +94,7 @@ pub struct Action {
}
/// Location metadata of a slot.
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Context {
pub device: String,
pub profile: String,
@@ -103,7 +103,7 @@ pub struct Context {
}
/// Information about the slot and index an instance is located in.
#[derive(Clone, serde_with::SerializeDisplay, serde_with::DeserializeFromStr)]
#[derive(Clone, PartialEq, Eq, serde_with::SerializeDisplay, serde_with::DeserializeFromStr)]
pub struct ActionContext {
pub device: String,
pub profile: String,
+5 -4
View File
@@ -147,11 +147,12 @@ pub async fn get_slot<'a>(context: &crate::shared::Context, locks: &'a mut Locks
pub async fn get_instance<'a>(context: &crate::shared::ActionContext, locks: &'a mut Locks<'_>) -> Result<Option<&'a mut crate::shared::ActionInstance>, anyhow::Error> {
let slot = get_slot(&(context.into()), locks).await?;
if (context.index as usize) < slot.len() {
Ok(Some(&mut slot[context.index as usize]))
} else {
Ok(None)
for instance in slot {
if instance.context == *context {
return Ok(Some(instance));
}
}
Ok(None)
}
pub async fn save_profile<'a>(device: &str, locks: &'a mut Locks<'_>) -> Result<(), anyhow::Error> {
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { DeviceInfo } from "$lib/DeviceInfo";
import type { Profile } from "$lib/Profile";
import type { Profile } from "$lib/Profile";
import { invoke } from "@tauri-apps/api";
import { listen } from "@tauri-apps/api/event";
-113
View File
@@ -3,8 +3,6 @@
import type { DeviceInfo } from "$lib/DeviceInfo";
import type { Profile } from "$lib/Profile";
import { inspectedInstance } from "$lib/propertyInspector";
import Key from "./Key.svelte";
import Slider from "./Slider.svelte";
@@ -43,92 +41,6 @@
dataTransfer?.setData("controller", controller);
dataTransfer?.setData("position", position.toString());
}
let iframes: { [context: string]: HTMLIFrameElement } = {};
let iframeContainer: HTMLDivElement;
let iframeClosePopup: HTMLButtonElement;
let iframePopupsOpen: string[] = [];
async function iframeOnLoad(instance: ActionInstance) {
const iframe = iframes[instance.context];
const split = instance.context.split(".");
let coordinates: { row: number, column: number };
if (split[2] == "Encoder") {
coordinates = { row: 0, column: parseInt(split[3]) };
} else {
coordinates = { row: Math.floor(parseInt(split[3]) / device.rows), column: parseInt(split[3]) % device.columns };
}
if (instance == null || !iframe.src || !iframe.src.startsWith("http://localhost:57118")) return;
const info = JSON.stringify(await invoke("make_info", { plugin: instance.action.plugin }));
iframe?.contentWindow?.postMessage({
event: "connect",
payload: [
57116,
instance.context,
"registerPropertyInspector",
info,
JSON.stringify({
action: instance.action.uuid,
context: instance.context,
device: split[0],
payload: {
settings: instance.settings,
coordinates
}
})
]
}, "http://localhost:57118");
}
const closePopup = (context: string) => {
const iframe = iframes[context];
iframe.style.position = "";
iframe.style.left = "";
iframe.style.top = "";
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.display = $inspectedInstance == context ? "block" : "none";
iframe.contentWindow?.postMessage({ event: "windowClosed" }, "http://localhost:57118");
iframePopupsOpen = iframePopupsOpen.filter((e) => e != context);
if (iframePopupsOpen.length == 0) {
iframeContainer.style.position = "";
iframeContainer.style.width = "";
iframeContainer.style.height = "";
iframeContainer.style.padding = "";
iframeClosePopup.style.display = "none";
}
}
window.addEventListener("message", ({ data }) => {
if (data.event == "windowOpened") {
const iframe = iframes[data.payload];
iframe.style.position = "absolute";
iframe.style.left = "36px";
iframe.style.top = "36px";
iframe.style.width = "calc(100% - 72px)";
iframe.style.height = "calc(100% - 72px)";
iframe.style.display = "block";
iframePopupsOpen.push(data.payload);
iframeContainer.style.position = "absolute";
iframeContainer.style.width = "100%";
iframeContainer.style.height = "100%";
iframeContainer.style.padding = "36px";
iframeClosePopup.style.display = "block";
} else if (data.event == "windowClosed") {
closePopup(data.payload);
}
});
$: nonEmptySlots = profile.keys.filter((v) => v.length > 0).concat(profile.sliders.filter((v) => v.length > 0));
</script>
<div class="flex flex-row">
@@ -158,28 +70,3 @@
{/each}
</div>
</div>
<div class="grow overflow-scroll bg-white border-t" bind:this={iframeContainer}>
<button
bind:this={iframeClosePopup}
on:click={() => closePopup(iframePopupsOpen[iframePopupsOpen.length - 1])}
class="absolute top-2 right-2 text-2xl font-bold hidden"
>
✕
</button>
{#each nonEmptySlots as slot (slot[0].context)}
{#each slot as instance}
{#if instance.action.property_inspector}
<iframe
title="Property inspector"
class="w-full h-full hidden"
class:!block={$inspectedInstance == instance.context}
src={"http://localhost:57118" + instance.action.property_inspector + "|opendeck_property_inspector"}
name={instance.context}
bind:this={iframes[instance.context]}
on:load={() => iframeOnLoad(instance)}
/>
{/if}
{/each}
{/each}
</div>
+18 -11
View File
@@ -3,7 +3,7 @@
import type { ActionState } from "$lib/ActionState";
import type { Context } from "$lib/Context";
import { inspectedInstance } from "$lib/propertyInspector";
import { inspectedInstance, inspectedMultiAction } from "$lib/propertyInspector";
import { getImage, renderImage } from "$lib/rendererHelper";
import { invoke } from "@tauri-apps/api";
@@ -12,6 +12,9 @@
export let context: Context;
export let slot: ActionInstance[];
export let active: boolean = true;
export let scale: number = 1;
let state: ActionState | undefined;
$: {
if (!slot.length) {
@@ -29,13 +32,15 @@
}
listen("update_state", ({ payload }: { payload: { context: Context, contents: ActionInstance[] }}) => {
if (JSON.stringify(payload.context) == JSON.stringify(context)) slot = payload.contents;
if (JSON.stringify(payload.context) == JSON.stringify(context) || payload.contents[0]?.context == slot[0]?.context) {
slot = payload.contents;
}
});
function select() {
if (!slot || slot.length == 0) return;
if (slot.length > 1) {
// TODO
inspectedMultiAction.set(context);
} else {
inspectedInstance.set(slot[0].context);
}
@@ -43,9 +48,10 @@
async function clear(event: MouseEvent) {
event.preventDefault();
if (!active) return;
if (event.ctrlKey) return;
await invoke("clear_slot", { context });
slot.forEach((instance) => { if ($inspectedInstance == instance.context) inspectedInstance.set(null); });
if (slot.map((instance) => instance.context).includes($inspectedInstance!)) inspectedInstance.set(null);
slot = [];
}
@@ -73,18 +79,19 @@
$: {
if (slot.length > 1) {
image = state?.image!;
renderImage(context, state!, null!, false, false, false);
if (active) renderImage(context, state!, null!, false, false, false);
} else if (slot.length) {
let instance = slot[0];
let fallback = instance.action.states[instance.current_state].image ?? instance.action.icon;
image = getImage(state?.image, fallback);
if (state) renderImage(context, state, fallback, showOk > 0, showAlert > 0);
if (active && state) renderImage(context, state, fallback, showOk > 0, showAlert > 0);
}
}
</script>
<div
class="relative m-2 w-32 h-32 border-2 rounded-md select-none"
class="relative m-2 border-2 rounded-md select-none"
style="width: calc(8rem * {scale}); height: calc(8rem * {scale});"
on:dragover on:drop
draggable on:dragstart
role="cell" tabindex="-1"
@@ -101,10 +108,10 @@
{#if state.show}
<div class="absolute flex justify-center w-full h-full top-0 left-0 pointer-events-none">
<span
style={`
font-size: ${state.size}px;
color: ${state.colour};
`}
style="
font-size: calc({state.size}px * {scale});
color: {state.colour};
"
class:self-start={state.alignment == "top"}
class:self-center={state.alignment == "middle"}
class:self-end={state.alignment == "bottom"}
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import type { ActionInstance } from "$lib/ActionInstance";
import type { Context } from "$lib/Context";
import type { Profile } from "$lib/Profile";
import { inspectedMultiAction } from "$lib/propertyInspector";
import { invoke } from "@tauri-apps/api";
import Key from "./Key.svelte";
export let profile: Profile;
let slot: ActionInstance[];
$: {
if ($inspectedMultiAction?.controller == "Encoder") {
slot = profile.sliders[$inspectedMultiAction.position];
} else {
slot = profile.keys[$inspectedMultiAction!.position];
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
return true;
}
async function handleDrop({ dataTransfer }: DragEvent) {
if (dataTransfer?.getData("action")) {
let response: ActionInstance[] = await invoke("create_instance", { context: $inspectedMultiAction, action: JSON.parse(dataTransfer?.getData("action")) });
if (response) slot = response;
}
}
async function removeInstance(index: number) {
await invoke("remove_instance", { context: slot[index].context });
let temp = [...slot];
temp.splice(index, 1);
slot = temp;
}
let context: Context;
context = null!;
</script>
<div class="px-6 pt-6 pb-4">
<button class="float-right text-xl" on:click={() => $inspectedMultiAction = null}> ✕ </button>
<h1 class="font-semibold text-2xl"> Multi Action </h1>
</div>
<div class="flex flex-col h-80 overflow-scroll">
{#each slot as instance, index}
<div class="flex flex-row items-center mx-4 my-1 bg-gray-100 rounded-md">
<Key slot={[instance]} {context} active={false} scale={3/4} />
<p class="ml-4 text-xl"> {instance.action.name} </p>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<img
src="/rubbish.png"
class="ml-auto mr-10 w-8 cursor-pointer"
alt="Remove action"
on:click={() => removeInstance(index)} on:keyup={() => removeInstance(index)}
/>
</div>
{/each}
<div
class="flex flex-row items-center mx-4 mt-1 mb-4 bg-gray-100 border-2 border-dashed rounded-md"
on:dragover={handleDragOver} on:drop={handleDrop}
role="cell" tabindex="-1"
>
<img src="/cube.png" class="m-2 w-24 rounded-xl" alt="Add new action" />
<p class="ml-4 text-xl text-gray-500"> Drop actions here </p>
</div>
</div>
+14 -5
View File
@@ -23,6 +23,12 @@
if (!profiles.includes(id)) profiles = [ ...profiles, id ];
}
async function deleteProfile(id: string) {
await invoke("delete_profile", { device: device.id, profile: id });
profiles.splice(profiles.indexOf(id), 1);
profiles = profiles;
}
let oldValue: string;
let value: string;
$: {
@@ -69,11 +75,14 @@
<input type="radio" bind:group={value} value={profile} />
{profile}
{#if profile != value}
<button class="float-right" on:click={() => {
invoke("delete_profile", { device: device.id, profile });
profiles.splice(i, 1);
profiles = profiles;
}}> ✕ </button>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<img
src="/rubbish.png"
class="float-right w-6 cursor-pointer"
alt="Remove profile"
on:click={() => deleteProfile(profile)}
on:keyup={() => deleteProfile(profile)}
/>
{/if}
</div>
{/each}
+120
View File
@@ -0,0 +1,120 @@
<script lang="ts">
import type { ActionInstance } from "$lib/ActionInstance";
import type { DeviceInfo } from "$lib/DeviceInfo";
import type { Profile } from "$lib/Profile";
import { inspectedInstance } from "$lib/propertyInspector";
import { invoke } from "@tauri-apps/api";
let iframes: { [context: string]: HTMLIFrameElement } = {};
let iframeContainer: HTMLDivElement;
let iframeClosePopup: HTMLButtonElement;
let iframePopupsOpen: string[] = [];
export let device: DeviceInfo;
export let profile: Profile;
async function iframeOnLoad(instance: ActionInstance) {
const iframe = iframes[instance.context];
const split = instance.context.split(".");
let coordinates: { row: number, column: number };
if (split[2] == "Encoder") {
coordinates = { row: 0, column: parseInt(split[3]) };
} else {
coordinates = { row: Math.floor(parseInt(split[3]) / device.rows), column: parseInt(split[3]) % device.columns };
}
if (instance == null || !iframe.src || !iframe.src.startsWith("http://localhost:57118")) return;
const info = JSON.stringify(await invoke("make_info", { plugin: instance.action.plugin }));
iframe?.contentWindow?.postMessage({
event: "connect",
payload: [
57116,
instance.context,
"registerPropertyInspector",
info,
JSON.stringify({
action: instance.action.uuid,
context: instance.context,
device: split[0],
payload: {
settings: instance.settings,
coordinates
}
})
]
}, "http://localhost:57118");
}
const closePopup = (context: string) => {
const iframe = iframes[context];
iframe.style.position = "";
iframe.style.left = "";
iframe.style.top = "";
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.display = $inspectedInstance == context ? "block" : "none";
iframe.contentWindow?.postMessage({ event: "windowClosed" }, "http://localhost:57118");
iframePopupsOpen = iframePopupsOpen.filter((e) => e != context);
if (iframePopupsOpen.length == 0) {
iframeContainer.style.position = "";
iframeContainer.style.width = "";
iframeContainer.style.height = "";
iframeContainer.style.padding = "";
iframeClosePopup.style.display = "none";
}
}
window.addEventListener("message", ({ data }) => {
if (data.event == "windowOpened") {
const iframe = iframes[data.payload];
iframe.style.position = "absolute";
iframe.style.left = "36px";
iframe.style.top = "36px";
iframe.style.width = "calc(100% - 72px)";
iframe.style.height = "calc(100% - 72px)";
iframe.style.display = "block";
iframePopupsOpen.push(data.payload);
iframeContainer.style.position = "absolute";
iframeContainer.style.width = "100%";
iframeContainer.style.height = "100%";
iframeContainer.style.padding = "36px";
iframeClosePopup.style.display = "block";
} else if (data.event == "windowClosed") {
closePopup(data.payload);
}
});
$: instances = profile.keys.reduce((prev, current) => prev.concat(current), []).concat(profile.sliders.reduce((prev, current) => prev.concat(current), []));
</script>
<div class="grow overflow-scroll bg-white border-t" bind:this={iframeContainer}>
<button
bind:this={iframeClosePopup}
on:click={() => closePopup(iframePopupsOpen[iframePopupsOpen.length - 1])}
class="absolute top-2 right-2 text-2xl font-bold hidden"
>
✕
</button>
{#each instances as instance}
{#if instance.action.property_inspector}
<iframe
title="Property inspector"
class="w-full h-full hidden"
class:!block={$inspectedInstance == instance.context}
src={"http://localhost:57118" + instance.action.property_inspector + "|opendeck_property_inspector"}
name={instance.context}
bind:this={iframes[instance.context]}
on:load={() => iframeOnLoad(instance)}
/>
{/if}
{/each}
</div>
+5 -5
View File
@@ -25,7 +25,7 @@
event.preventDefault();
if (event.ctrlKey) return;
await invoke("clear_slot", { context });
slot.forEach((instance) => { if ($inspectedInstance == instance.context) inspectedInstance.set(null); });
if ($inspectedInstance == slot[0]?.context) inspectedInstance.set(null);
slot = [];
}
@@ -75,11 +75,11 @@
{#if state.show}
<div class="absolute flex justify-center w-full aspect-square top-[50%] -translate-y-1/2 left-0 pointer-events-none">
<span
style={`
font-size: ${state.size}px;
color: ${state.colour};
style="
font-size: {state.size}px;
color: {state.colour};
scale: 0.5;
`}
"
class:self-start={state.alignment == "top"}
class:self-center={state.alignment == "middle"}
class:self-end={state.alignment == "bottom"}
+4
View File
@@ -1,3 +1,5 @@
import type { Context } from "./Context";
import { writable, type Writable } from "svelte/store";
export const inspectedInstance: Writable<string | null> = writable(null);
@@ -8,3 +10,5 @@ inspectedInstance.subscribe(async (value) => {
await invoke("switch_property_inspector", { old, new: value });
old = value;
});
export const inspectedMultiAction: Writable<Context | null> = writable(null);
+16 -4
View File
@@ -2,10 +2,14 @@
import type { DeviceInfo } from "$lib/DeviceInfo";
import type { Profile } from "$lib/Profile";
import { inspectedMultiAction } from "$lib/propertyInspector";
import ActionList from "../components/ActionList.svelte";
import DeviceSelector from "../components/DeviceSelector.svelte";
import DeviceView from "../components/DeviceView.svelte";
import MultiActionView from "../components/MultiActionView.svelte";
import ProfileSelector from "../components/ProfileSelector.svelte";
import PropertyInspectorView from "../components/PropertyInspectorView.svelte";
let selectedDevice: DeviceInfo;
$: _selectedDevice = selectedDevice;
@@ -15,15 +19,23 @@
</script>
<div class="flex flex-col grow">
{#if selectedDevice && selectedProfile}
{#if $inspectedMultiAction}
<MultiActionView bind:profile={_selectedProfile} />
{:else if selectedDevice && selectedProfile}
<DeviceView bind:device={_selectedDevice} bind:profile={_selectedProfile} />
{/if}
{#if selectedProfile}
<PropertyInspectorView bind:device={_selectedDevice} bind:profile={_selectedProfile} />
{/if}
</div>
<div class="flex flex-col p-2 grow max-w-[18rem] h-full border-l">
<DeviceSelector bind:device={selectedDevice} />
{#if selectedDevice}
<ProfileSelector bind:device={_selectedDevice} bind:profile={selectedProfile} />
{#if !$inspectedMultiAction}
<DeviceSelector bind:device={selectedDevice} />
{#if selectedDevice}
<ProfileSelector bind:device={_selectedDevice} bind:profile={selectedProfile} />
{/if}
{/if}
<ActionList />
</div>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB