Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ tokio-tungstenite = "0.28"
tiny_http = "0.12"
elgato-streamdeck = { version = "0.12", default-features = false, features = ["async"] }
hidapi = "2.6"
image = { version = "0.25", default-features = false, features = ["bmp", "jpeg"] }
image = { version = "0.25", default-features = false, features = ["bmp", "jpeg", "gif", "webp"] }
# Smaller utility libraries
dashmap = { version = "6.1", features = ["serde"] }
futures = "0.3"
Expand Down
35 changes: 34 additions & 1 deletion src-tauri/src/events/frontend/instances.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ use super::Error;
use crate::shared::{Action, ActionContext, ActionInstance, ActionState, Context, config_dir};
use crate::store::profiles::{LocksMut, acquire_locks, acquire_locks_mut, get_instance_mut, get_slot, get_slot_mut, save_profile};

use std::collections::HashMap;
use std::sync::LazyLock;

use tauri::{AppHandle, Emitter, Manager, command};
use tokio::fs::remove_dir_all;
use tokio::sync::RwLock;

static LAST_RENDER_SEQUENCES: LazyLock<RwLock<HashMap<String, u64>>> = LazyLock::new(|| RwLock::new(HashMap::new()));

fn context_sequence_key(context: &Context) -> String {
format!("{}.{}.{}.{}", context.device, context.profile, context.controller, context.position)
}

#[command]
pub async fn create_instance(app: AppHandle, action: Action, context: Context) -> Result<Option<ActionInstance>, Error> {
Expand Down Expand Up @@ -221,11 +231,34 @@ pub async fn set_state(context: ActionContext, index: u16, state: ActionState) -
}

#[command]
pub async fn update_image(context: Context, image: Option<String>) {
pub async fn update_image(context: Context, image: Option<String>, render_sequence: Option<u64>) {
if Some(&context.profile) != crate::store::profiles::DEVICE_STORES.write().await.get_selected_profile(&context.device).ok().as_ref() {
return;
}

// Ignore image writes for slots that no longer have an instance (e.g. after move/remove).
if image.is_some() {
let locks = acquire_locks().await;
let Ok(slot) = get_slot(&context, &locks).await else { return; };
if slot.is_none() {
return;
}
}

if let Some(sequence) = render_sequence {
let mut sequences = LAST_RENDER_SEQUENCES.write().await;
let key = context_sequence_key(&context);
if let Some(previous) = sequences.get(&key)
&& sequence < *previous {
return;
}
sequences.insert(key, sequence);
} else if image.is_none() {
// Clearing a slot invalidates any prior sequencing state for this context.
let mut sequences = LAST_RENDER_SEQUENCES.write().await;
sequences.remove(&context_sequence_key(&context));
}

if let Err(error) = crate::events::outbound::devices::update_image(context, image).await {
log::warn!("Failed to update device image: {}", error);
}
Expand Down
16 changes: 8 additions & 8 deletions src/components/InstanceEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@
title="Click to select an image, or right-click to reset to the default image."
aria-label="Click to select an image, or right-click to reset to the default image."
>
{#await renderImage(null, null, instance.states[state], instance.action.states[state]?.image ?? instance.action.icon, false, false, true, false, false, 0, true)}
<div class="w-32 min-w-32 h-32 bg-neutral-800 animate-pulse border border-neutral-600 rounded-xl"></div>
{:then resolvedSrc}
<img
src={resolvedSrc}
class="my-auto w-32 min-w-32 h-min aspect-square bg-black border border-neutral-600 rounded-xl cursor-pointer"
alt="State {state + 1} image"
/>
{#await renderImage(null, null, instance.states[state], instance.action.states[state]?.image ?? instance.action.icon, false, false, true, false, false, 0, true) then resolvedSrc}
{#if typeof resolvedSrc === "string"}
<img
src={resolvedSrc}
class="my-auto w-32 min-w-32 h-min aspect-square bg-black border border-neutral-600 rounded-xl cursor-pointer"
alt="State {state + 1} image"
/>
{/if}
{/await}
</button>
<div class="flex flex-row items-center justify-center mt-1 space-x-1 text-neutral-300">
Expand Down
57 changes: 35 additions & 22 deletions src/components/Key.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
import InstanceEditor from "./InstanceEditor.svelte";

import { copiedItem, inspectedInstance, inspectedParentAction, openContextMenu } from "$lib/propertyInspector";
import { CanvasLock, renderImage } from "$lib/rendererHelper";
import { CanvasLock, invalidateRenderContext, renderImage } from "$lib/rendererHelper";
import { settings } from "$lib/settings";

import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { tick } from "svelte";
import { onDestroy, tick } from "svelte";

export let context: Context | null;
export let label: string = "";
Expand Down Expand Up @@ -145,37 +145,50 @@

let canvas: HTMLCanvasElement;
let lock = new CanvasLock();
let animationCleanup: (() => void) | undefined;
let renderVersion = 0;
export let size = 144;

function stopAnimation() {
if (animationCleanup) {
animationCleanup();
animationCleanup = undefined;
}
}

onDestroy(stopAnimation);

$: (async () => {
const version = ++renderVersion;
const sl = structuredClone(slot);
if (!sl) {
const unlock = await lock.lock();
try {
// Stop previous loop immediately so moved animated keys cannot keep pushing old frames.
stopAnimation();
const unlock = await lock.lock();
try {
if (version !== renderVersion) return;
if (!sl) {
invalidateRenderContext(context);
const ctx = canvas?.getContext("2d");
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
if (active) await invoke("update_image", { context, image: null });
} finally {
unlock();
}
} else {
const unlock = await lock.lock();
try {
} else {
let fallback = sl.action.states[sl.current_state]?.image ?? sl.action.icon;
if (state) await renderImage(canvas, context, state, fallback, showOk, showAlert, true, active, pressed, $settings?.rotation);
} finally {
unlock();
if (state) {
const result = await renderImage(canvas, context, state, fallback, showOk, showAlert, true, active, pressed, $settings?.rotation);
if (version !== renderVersion) {
if (typeof result === "function") result();
return;
}
if (typeof result === "function") {
animationCleanup = result;
}
}
}
} finally {
unlock();
}
})();

function clearAndRedraw() {
canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
slot = slot;
}
$: if ($settings?.rotation != undefined) {
clearAndRedraw();
}

async function triggerVirtualPress() {
if (!active || !context || !slot) return;
await invoke("trigger_virtual_press", { context });
Expand Down
Loading