Packages

Native desktop GUI framework for Gleam, powered by Iced

Current section

Files

Jump to
plushie_gleam src plushie_runtime_web_ffi.mjs
Raw

src/plushie_runtime_web_ffi.mjs

// JavaScript runtime FFI: mutable state container and async primitives.
//
// The WebRuntimeHandle is a plain JS object holding all mutable state.
// Gleam code passes it around opaquely; only the functions in this
// module read/write its fields.
import { Ok, Error, Some, None } from "./gleam.mjs";
// -- Handle (mutable state container) ----------------------------------------
export function createHandle(
model,
app,
transport,
session,
emptySubs,
emptyWindows,
) {
return {
model,
app,
transport,
session,
tree: new None(), // Option(Node), Gleam None
gleamActiveSubs: emptySubs, // Gleam Dict(String, Subscription)
gleamWindows: emptyWindows, // Gleam Set(String)
cwRegistry: null, // Gleam Dict, canvas widget registry
memoCache: null, // Gleam MemoCache (Dict), set from Gleam side
asyncTasks: new Map(), // tag -> { nonce, cancel }
asyncTaskTokens: new Map(), // tag -> latest nonce
nextNonce: 0,
timerSubs: new Map(), // key -> JS intervalId
sendAfterTimers: new Map(), // key -> { id, token }
sendAfterTokens: new Map(), // key -> latest token
nextTimerToken: 0,
pendingEffects: new Map(), // requestId -> { tag, kind, timeoutId }
pendingEffectTags: new Map(), // tag -> requestId
pendingCoalesce: new Map(), // key -> Gleam Event
coalescePending: false,
dispatchQueue: [],
dispatchDraining: false,
stopped: false,
// Gleam callbacks, registered after handle creation
dispatch: null, // fn(Event) -> Nil, goes through handle_event
dispatchDirect: null, // fn(Event) -> Nil, goes straight to dispatch_update
onTimerFired: null, // fn(String) -> Nil
onAsyncComplete: null, // fn(String, Result(Dynamic, Dynamic)) -> Nil
onStreamEmit: null, // fn(String, Dynamic) -> Nil
};
}
export function enqueueDispatch(handle, callback) {
if (handle.stopped) return;
handle.dispatchQueue.push({ callback, ready: true });
drainDispatch(handle);
}
export function deferDispatch(handle, callback) {
if (handle.stopped) return;
const entry = { callback, ready: false };
handle.dispatchQueue.push(entry);
drainDispatch(handle);
queueMicrotask(() => {
if (handle.stopped) return;
entry.ready = true;
drainDispatch(handle);
});
}
function drainDispatch(handle) {
if (handle.stopped) {
handle.dispatchQueue.length = 0;
return;
}
if (handle.dispatchDraining) return;
handle.dispatchDraining = true;
try {
while (!handle.stopped && handle.dispatchQueue.length > 0) {
const next = handle.dispatchQueue[0];
if (!next.ready) return;
handle.dispatchQueue.shift();
next.callback();
}
} finally {
handle.dispatchDraining = false;
if (handle.stopped) {
handle.dispatchQueue.length = 0;
}
}
}
// -- Callback registration ---------------------------------------------------
// Called once after createHandle, before any events are processed.
export function registerDispatch(handle, dispatch, dispatchDirect) {
handle.dispatch = dispatch;
handle.dispatchDirect = dispatchDirect;
}
export function registerTimerCallback(handle, callback) {
handle.onTimerFired = callback;
}
export function registerAsyncCallback(handle, callback) {
handle.onAsyncComplete = callback;
}
export function registerStreamCallback(handle, callback) {
handle.onStreamEmit = callback;
}
// -- Model access ------------------------------------------------------------
export function getModel(handle) {
return handle.model;
}
export function setModel(handle, model) {
handle.model = model;
}
// -- Tree access -------------------------------------------------------------
export function getTree(handle) {
return handle.tree;
}
export function setTree(handle, tree) {
handle.tree = tree;
}
// -- App and session access --------------------------------------------------
export function getApp(handle) {
return handle.app;
}
export function getSession(handle) {
return handle.session;
}
export function getTransport(handle) {
return handle.transport;
}
// -- Canvas widget registry --------------------------------------------------
export function getCwRegistry(handle) {
return handle.cwRegistry;
}
export function setCwRegistry(handle, registry) {
handle.cwRegistry = registry;
}
// -- Memo cache --------------------------------------------------------------
export function getMemoCache(handle) {
return handle.memoCache;
}
export function setMemoCache(handle, cache) {
handle.memoCache = cache;
}
// -- Subscriptions -----------------------------------------------------------
export function getActiveSubs(handle) {
return handle.gleamActiveSubs;
}
export function setActiveSubs(handle, subs) {
handle.gleamActiveSubs = subs;
}
// -- Windows -----------------------------------------------------------------
export function getWindows(handle) {
return handle.gleamWindows;
}
export function setWindows(handle, windows) {
handle.gleamWindows = windows;
}
// -- Stop --------------------------------------------------------------------
export function stop(handle) {
handle.stopped = true;
// Clear all timer subscriptions
for (const [, id] of handle.timerSubs) {
clearInterval(id);
}
handle.timerSubs.clear();
// Clear all send_after timers
for (const [, entry] of handle.sendAfterTimers) {
clearTimeout(entry.id);
}
handle.sendAfterTimers.clear();
handle.sendAfterTokens.clear();
// Cancel all async tasks
for (const [, task] of handle.asyncTasks) {
task.cancel?.();
}
handle.asyncTasks.clear();
handle.asyncTaskTokens.clear();
// Clear coalesce state
handle.pendingCoalesce.clear();
handle.coalescePending = false;
handle.dispatchQueue.length = 0;
// Clear pending effect tracking
for (const [, entry] of handle.pendingEffects) {
if (entry.timeoutId !== null) {
clearTimeout(entry.timeoutId);
}
}
handle.pendingEffects.clear();
handle.pendingEffectTags.clear();
}
// -- Coalescing --------------------------------------------------------------
export function setCoalesce(handle, key, event) {
handle.pendingCoalesce.set(key, event);
}
export function scheduleCoalesceFlush(handle) {
if (handle.coalescePending) return;
handle.coalescePending = true;
queueMicrotask(() => {
if (handle.stopped) return;
// Drain pending events and dispatch each directly (bypassing
// coalesce checks to avoid re-coalescing flushed events).
handle.coalescePending = false;
const events = [...handle.pendingCoalesce.values()];
handle.pendingCoalesce.clear();
enqueueDispatch(handle, () => {
for (const event of events) {
handle.dispatchDirect?.(event);
}
});
});
}
// flushCoalesced is called synchronously from the Gleam side when
// a non-coalescable event arrives. It drains and dispatches pending
// coalesced events immediately, bypassing coalesce checks.
export function flushCoalesced(handle) {
handle.coalescePending = false;
const events = [...handle.pendingCoalesce.values()];
handle.pendingCoalesce.clear();
for (const event of events) {
handle.dispatchDirect?.(event);
}
}
// -- Timer subscriptions -----------------------------------------------------
export function startTimerSub(handle, key, intervalMs, _tag) {
// Clear existing if any
if (handle.timerSubs.has(key)) {
clearInterval(handle.timerSubs.get(key));
}
const id = setInterval(() => {
if (handle.stopped) return;
// Call the Gleam-side timer callback which constructs a
// TimerTick event and dispatches it through handle_event.
enqueueDispatch(handle, () => {
handle.onTimerFired?.(_tag);
});
}, intervalMs);
handle.timerSubs.set(key, id);
}
export function clearTimerSub(handle, key) {
const id = handle.timerSubs.get(key);
if (id !== undefined) {
clearInterval(id);
handle.timerSubs.delete(key);
}
}
// -- SendAfter ---------------------------------------------------------------
export function setSendAfter(handle, key, delayMs, callback) {
// Cancel existing timer for same key
const existing = handle.sendAfterTimers.get(key);
if (existing !== undefined) {
clearTimeout(existing.id);
}
const token = ++handle.nextTimerToken;
handle.sendAfterTokens.set(key, token);
const id = setTimeout(() => {
if (handle.stopped) return;
const current = handle.sendAfterTimers.get(key);
if (!current || current.token !== token) return;
handle.sendAfterTimers.delete(key);
// callback is a Gleam closure that dispatches the msg
// directly to dispatch_update (already typed as msg)
enqueueDispatch(handle, () => {
if (handle.sendAfterTokens.get(key) !== token) return;
callback();
if (handle.sendAfterTokens.get(key) === token) {
handle.sendAfterTokens.delete(key);
}
});
}, delayMs);
handle.sendAfterTimers.set(key, { id, token });
}
// -- Effect requests --------------------------------------------------------
export function startPendingEffect(
handle,
requestId,
tag,
kind,
timeoutMs,
onTimeout,
) {
let timeoutId = null;
const fireTimeout = () => {
if (handle.stopped) return;
enqueueDispatch(handle, onTimeout);
};
if (globalThis.__plushieImmediateEffectTimeouts) {
timeoutId = null;
} else {
timeoutId = setTimeout(fireTimeout, timeoutMs);
}
handle.pendingEffects.set(requestId, { tag, kind, timeoutId });
handle.pendingEffectTags.set(tag, requestId);
if (globalThis.__plushieImmediateEffectTimeouts) {
fireTimeout();
}
}
export function cancelPendingEffectByTag(handle, tag) {
const requestId = handle.pendingEffectTags.get(tag);
if (requestId === undefined) return;
handle.pendingEffectTags.delete(tag);
const entry = handle.pendingEffects.get(requestId);
if (entry !== undefined) {
if (entry.timeoutId !== null) {
clearTimeout(entry.timeoutId);
}
handle.pendingEffects.delete(requestId);
}
}
export function takePendingEffect(handle, requestId) {
const entry = handle.pendingEffects.get(requestId);
if (entry === undefined) {
return new None();
}
handle.pendingEffects.delete(requestId);
if (handle.pendingEffectTags.get(entry.tag) === requestId) {
handle.pendingEffectTags.delete(entry.tag);
}
if (entry.timeoutId !== null) {
clearTimeout(entry.timeoutId);
}
return new Some([entry.tag, entry.kind]);
}
// -- Async tasks -------------------------------------------------------------
export function startAsync(handle, tag, work) {
// Cancel existing task with same tag
cancelAsync(handle, tag);
const nonce = ++handle.nextNonce;
let cancelled = false;
const cancel = () => {
cancelled = true;
};
handle.asyncTasks.set(tag, { nonce, cancel });
handle.asyncTaskTokens.set(tag, nonce);
// Run work; might return a Promise or a plain value
try {
const result = work();
if (result && typeof result.then === "function") {
// Promise-based async
result.then(
(value) => {
settleAsyncTask(handle, tag, nonce, () => cancelled, () => {
enqueueDispatch(handle, () => {
notifyAsyncComplete(handle, tag, nonce, new Ok(value));
});
});
},
(error) => {
settleAsyncTask(handle, tag, nonce, () => cancelled, () => {
enqueueDispatch(handle, () => {
notifyAsyncComplete(handle, tag, nonce, new Error(error));
});
});
},
);
} else {
// Synchronous result; defer to next microtask
queueMicrotask(() => {
settleAsyncTask(handle, tag, nonce, () => cancelled, () => {
enqueueDispatch(handle, () => {
notifyAsyncComplete(handle, tag, nonce, new Ok(result));
});
});
});
}
} catch (error) {
settleAsyncTask(handle, tag, nonce, () => cancelled, () => {
enqueueDispatch(handle, () => {
notifyAsyncComplete(handle, tag, nonce, new Error(error));
});
});
}
}
export function startStream(handle, tag, work) {
cancelAsync(handle, tag);
const nonce = ++handle.nextNonce;
let cancelled = false;
const cancel = () => {
cancelled = true;
};
handle.asyncTasks.set(tag, { nonce, cancel });
handle.asyncTaskTokens.set(tag, nonce);
const emit = (value) => {
if (cancelled || handle.stopped) return;
const current = handle.asyncTasks.get(tag);
if (!current || current.nonce !== nonce) return;
enqueueDispatch(handle, () => {
if (!isLatestAsyncTask(handle, tag, nonce)) return;
handle.onStreamEmit?.(tag, value);
});
};
try {
work(emit);
} catch (error) {
settleAsyncTask(handle, tag, nonce, () => cancelled, () => {
enqueueDispatch(handle, () => {
notifyAsyncComplete(handle, tag, nonce, new Error(error));
});
});
}
}
export function cancelAsync(handle, tag) {
const task = handle.asyncTasks.get(tag);
if (task) {
task.cancel();
handle.asyncTasks.delete(tag);
}
handle.asyncTaskTokens.delete(tag);
}
function settleAsyncTask(handle, tag, nonce, isCancelled, notify) {
const current = handle.asyncTasks.get(tag);
if (!current || current.nonce !== nonce) return;
handle.asyncTasks.delete(tag);
if (isCancelled() || handle.stopped) return;
notify();
}
function isLatestAsyncTask(handle, tag, nonce) {
return !handle.stopped && handle.asyncTaskTokens.get(tag) === nonce;
}
function notifyAsyncComplete(handle, tag, nonce, result) {
if (!isLatestAsyncTask(handle, tag, nonce)) return;
handle.onAsyncComplete?.(tag, result);
if (handle.asyncTaskTokens.get(tag) === nonce) {
handle.asyncTaskTokens.delete(tag);
}
}