From 5a53eb132d6c7183806015b9668f901dc16a65cb Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 28 Oct 2026 19:53:08 +0100 Subject: [PATCH 01/32] feat: implement event handling for cookie clicker extension - Add event handling infrastructure in the pyodide-code package. - Create EventSource and ExecutionEventSource interfaces for event management. - Implement ExtensionEventStore to manage event subscriptions and deliveries. - Develop WorkerEventSession to handle event sessions in web workers. - Introduce webio.py for Python event handling, allowing event registration and processing. - Create cookie-klicker extension with event handling for cookie clicks. - Add CookieComponent to the website for user interaction with cookies. - Update SiteStore to include CookieStore for managing cookie states. - Enhance tests for event handling and worker extensions to ensure reliability. --- packages/tdev/pyodide-code/README.mdx | 40 +++++ packages/tdev/pyodide-code/events/index.ts | 37 +++++ .../tdev/pyodide-code/extensions/index.ts | 5 + .../tdev/pyodide-code/extensions/worker.ts | 7 +- packages/tdev/pyodide-code/messages.ts | 3 +- .../pyodide-code/models/PyodideRuntime.ts | 42 +++++ .../stores/ExtensionEventStore.ts | 111 +++++++++++++ .../tdev/pyodide-code/stores/PyodideStore.ts | 23 +++ .../tdev/pyodide-code/tests/events.test.ts | 100 ++++++++++++ .../tests/workerExtensions.test.ts | 10 +- .../workers/WorkerEventSession.ts | 152 ++++++++++++++++++ .../tdev/pyodide-code/workers/installWebio.ts | 31 ++++ .../pyodide-code/workers/pyodide.worker.ts | 25 ++- packages/tdev/pyodide-code/workers/webio.d.ts | 4 + packages/tdev/pyodide-code/workers/webio.py | 89 ++++++++++ tdev-website/siteConfig.ts | 5 + .../components/CookieComponent/index.tsx | 51 ++++++ .../CookieComponent/styles.module.scss | 56 +++++++ tdev-website/website/models/Cookie.ts | 37 +++++ .../website/pyodide/cookie-klicker/client.ts | 6 + .../pyodide/cookie-klicker/protocol.ts | 10 ++ .../website/pyodide/cookie-klicker/worker.ts | 35 ++++ tdev-website/website/stores/CookieStore.ts | 23 +++ tdev-website/website/stores/SiteStore.ts | 6 +- 24 files changed, 900 insertions(+), 8 deletions(-) create mode 100644 packages/tdev/pyodide-code/events/index.ts create mode 100644 packages/tdev/pyodide-code/stores/ExtensionEventStore.ts create mode 100644 packages/tdev/pyodide-code/tests/events.test.ts create mode 100644 packages/tdev/pyodide-code/workers/WorkerEventSession.ts create mode 100644 packages/tdev/pyodide-code/workers/installWebio.ts create mode 100644 packages/tdev/pyodide-code/workers/webio.d.ts create mode 100644 packages/tdev/pyodide-code/workers/webio.py create mode 100644 tdev-website/website/components/CookieComponent/index.tsx create mode 100644 tdev-website/website/components/CookieComponent/styles.module.scss create mode 100644 tdev-website/website/models/Cookie.ts create mode 100644 tdev-website/website/pyodide/cookie-klicker/client.ts create mode 100644 tdev-website/website/pyodide/cookie-klicker/protocol.ts create mode 100644 tdev-website/website/pyodide/cookie-klicker/worker.ts create mode 100644 tdev-website/website/stores/CookieStore.ts diff --git a/packages/tdev/pyodide-code/README.mdx b/packages/tdev/pyodide-code/README.mdx index 465ab64f5..69fd978f0 100644 --- a/packages/tdev/pyodide-code/README.mdx +++ b/packages/tdev/pyodide-code/README.mdx @@ -10,6 +10,7 @@ sidebar_position: 50 import CodeShowcase from '@tdev-components/CodeShowcase'; import Clock from '@tdev/packages/pyodide-code/components/Clock'; import Pyodide from '@tdev/pyodide-code/components/Pyodide'; +import CookieComponent from '@tdev-components/CookieComponent'; import CodeBlock from '@theme/CodeBlock'; @@ -151,6 +152,44 @@ while True: ``` +### Cookie-Klicker: Browser-Ereignisse in Python + +`@on_click(cookie)` registriert einen Handler für genau diesen Cookie. Der Handler erhält ein Ereignis mit `type`, `extension`, `target_id`, `timestamp` und `payload`. `await run()` hält das Programm aktiv, bis `webio.stop()` aufgerufen oder im Editor **Abbrechen** gedrückt wird. + +Der Store verwaltet für jede `cookieId` ein eigenes MobX-Modell. Mehrere Cookies können unabhängig gesteuert werden; mehrere Ansichten derselben ID teilen dasselbe Modell. + + + + + +```py live_pyo id=62007a34-9647-4c62-83bd-7a0f829e502a title=Cookie_Klicker +from random import randint +from cookie_klicker import use_cookie +from webio import on_click, run + +cookie = use_cookie('cookie-demo') +zweiter_cookie = use_cookie('cookie-zwei') + +@on_click(cookie) +def drehen(event): + cookie.set_rotation(cookie.rotation + randint(0, 360)) + +@on_click(zweiter_cookie) +def schrittweise_drehen(event): + zweiter_cookie.set_rotation(zweiter_cookie.rotation + 30) + +await run() +``` + + +Der Cookie wird anklickbar, sobald das Programm seine Ereignisquellen angemeldet hat. Eine Ziel-ID kann gleichzeitig nur von einem laufenden Programm für Ereignisse verwendet werden. Ein zweiter Controller erhält eine Fehlermeldung. Nach Ende oder Abbruch werden die Zuordnungen entfernt; alte Klicks werden nicht an einen Neustart weitergegeben. + +`use_cookie(id)` liefert innerhalb derselben Ausführung denselben Handle zurück. Seine lokale `rotation` beginnt bei null; `set_rotation()` normalisiert den Winkel auf `[0, 360)` und aktualisiert das Browser-Modell. Ein neuer Handle verändert die bisherige Anzeige erst mit seinem ersten Update. + +Handler laufen nacheinander. Für absichtliche Wartezeiten kann ein Handler `async def` verwenden und `await asyncio.sleep(0.1)` aufrufen. Währenddessen können weitere Klicks empfangen werden; ihre Handler starten nach Abschluss des aktuellen Handlers. `time.sleep()`, synchrone Eingaben mit `input()` und lange Berechnungen blockieren dagegen den Empfang im Worker. + +Für weitere Ereignistypen steht `@on_event('typ', source=cookie.events)` zur Verfügung. `webio.stop()` beendet den Empfang nach dem aktuellen Handler. Pro Ausführung ist ein `run()`-Aufruf vorgesehen; bei einem Handler-Fehler wird das Programm mit einer Diagnose beendet. Überfüllte Ereigniswarteschlangen beenden die betroffene Ausführung ebenfalls mit einer Fehlermeldung. + ### Erweiterungen registrieren Eine Erweiterung hat zwei getrennte Einstiegspunkte: `worker.ts` stellt Python-Module bereit, `client.ts` verarbeitet deren Nachrichten im Browser. Beide verwenden denselben Payload-Typ aus `protocol.ts`. React-Komponenten und MobX-Stores bleiben normale Bestandteile der Website. @@ -210,6 +249,7 @@ Der Factory-Kontext bietet: - `editorId`: die ID des ausführenden Editors. - `send(targetId, payload)`: sendet eine Nachricht an den Browser-Handler dieser Erweiterung. Das Framework ergänzt Editor-ID, Erweiterungsname und Zeitstempel. Der Payload muss durch strukturiertes Klonen übertragbar sein, beispielsweise ein Objekt aus Zahlen, Strings und Arrays. +- `events.source(targetId)`: erzeugt einen Ereignisquellen-Deskriptor, der an die aktuelle Ausführung gebunden ist. Ein Python-Handle kann ihn als `events` bereitstellen. `webio.run()` meldet die Quellen seiner registrierten Handler an; ein Browser-Store sendet Ereignisse mit `pyodideStore.events.emit(source, { type, payload })` an den aktiven Controller. Die Factory wird bei **jeder Ausführung** aufgerufen und ihre Module werden vor dem Python-Code registriert. Damit funktionieren auch dynamische Imports wie `importlib.import_module('clock')`. Der Worker wartet vor Abschluss auf ausstehende Nachrichten an die Browser-Handler. Ein Handler-Fehler wird als Ausführungsfehler im Editor angezeigt. Bereits zugestellte Änderungen werden nicht zurückgerollt. diff --git a/packages/tdev/pyodide-code/events/index.ts b/packages/tdev/pyodide-code/events/index.ts new file mode 100644 index 000000000..e7c097b1c --- /dev/null +++ b/packages/tdev/pyodide-code/events/index.ts @@ -0,0 +1,37 @@ +export const MAX_PENDING_EVENTS = 256; + +export interface EventSource { + readonly extension: string; + readonly targetId: string; +} + +export interface ExecutionEventSource extends EventSource { + readonly executionId: number; +} + +export interface WebEvent { + type: string; + payload: Payload; +} + +export interface ExecutionEvent { + editorId: string; + executionId: number; + extension: string; + targetId: string; + timeStamp: number; + event: WebEvent; +} + +export interface EventSubscriptionMessage { + type: 'event-subscriptions'; + id: string; + executionId: number; + operation: 'open' | 'close'; + sources: readonly ExecutionEventSource[]; +} + +export type EventDeliveryResult = 'accepted' | 'inactive' | 'overflow'; + +export const eventSourceKey = (source: EventSource): string => + JSON.stringify([source.extension, source.targetId]); diff --git a/packages/tdev/pyodide-code/extensions/index.ts b/packages/tdev/pyodide-code/extensions/index.ts index e2acdf971..90b0de8a2 100644 --- a/packages/tdev/pyodide-code/extensions/index.ts +++ b/packages/tdev/pyodide-code/extensions/index.ts @@ -1,4 +1,5 @@ import type { RootStore } from '@tdev-stores/rootStore'; +import type { ExecutionEventSource } from '../events'; export interface ExtensionMessage { type: 'extension'; @@ -12,6 +13,9 @@ export interface ExtensionMessage { export interface WorkerContext { editorId: string; send: (targetId: string, payload: Payload) => void; + events: { + source: (targetId: string) => ExecutionEventSource; + }; } export interface ClientContext { @@ -44,6 +48,7 @@ export const defineWorkerExtension = ( createModules: (context) => createModules({ editorId: context.editorId, + events: context.events, send: (targetId, payload) => context.send(targetId, payload) }) }); diff --git a/packages/tdev/pyodide-code/extensions/worker.ts b/packages/tdev/pyodide-code/extensions/worker.ts index ae3d62124..3fda9a715 100644 --- a/packages/tdev/pyodide-code/extensions/worker.ts +++ b/packages/tdev/pyodide-code/extensions/worker.ts @@ -3,19 +3,22 @@ import type { Context } from '../messages'; import type { RegisteredExtension, WorkerExtension } from './index'; import { registerJsModule } from '../workers/registerJsModule'; import { createMessageDelivery } from '../workers/messageDelivery'; +import type { ExecutionEventSource } from '../events'; export const createWorkerExtensions = ( context: Context & { id: string }, - extensions: readonly RegisteredExtension[] + extensions: readonly RegisteredExtension[], + createEventSource: (extension: string, targetId: string) => ExecutionEventSource ) => { const delivery = createMessageDelivery((message) => context.sendMessage(message)); let isActive = true; const register = (pyodide: PyodideAPI) => { - const moduleNames = new Set(['browser_input']); + const moduleNames = new Set(['browser_input', 'webio']); for (const { name, extension } of extensions) { const modules = extension.createModules({ editorId: context.id, + events: { source: (targetId) => createEventSource(name, targetId) }, send: (targetId, payload) => { if (!isActive) { return; diff --git a/packages/tdev/pyodide-code/messages.ts b/packages/tdev/pyodide-code/messages.ts index b7e7715c9..0b9e517d5 100644 --- a/packages/tdev/pyodide-code/messages.ts +++ b/packages/tdev/pyodide-code/messages.ts @@ -1,4 +1,5 @@ import type { ExtensionMessage } from './extensions'; +import type { EventSubscriptionMessage } from './events'; export interface LogMessage { id: string; @@ -19,7 +20,7 @@ export interface ClearMessage { type: 'clear'; } -export type Message = LogMessage | ErrorMessage | ClearMessage | ExtensionMessage; +export type Message = LogMessage | ErrorMessage | ClearMessage | ExtensionMessage | EventSubscriptionMessage; export interface Context { sendMessage: (message: Message) => void | Promise; diff --git a/packages/tdev/pyodide-code/models/PyodideRuntime.ts b/packages/tdev/pyodide-code/models/PyodideRuntime.ts index b022b98d4..f7e87db77 100644 --- a/packages/tdev/pyodide-code/models/PyodideRuntime.ts +++ b/packages/tdev/pyodide-code/models/PyodideRuntime.ts @@ -2,6 +2,7 @@ import { ModuleWorker } from '@tdev/pyodide-code/workers/ModuleWorker'; import type { PyWorker, PyWorkerApi } from '../workers/pyodide.worker'; import * as Comlink from 'comlink'; import { withTimeout } from '../helpers/withTimeout'; +import type { EventDeliveryResult, ExecutionEvent } from '../events'; export default class PyodideRuntime { readonly worker: Worker; @@ -11,6 +12,7 @@ export default class PyodideRuntime { isUsable = true; private resolveStopped!: () => void; private rejectFailure!: (error: Error) => void; + private readonly pendingEvents = new Set<(error?: Error) => void>(); constructor() { this.stopped = new Promise((resolve) => { @@ -55,14 +57,54 @@ export default class PyodideRuntime { }); } + deliverEvent(event: ExecutionEvent): Promise { + if (!this.isUsable) { + return Promise.resolve('inactive'); + } + return new Promise((resolve, reject) => { + const finish = (result: EventDeliveryResult) => { + this.pendingEvents.delete(cancel); + resolve(result); + }; + const fail = (error: unknown) => { + this.pendingEvents.delete(cancel); + reject(error); + }; + const cancel = (error?: Error) => { + if (error) { + fail(error); + } else { + finish('inactive'); + } + }; + this.pendingEvents.add(cancel); + this.api + .then((api) => { + if (!this.pendingEvents.has(cancel)) { + return 'inactive' as const; + } + return api.deliverEvent(event); + }) + .then(finish, fail); + }); + } + terminate() { this.isUsable = false; this.worker.terminate(); + this.finishPendingEvents(); this.resolveStopped(); } fail(error: Error) { this.isUsable = false; + this.finishPendingEvents(error); this.rejectFailure(error); } + + private finishPendingEvents(error?: Error) { + for (const cancel of this.pendingEvents) { + cancel(error); + } + } } diff --git a/packages/tdev/pyodide-code/stores/ExtensionEventStore.ts b/packages/tdev/pyodide-code/stores/ExtensionEventStore.ts new file mode 100644 index 000000000..b881c3ff2 --- /dev/null +++ b/packages/tdev/pyodide-code/stores/ExtensionEventStore.ts @@ -0,0 +1,111 @@ +import { action, observable } from 'mobx'; +import { + eventSourceKey, + MAX_PENDING_EVENTS, + type EventSource, + type ExecutionEventSource, + type WebEvent +} from '../events'; +import type PyodideCode from '../models/PyodideCode'; +import type PyodideStore from './PyodideStore'; + +interface Subscription { + code: PyodideCode; + executionId: number; +} + +export default class ExtensionEventStore { + private readonly subscriptions = observable.map([], { deep: false }); + private readonly pendingDeliveries = new Map(); + + constructor(private readonly store: PyodideStore) {} + + isConnected(source: EventSource): boolean { + return this.subscriptions.has(eventSourceKey(source)); + } + + @action + open(code: PyodideCode, executionId: number, sources: readonly ExecutionEventSource[]) { + if (this.store.executions.get(code.id) !== executionId) { + throw new Error('This Python execution is no longer active.'); + } + for (const source of sources) { + if (source.executionId !== executionId) { + throw new Error('Cannot subscribe with an expired event source.'); + } + const owner = this.subscriptions.get(eventSourceKey(source)); + if (owner && owner.executionId !== executionId) { + throw new Error( + `Pyodide event source "${source.extension}/${source.targetId}" already has a running controller.` + ); + } + } + for (const source of sources) { + this.subscriptions.set(eventSourceKey(source), { code, executionId }); + } + } + + @action + close(editorId: string, executionId: number) { + for (const [key, owner] of this.subscriptions) { + if (owner.code.id === editorId && owner.executionId === executionId) { + this.subscriptions.delete(key); + } + } + this.pendingDeliveries.delete(executionId); + } + + async emit(source: EventSource, event: WebEvent): Promise<'accepted' | 'inactive'> { + const owner = this.subscriptions.get(eventSourceKey(source)); + if (!owner || this.store.executions.get(owner.code.id) !== owner.executionId) { + return 'inactive'; + } + const pending = this.pendingDeliveries.get(owner.executionId) ?? 0; + if (pending >= MAX_PENDING_EVENTS) { + this.fail(owner, new Error('Too many pending browser events. Run the Python script again.')); + return 'inactive'; + } + this.pendingDeliveries.set(owner.executionId, pending + 1); + try { + const result = await this.store.deliverEvent({ + editorId: owner.code.id, + executionId: owner.executionId, + extension: source.extension, + targetId: source.targetId, + timeStamp: Date.now(), + event + }); + if (result === 'overflow') { + throw new Error('Python event queue is full. Run the script again.'); + } + return result; + } catch (error: unknown) { + this.fail(owner, error); + return 'inactive'; + } finally { + const remaining = this.pendingDeliveries.get(owner.executionId); + if (remaining !== undefined) { + if (remaining <= 1) { + this.pendingDeliveries.delete(owner.executionId); + } else { + this.pendingDeliveries.set(owner.executionId, remaining - 1); + } + } + } + } + + @action + private fail(owner: Subscription, error: unknown) { + if (this.store.executions.get(owner.code.id) !== owner.executionId) { + return; + } + owner.code.addLogMessage({ + type: 'error', + message: error instanceof Error ? error.message : String(error), + id: owner.code.id, + timeStamp: Date.now() + }); + this.store.stopExecution(owner.code.id); + owner.code.setRuntimeId(null); + } +} diff --git a/packages/tdev/pyodide-code/stores/PyodideStore.ts b/packages/tdev/pyodide-code/stores/PyodideStore.ts index 7ac909a6d..2128ecaf1 100644 --- a/packages/tdev/pyodide-code/stores/PyodideStore.ts +++ b/packages/tdev/pyodide-code/stores/PyodideStore.ts @@ -9,6 +9,8 @@ import { initializeInputServiceWorker } from '../helpers/inputServiceWorker'; import type PyodideCode from '../models/PyodideCode'; import PyodideRuntime from '../models/PyodideRuntime'; import { handleExtensionMessage } from '../extensions/client'; +import type { EventDeliveryResult, ExecutionEvent } from '../events'; +import ExtensionEventStore from './ExtensionEventStore'; const BASE_URL = siteConfig.baseUrl || '/'; // Bound unused heaps while keeping two recent runtimes warm. const MAX_IDLE_RUNTIMES = 2; @@ -21,6 +23,7 @@ interface InputRequest { export default class PyodideStore { viewStore: ViewStore; readonly executions = observable.map(); + readonly events = new ExtensionEventStore(this); private readonly retainedRuntimes = new Set(); private readonly runtimes = new Map(); private readonly idleRuntimes: PyodideRuntime[] = []; @@ -117,6 +120,7 @@ export default class PyodideStore { }); } finally { runInAction(() => { + this.events.close(code.id, executionId); this.cancellations.delete(executionId); if (this.executions.get(code.id) === executionId) { this.executions.delete(code.id); @@ -136,6 +140,7 @@ export default class PyodideStore { return; } this.latestExecutions.delete(id); + this.events.close(id, executionId); if (this.awaitingInputPrompt.has(id)) { this.cancelCodeExecution(id); } @@ -150,6 +155,13 @@ export default class PyodideStore { @action handleMessage(code: PyodideCode, message: Message) { switch (message.type) { + case 'event-subscriptions': + if (message.operation === 'close') { + this.events.close(code.id, message.executionId); + } else { + this.events.open(code, message.executionId, message.sources); + } + break; case 'extension': handleExtensionMessage({ root: this.viewStore.root }, message); break; @@ -166,6 +178,17 @@ export default class PyodideStore { } } + async deliverEvent(event: ExecutionEvent): Promise { + if (this.executions.get(event.editorId) !== event.executionId) { + return 'inactive'; + } + const runtime = this.runtimes.get(event.editorId); + if (!runtime) { + return 'inactive'; + } + return runtime.deliverEvent(event); + } + @computed get isInitialized() { return this.initializationState === 'ready'; diff --git a/packages/tdev/pyodide-code/tests/events.test.ts b/packages/tdev/pyodide-code/tests/events.test.ts new file mode 100644 index 000000000..29542a0e4 --- /dev/null +++ b/packages/tdev/pyodide-code/tests/events.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest'; +import { MAX_PENDING_EVENTS, type ExecutionEvent } from '../events'; +import WorkerEventSession from '../workers/WorkerEventSession'; + +const createSession = () => { + const sendMessage = vi.fn(); + const session = new WorkerEventSession({ id: 'editor', executionId: 1, sendMessage, getTime: Date.now }, [ + 'cookie_klicker' + ]); + const source = session.source('cookie_klicker', 'demo'); + return { session, source, sendMessage }; +}; + +const click: ExecutionEvent = { + editorId: 'editor', + executionId: 1, + extension: 'cookie_klicker', + targetId: 'demo', + timeStamp: 123, + event: { type: 'click', payload: null } +}; + +describe('Pyodide event sessions', () => { + it('queues events in arrival order and wakes an idle receiver', async () => { + const { session, source } = createSession(); + await session.open(JSON.stringify([source])); + expect(session.deliver(click)).toBe('accepted'); + expect(session.deliver({ ...click, timeStamp: 124 })).toBe('accepted'); + expect(await session.nextEvent()).toEqual(click); + expect((await session.nextEvent())?.timeStamp).toBe(124); + const next = session.nextEvent(); + session.deliver({ ...click, timeStamp: 125 }); + expect((await next)?.timeStamp).toBe(125); + await session.close(); + }); + + it('rejects old executions and unsubscribed targets', async () => { + const { session, source } = createSession(); + expect(session.deliver(click)).toBe('inactive'); + await session.open(JSON.stringify([source])); + expect(session.deliver({ ...click, executionId: 2 })).toBe('inactive'); + expect(session.deliver({ ...click, editorId: 'other' })).toBe('inactive'); + expect(session.deliver({ ...click, targetId: 'other' })).toBe('inactive'); + await session.close(); + }); + + it('validates every descriptor, including an expired duplicate', async () => { + const { session, source } = createSession(); + await expect(session.open(JSON.stringify([{ ...source, executionId: 0 }, source]))).rejects.toThrow( + 'Invalid or expired event source' + ); + await session.close(); + }); + + it('closes an idle receiver and discards queued events on stop', async () => { + const { session, source, sendMessage } = createSession(); + await session.open(JSON.stringify([source])); + const waiting = session.nextEvent(); + session.stop(); + expect(await waiting).toBeUndefined(); + expect(session.deliver(click)).toBe('inactive'); + await session.close(); + await session.close(); + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(sendMessage.mock.lastCall?.[0].operation).toBe('close'); + expect(() => session.source('cookie_klicker', 'demo')).toThrow('session has ended'); + }); + + it('reports overflow without silently replacing queued clicks', async () => { + const { session, source } = createSession(); + await session.open(JSON.stringify([source])); + for (let index = 0; index < MAX_PENDING_EVENTS; index++) { + expect(session.deliver({ ...click, timeStamp: index })).toBe('accepted'); + } + expect(session.deliver(click)).toBe('overflow'); + expect((await session.nextEvent())?.timeStamp).toBe(0); + session.stop(); + expect(await session.nextEvent()).toBeUndefined(); + await session.close(); + }); + + it('closes ingress after subscription failure', async () => { + const { session, source, sendMessage } = createSession(); + sendMessage.mockRejectedValueOnce(new Error('source already owned')); + await expect(session.open(JSON.stringify([source]))).rejects.toThrow('source already owned'); + expect(session.deliver(click)).toBe('inactive'); + expect(await session.nextEvent()).toBeUndefined(); + await session.close(); + }); + + it('allows only one event pump and receiver', async () => { + const { session, source } = createSession(); + await session.open(JSON.stringify([source])); + await expect(session.open(JSON.stringify([source]))).rejects.toThrow('Only one webio.run()'); + const pending = session.nextEvent(); + expect(() => session.nextEvent()).toThrow('Only one event receiver'); + await session.close(); + expect(await pending).toBeUndefined(); + }); +}); diff --git a/packages/tdev/pyodide-code/tests/workerExtensions.test.ts b/packages/tdev/pyodide-code/tests/workerExtensions.test.ts index fdce8ec8b..6a60b2f10 100644 --- a/packages/tdev/pyodide-code/tests/workerExtensions.test.ts +++ b/packages/tdev/pyodide-code/tests/workerExtensions.test.ts @@ -1,12 +1,17 @@ import type { PyodideAPI } from 'pyodide'; import { describe, expect, it, vi } from 'vitest'; import { defineWorkerExtension, type WorkerContext } from '../extensions'; -import { createWorkerExtensions } from '../extensions/worker'; +import { createWorkerExtensions as createExtensions } from '../extensions/worker'; import { registerJsModule } from '../workers/registerJsModule'; vi.mock('../workers/registerJsModule', () => ({ registerJsModule: vi.fn() })); const pyodide = {} as PyodideAPI; +const createWorkerExtensions = ( + context: Parameters[0], + extensions: Parameters[1] +) => + createExtensions(context, extensions, (extension, targetId) => ({ extension, targetId, executionId: 1 })); describe('Pyodide worker extensions', () => { it('adds transport metadata without confusing editor and target IDs', async () => { @@ -54,8 +59,7 @@ describe('Pyodide worker extensions', () => { expect(modules[0]).not.toBe(modules[1]); }); - it('rejects the reserved browser_input module', async () => { - const name = 'browser_input'; + it.each(['browser_input', 'webio'])('rejects the reserved %s module', async (name) => { const session = createWorkerExtensions({ id: 'editor', getTime: Date.now, sendMessage: vi.fn() }, [ { name: 'example', extension: defineWorkerExtension(() => ({ [name]: {} })) } ]); diff --git a/packages/tdev/pyodide-code/workers/WorkerEventSession.ts b/packages/tdev/pyodide-code/workers/WorkerEventSession.ts new file mode 100644 index 000000000..fdf01f21b --- /dev/null +++ b/packages/tdev/pyodide-code/workers/WorkerEventSession.ts @@ -0,0 +1,152 @@ +import { + eventSourceKey, + MAX_PENDING_EVENTS, + type EventDeliveryResult, + type ExecutionEvent, + type ExecutionEventSource +} from '../events'; +import type { Context } from '../messages'; + +interface EventContext extends Context { + id: string; + executionId: number; +} + +export default class WorkerEventSession { + private readonly sources = new Set(); + private readonly queue: ExecutionEvent[] = []; + private receiver?: (event: ExecutionEvent | undefined) => void; + private opened = false; + private closed = false; + private listening = false; + + constructor( + private readonly context: EventContext, + private readonly extensionNames: readonly string[] + ) {} + + source(extension: string, targetId: string): ExecutionEventSource { + if (this.closed) { + throw new Error('This Python event session has ended. Run the script again.'); + } + if (typeof targetId !== 'string' || !targetId) { + throw new Error('An event source needs a non-empty target ID.'); + } + return Object.freeze({ extension, targetId, executionId: this.context.executionId }); + } + + async open(sourcesJson: string) { + if (this.opened || this.closed) { + throw new Error('Only one webio.run() call is allowed per execution.'); + } + const sources = this.parseSources(sourcesJson); + this.opened = true; + for (const source of sources) { + this.sources.add(eventSourceKey(source)); + } + this.listening = true; + try { + await this.context.sendMessage({ + type: 'event-subscriptions', + id: this.context.id, + executionId: this.context.executionId, + operation: 'open', + sources + }); + } catch (error) { + this.stop(); + throw error; + } + } + + deliver(event: ExecutionEvent): EventDeliveryResult { + if ( + !this.listening || + event.editorId !== this.context.id || + event.executionId !== this.context.executionId || + !this.sources.has(eventSourceKey(event)) + ) { + return 'inactive'; + } + if (this.receiver) { + const receive = this.receiver; + this.receiver = undefined; + receive(event); + return 'accepted'; + } + if (this.queue.length >= MAX_PENDING_EVENTS) { + return 'overflow'; + } + this.queue.push(event); + return 'accepted'; + } + + nextEvent(): Promise { + if (!this.listening) { + return Promise.resolve(undefined); + } + const event = this.queue.shift(); + if (event) { + return Promise.resolve(event); + } + if (this.receiver) { + throw new Error('Only one event receiver is allowed per execution.'); + } + return new Promise((resolve) => { + this.receiver = resolve; + }); + } + + stop() { + this.listening = false; + this.queue.length = 0; + this.receiver?.(undefined); + this.receiver = undefined; + } + + async close() { + if (this.closed) { + return; + } + this.closed = true; + this.stop(); + if (!this.opened) { + return; + } + await this.context.sendMessage({ + type: 'event-subscriptions', + id: this.context.id, + executionId: this.context.executionId, + operation: 'close', + sources: [] + }); + } + + private parseSources(json: string): ExecutionEventSource[] { + const sources: unknown = JSON.parse(json); + if (!Array.isArray(sources) || sources.length === 0) { + throw new Error('Register at least one event handler before calling webio.run().'); + } + return sources.map((source: unknown) => { + if ( + !source || + typeof source !== 'object' || + !('extension' in source) || + typeof source.extension !== 'string' || + !this.extensionNames.includes(source.extension) || + !('targetId' in source) || + typeof source.targetId !== 'string' || + !source.targetId || + !('executionId' in source) || + source.executionId !== this.context.executionId + ) { + throw new Error('Invalid or expired event source. Create the handle in this execution.'); + } + return { + extension: source.extension, + targetId: source.targetId, + executionId: this.context.executionId + }; + }); + } +} diff --git a/packages/tdev/pyodide-code/workers/installWebio.ts b/packages/tdev/pyodide-code/workers/installWebio.ts new file mode 100644 index 000000000..aa07e89cb --- /dev/null +++ b/packages/tdev/pyodide-code/workers/installWebio.ts @@ -0,0 +1,31 @@ +import type { PyodideAPI } from 'pyodide'; +import source from '!!raw-loader!./webio.py'; +import type WorkerEventSession from './WorkerEventSession'; + +export const installWebio = (pyodide: PyodideAPI, session: WorkerEventSession) => { + const module = pyodide.runPython('__import__("types").ModuleType("webio")'); + const namespace = module.__dict__; + const modules = pyodide.pyimport('sys.modules'); + try { + namespace.set('_bridge', { + open: (sources: string) => session.open(sources), + next_event: () => session.nextEvent(), + stop: () => session.stop(), + close: () => session.close() + }); + pyodide.runPython(source, { globals: namespace, filename: 'webio.py' }); + modules.set('webio', module); + } finally { + modules.destroy(); + namespace.destroy(); + module.destroy(); + } + return () => { + const modules = pyodide.pyimport('sys.modules'); + try { + modules.delete('webio'); + } finally { + modules.destroy(); + } + }; +}; diff --git a/packages/tdev/pyodide-code/workers/pyodide.worker.ts b/packages/tdev/pyodide-code/workers/pyodide.worker.ts index bc4b81125..cba3d47b2 100644 --- a/packages/tdev/pyodide-code/workers/pyodide.worker.ts +++ b/packages/tdev/pyodide-code/workers/pyodide.worker.ts @@ -10,6 +10,9 @@ import { configureInput } from './input'; import { captureOutput } from './output'; import { configureShell } from './shell'; import { createWorkerExtensions } from '../extensions/worker'; +import type { EventDeliveryResult, ExecutionEvent } from '../events'; +import WorkerEventSession from './WorkerEventSession'; +import { installWebio } from './installWebio'; interface Execution extends Context { id: string; @@ -30,10 +33,16 @@ const pyodideReadyPromise = loadPyodide({ stdout: () => {}, stderr: () => {} }); void pyodideReadyPromise.catch(() => {}); export class PyWorker { + private eventSession?: WorkerEventSession; + async ready() { await pyodideReadyPromise; } + deliverEvent(event: ExecutionEvent): EventDeliveryResult { + return this.eventSession?.deliver(event) ?? 'inactive'; + } + async run( id: string, code: string, @@ -76,7 +85,15 @@ export class PyWorker { private async execute(pyodide: PyodideAPI, execution: Execution): Promise { const { id, code, filename, clientId, executionId } = execution; pyodide.registerComlink(Comlink); - const extensions = createWorkerExtensions(execution, registeredExtensions); + const events = new WorkerEventSession( + execution, + registeredExtensions.map(({ name }) => name) + ); + this.eventSession = events; + const extensions = createWorkerExtensions(execution, registeredExtensions, (extension, targetId) => + events.source(extension, targetId) + ); + let removeWebio: (() => void) | undefined; let output: ReturnType | undefined; let restoreInput: (() => void) | undefined; let restoreShell: (() => void) | undefined; @@ -85,6 +102,7 @@ export class PyWorker { const errors: unknown[] = []; try { extensions.register(pyodide); + removeWebio = installWebio(pyodide, events); await this.preparePackages(pyodide, execution); output = captureOutput(pyodide, id, execution); const fname = filename.endsWith('.py') ? filename : `${filename}.py`; @@ -110,10 +128,15 @@ export class PyWorker { } finally { await finalizeExecution( [ + () => events.close(), + () => { + this.eventSession = undefined; + }, () => output?.finish(), () => extensions.finish(), () => restoreShell?.(), () => restoreInput?.(), + () => removeWebio?.(), () => globals?.destroy(), () => { if (result instanceof pyodide.ffi.PyProxy) { diff --git a/packages/tdev/pyodide-code/workers/webio.d.ts b/packages/tdev/pyodide-code/workers/webio.d.ts new file mode 100644 index 000000000..3c11f075e --- /dev/null +++ b/packages/tdev/pyodide-code/workers/webio.d.ts @@ -0,0 +1,4 @@ +declare module '!!raw-loader!*.py' { + const source: string; + export default source; +} diff --git a/packages/tdev/pyodide-code/workers/webio.py b/packages/tdev/pyodide-code/workers/webio.py new file mode 100644 index 000000000..730410e0b --- /dev/null +++ b/packages/tdev/pyodide-code/workers/webio.py @@ -0,0 +1,89 @@ +"""Execution-owned browser events for Teaching Dev.""" + +import inspect +import json +from types import SimpleNamespace + +_handlers = [] +_started = False +_stopping = False + + +def on_event(event_type, *, source): + if not isinstance(event_type, str) or not event_type: + raise ValueError('An event needs a non-empty type.') + + def register(handler): + if _started: + raise RuntimeError('Register handlers before calling webio.run().') + if not callable(handler): + raise TypeError('An event handler must be callable.') + _handlers.append((event_type, source, handler)) + return handler + + return register + + +def on_click(cookie): + """Register a handler accepting one click event from this cookie.""" + return on_event('click', source=cookie.events) + + +def stop(): + """Finish after the active handler, or wake an idle event receiver.""" + global _stopping + _stopping = True + _bridge.stop() + + +async def run(): + """Listen until stop(), a handler error, or the editor's Stop button.""" + global _started, _stopping + if _started: + raise RuntimeError('Only one webio.run() call is allowed per execution.') + _started = True + sources = {} + failure = None + try: + for _, source, _ in _handlers: + key = (source.executionId, source.extension, source.targetId) + sources[key] = { + 'extension': source.extension, + 'targetId': source.targetId, + 'executionId': source.executionId + } + await _bridge.open(json.dumps(list(sources.values()))) + while not _stopping: + raw = await _bridge.next_event() + if raw is None: + break + data = raw.to_py() + event = SimpleNamespace( + type=data['event']['type'], + extension=data['extension'], + target_id=data['targetId'], + timestamp=data['timeStamp'], + payload=data['event']['payload'] + ) + for event_type, source, handler in _handlers: + if _stopping: + break + if (event.type, event.extension, event.target_id) != ( + event_type, source.extension, source.targetId + ): + continue + result = handler(event) + if inspect.isawaitable(result): + await result + except BaseException as error: + failure = error + raise + finally: + _stopping = True + _handlers.clear() + try: + await _bridge.close() + except Exception as cleanup_error: + if failure is None: + raise + failure.add_note(f'Additional event cleanup error: {cleanup_error}') diff --git a/tdev-website/siteConfig.ts b/tdev-website/siteConfig.ts index 5a7520214..c3be0c650 100644 --- a/tdev-website/siteConfig.ts +++ b/tdev-website/siteConfig.ts @@ -120,6 +120,11 @@ const getSiteConfig: SiteConfigProvider = () => { name: 'clock', worker: require.resolve('./website/pyodide/clock/worker'), client: require.resolve('./website/pyodide/clock/client') + }, + { + name: 'cookie_klicker', + worker: require.resolve('./website/pyodide/cookie-klicker/worker'), + client: require.resolve('./website/pyodide/cookie-klicker/client') } ] } satisfies PyodidePluginOptions diff --git a/tdev-website/website/components/CookieComponent/index.tsx b/tdev-website/website/components/CookieComponent/index.tsx new file mode 100644 index 000000000..704762dc5 --- /dev/null +++ b/tdev-website/website/components/CookieComponent/index.tsx @@ -0,0 +1,51 @@ +import { useStore } from '@tdev-hooks/useStore'; +import clsx from 'clsx'; +import { observer } from 'mobx-react-lite'; +import styles from './styles.module.scss'; + +interface CookieComponentProps { + cookieId: string; + className?: string; +} + +const CHOCOLATE_CHIPS = [ + [65, 55], + [125, 48], + [148, 94], + [109, 106], + [57, 117], + [84, 151], + [139, 143] +]; + +const CookieComponent = observer(({ cookieId, className }: CookieComponentProps) => { + const siteStore = useStore('siteStore'); + const cookie = siteStore.cookieStore.useCookie(cookieId); + + return ( +
+ + {cookieId} + + {cookie.isConnected ? 'Klicke auf den Cookie.' : 'Starte zuerst das Python-Programm.'} + +
+ ); +}); + +export default CookieComponent; diff --git a/tdev-website/website/components/CookieComponent/styles.module.scss b/tdev-website/website/components/CookieComponent/styles.module.scss new file mode 100644 index 000000000..43972a35a --- /dev/null +++ b/tdev-website/website/components/CookieComponent/styles.module.scss @@ -0,0 +1,56 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + margin-block: 1rem; +} + +.cookie { + width: 12rem; + max-width: 100%; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + cursor: pointer; + transition: scale 100ms; + + &:focus-visible { + outline: 3px solid var(--ifm-color-primary); + outline-offset: 4px; + } + + &:active:not(:disabled) { + scale: 0.95; + } + + &:disabled { + cursor: default; + opacity: 0.6; + } +} + +.image { + display: block; + width: 100%; +} + +.dough { + fill: var(--ifm-color-warning-light); + stroke: var(--ifm-color-warning-darkest); + stroke-width: 5px; +} + +.chip { + fill: var(--ifm-color-warning-darkest); +} + +.label { + font-weight: var(--ifm-font-weight-bold); +} + +.status { + color: var(--ifm-color-emphasis-700); + font-size: 0.875rem; +} diff --git a/tdev-website/website/models/Cookie.ts b/tdev-website/website/models/Cookie.ts new file mode 100644 index 000000000..4549a2429 --- /dev/null +++ b/tdev-website/website/models/Cookie.ts @@ -0,0 +1,37 @@ +import { action, computed, observable } from 'mobx'; +import type CookieStore from '../stores/CookieStore'; +import { COOKIE_EXTENSION, type CookieClick } from '../pyodide/cookie-klicker/protocol'; +import type { EventSource } from '@tdev/pyodide-code/events'; + +const CLICK_EVENT: CookieClick = { type: 'click', payload: null }; + +export default class Cookie { + @observable accessor rotation = 0; + readonly eventSource: EventSource; + + constructor( + readonly id: string, + readonly store: CookieStore + ) { + this.eventSource = { extension: COOKIE_EXTENSION, targetId: id }; + } + + @computed + get isConnected(): boolean { + return this.store.pyodideStore?.events.isConnected(this.eventSource) ?? false; + } + + @computed + get rotationTransform(): string { + return `rotate(${this.rotation} 100 100)`; + } + + @action + setRotation(rotation: number) { + this.rotation = rotation; + } + + handleClick = () => { + void this.store.pyodideStore?.events.emit(this.eventSource, CLICK_EVENT); + }; +} diff --git a/tdev-website/website/pyodide/cookie-klicker/client.ts b/tdev-website/website/pyodide/cookie-klicker/client.ts new file mode 100644 index 000000000..d8e06e2cf --- /dev/null +++ b/tdev-website/website/pyodide/cookie-klicker/client.ts @@ -0,0 +1,6 @@ +import { defineClientExtension } from '@tdev/pyodide-code/extensions'; +import type { CookieUpdate } from './protocol'; + +export default defineClientExtension(({ root }, { targetId, payload }) => { + root.siteStore.cookieStore.useCookie(targetId).setRotation(payload.rotation); +}); diff --git a/tdev-website/website/pyodide/cookie-klicker/protocol.ts b/tdev-website/website/pyodide/cookie-klicker/protocol.ts new file mode 100644 index 000000000..5877bf933 --- /dev/null +++ b/tdev-website/website/pyodide/cookie-klicker/protocol.ts @@ -0,0 +1,10 @@ +export const COOKIE_EXTENSION = 'cookie_klicker'; + +export interface CookieUpdate { + rotation: number; +} + +export interface CookieClick { + type: 'click'; + payload: null; +} diff --git a/tdev-website/website/pyodide/cookie-klicker/worker.ts b/tdev-website/website/pyodide/cookie-klicker/worker.ts new file mode 100644 index 000000000..850bff4ad --- /dev/null +++ b/tdev-website/website/pyodide/cookie-klicker/worker.ts @@ -0,0 +1,35 @@ +import { defineWorkerExtension } from '@tdev/pyodide-code/extensions'; +import type { CookieUpdate } from './protocol'; + +export default defineWorkerExtension((context) => { + const createCookie = (id: string) => { + const events = context.events.source(id); + let rotation = 0; + return { + events, + get rotation() { + return rotation; + }, + set_rotation: (value: number) => { + if (!Number.isFinite(value)) { + throw new Error('Cookie rotation must be a finite number.'); + } + rotation = ((value % 360) + 360) % 360; + context.send(id, { rotation }); + } + }; + }; + const cookies = new Map>(); + return { + cookie_klicker: { + use_cookie: (id: string) => { + let cookie = cookies.get(id); + if (!cookie) { + cookie = createCookie(id); + cookies.set(id, cookie); + } + return cookie; + } + } + }; +}); diff --git a/tdev-website/website/stores/CookieStore.ts b/tdev-website/website/stores/CookieStore.ts new file mode 100644 index 000000000..96b30b340 --- /dev/null +++ b/tdev-website/website/stores/CookieStore.ts @@ -0,0 +1,23 @@ +import type { RootStore } from '@tdev-stores/rootStore'; +import { action, observable } from 'mobx'; +import Cookie from '../models/Cookie'; + +export default class CookieStore { + readonly cookies = observable.map([], { deep: false }); + + constructor(readonly root: RootStore) {} + + get pyodideStore() { + return this.root.viewStore.useStore('pyodideStore'); + } + + @action + useCookie(id: string): Cookie { + let cookie = this.cookies.get(id); + if (!cookie) { + cookie = new Cookie(id, this); + this.cookies.set(id, cookie); + } + return cookie; + } +} diff --git a/tdev-website/website/stores/SiteStore.ts b/tdev-website/website/stores/SiteStore.ts index 09b6fdf30..6602d836c 100644 --- a/tdev-website/website/stores/SiteStore.ts +++ b/tdev-website/website/stores/SiteStore.ts @@ -1,8 +1,12 @@ import { RootStore } from '@tdev-stores/rootStore'; import { observableRef } from 'mobx'; import ClockStore from './ClockStore'; +import CookieStore from './CookieStore'; export default class SiteStore { @observableRef accessor clockStore = new ClockStore(); - constructor(root: RootStore) {} + @observableRef accessor cookieStore: CookieStore; + constructor(root: RootStore) { + this.cookieStore = new CookieStore(root); + } } From dc70c5a668135de22b7af98cfc3ca53cd6b90b6e Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 28 Oct 2026 21:57:08 +0100 Subject: [PATCH 02/32] feat: update on_click handler to support generic event sources for extensions --- packages/tdev/pyodide-code/README.mdx | 4 +++- packages/tdev/pyodide-code/workers/webio.py | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/tdev/pyodide-code/README.mdx b/packages/tdev/pyodide-code/README.mdx index 69fd978f0..8464dfa98 100644 --- a/packages/tdev/pyodide-code/README.mdx +++ b/packages/tdev/pyodide-code/README.mdx @@ -154,7 +154,7 @@ while True: ### Cookie-Klicker: Browser-Ereignisse in Python -`@on_click(cookie)` registriert einen Handler für genau diesen Cookie. Der Handler erhält ein Ereignis mit `type`, `extension`, `target_id`, `timestamp` und `payload`. `await run()` hält das Programm aktiv, bis `webio.stop()` aufgerufen oder im Editor **Abbrechen** gedrückt wird. +`@on_click(target)` ist ein generischer Decorator für Erweiterungs-Handles, die ihre Ereignisquelle als `events` bereitstellen. Er kann beispielsweise mit einem Button, einem anderen interaktiven Objekt oder mit `@on_click(cookie)` für den folgenden Cookie verwendet werden. Dafür sind weder eine gemeinsame Basisklasse noch Cookie-spezifische Funktionen in `webio` nötig. Der Handler erhält ein Ereignis mit `type`, `extension`, `target_id`, `timestamp` und `payload`. `await run()` hält das Programm aktiv, bis `webio.stop()` aufgerufen oder im Editor **Abbrechen** gedrückt wird. Der Store verwaltet für jede `cookieId` ein eigenes MobX-Modell. Mehrere Cookies können unabhängig gesteuert werden; mehrere Ansichten derselben ID teilen dasselbe Modell. @@ -251,6 +251,8 @@ Der Factory-Kontext bietet: - `send(targetId, payload)`: sendet eine Nachricht an den Browser-Handler dieser Erweiterung. Das Framework ergänzt Editor-ID, Erweiterungsname und Zeitstempel. Der Payload muss durch strukturiertes Klonen übertragbar sein, beispielsweise ein Objekt aus Zahlen, Strings und Arrays. - `events.source(targetId)`: erzeugt einen Ereignisquellen-Deskriptor, der an die aktuelle Ausführung gebunden ist. Ein Python-Handle kann ihn als `events` bereitstellen. `webio.run()` meldet die Quellen seiner registrierten Handler an; ein Browser-Store sendet Ereignisse mit `pyodideStore.events.emit(source, { type, payload })` an den aktiven Controller. +Alle interaktiven Handles verwenden denselben Vertrag: Ihre `events`-Eigenschaft enthält den mit `context.events.source(targetId)` erzeugten Deskriptor. `@on_click(handle)` entspricht `@on_event('click', source=handle.events)` und benötigt keine Kenntnis des Erweiterungstyps. Verhalten und Zustand des Handles bleiben in der jeweiligen Erweiterung. + Die Factory wird bei **jeder Ausführung** aufgerufen und ihre Module werden vor dem Python-Code registriert. Damit funktionieren auch dynamische Imports wie `importlib.import_module('clock')`. Der Worker wartet vor Abschluss auf ausstehende Nachrichten an die Browser-Handler. Ein Handler-Fehler wird als Ausführungsfehler im Editor angezeigt. Bereits zugestellte Änderungen werden nicht zurückgerollt. Schlagen sowohl die Ausführung als auch deren Abschluss fehl, erscheint der ursprüngliche Ausführungsfehler zuerst. Zusätzliche Fehler beim Zustellen von Nachrichten oder beim Aufräumen werden darunter angezeigt. Die weiteren Aufräumschritte werden auch nach einem Fehler ausgeführt. diff --git a/packages/tdev/pyodide-code/workers/webio.py b/packages/tdev/pyodide-code/workers/webio.py index 730410e0b..1d4b6aa24 100644 --- a/packages/tdev/pyodide-code/workers/webio.py +++ b/packages/tdev/pyodide-code/workers/webio.py @@ -24,9 +24,9 @@ def register(handler): return register -def on_click(cookie): - """Register a handler accepting one click event from this cookie.""" - return on_event('click', source=cookie.events) +def on_click(target): + """Register a click handler for any extension handle exposing an events source.""" + return on_event('click', source=target.events) def stop(): From 995633f4f58c980b69c047c79ba308dbcebeca08 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Thu, 29 Oct 2026 07:06:40 +0100 Subject: [PATCH 03/32] feat: add keyboard event handling for cookie component and update protocol --- packages/tdev/pyodide-code/README.mdx | 12 ++++++++++- packages/tdev/pyodide-code/workers/webio.py | 21 +++++++++++++++++++ .../components/CookieComponent/index.tsx | 8 +++++++ tdev-website/website/models/Cookie.ts | 7 ++++++- .../pyodide/cookie-klicker/protocol.ts | 7 +++++++ 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/tdev/pyodide-code/README.mdx b/packages/tdev/pyodide-code/README.mdx index 8464dfa98..c614c5b55 100644 --- a/packages/tdev/pyodide-code/README.mdx +++ b/packages/tdev/pyodide-code/README.mdx @@ -165,7 +165,7 @@ Der Store verwaltet für jede `cookieId` ein eigenes MobX-Modell. Mehrere Cookie ```py live_pyo id=62007a34-9647-4c62-83bd-7a0f829e502a title=Cookie_Klicker from random import randint from cookie_klicker import use_cookie -from webio import on_click, run +from webio import on_click, on_key, run cookie = use_cookie('cookie-demo') zweiter_cookie = use_cookie('cookie-zwei') @@ -178,12 +178,22 @@ def drehen(event): def schrittweise_drehen(event): zweiter_cookie.set_rotation(zweiter_cookie.rotation + 30) +@on_key(zweiter_cookie, 'ArrowLeft') +def nach_links_drehen(event): + zweiter_cookie.set_rotation(zweiter_cookie.rotation - 5) + +@on_key(zweiter_cookie, 'ArrowRight') +def nach_rechts_drehen(event): + zweiter_cookie.set_rotation(zweiter_cookie.rotation + 5) + await run() ``` Der Cookie wird anklickbar, sobald das Programm seine Ereignisquellen angemeldet hat. Eine Ziel-ID kann gleichzeitig nur von einem laufenden Programm für Ereignisse verwendet werden. Ein zweiter Controller erhält eine Fehlermeldung. Nach Ende oder Abbruch werden die Zuordnungen entfernt; alte Klicks werden nicht an einen Neustart weitergegeben. +`@on_key(target)` registriert einen generischen Handler für alle Tasten. Dasselbe gilt mit `None` oder `'*'` als zweitem Parameter. Ein anderer Tastenname filtert die Ereignisse, zum Beispiel `@on_key(target, 'ArrowLeft')`. Der Cookie sendet bei `keydown` den Tastennamen als `event.payload['key']`. Tastatureingaben gehen nur an den fokussierten Cookie: Wähle den zweiten Cookie mit Tab oder einem Klick aus. Die Pfeiltasten Links und Rechts drehen ihn jeweils um 5°; dabei wird das Scrollen der Seite verhindert. Enter und die Leertaste behalten ihre normale Button-Funktion. + `use_cookie(id)` liefert innerhalb derselben Ausführung denselben Handle zurück. Seine lokale `rotation` beginnt bei null; `set_rotation()` normalisiert den Winkel auf `[0, 360)` und aktualisiert das Browser-Modell. Ein neuer Handle verändert die bisherige Anzeige erst mit seinem ersten Update. Handler laufen nacheinander. Für absichtliche Wartezeiten kann ein Handler `async def` verwenden und `await asyncio.sleep(0.1)` aufrufen. Währenddessen können weitere Klicks empfangen werden; ihre Handler starten nach Abschluss des aktuellen Handlers. `time.sleep()`, synchrone Eingaben mit `input()` und lange Berechnungen blockieren dagegen den Empfang im Worker. diff --git a/packages/tdev/pyodide-code/workers/webio.py b/packages/tdev/pyodide-code/workers/webio.py index 1d4b6aa24..5ea48b694 100644 --- a/packages/tdev/pyodide-code/workers/webio.py +++ b/packages/tdev/pyodide-code/workers/webio.py @@ -29,6 +29,27 @@ def on_click(target): return on_event('click', source=target.events) +def on_key(target, key=None): + """Register for every key with None or '*', or only the specified key.""" + if key is None or key == '*': + return on_event('key', source=target.events) + if not isinstance(key, str) or not key: + raise ValueError('A key filter must be a non-empty string.') + + def register(handler): + if not callable(handler): + raise TypeError('An event handler must be callable.') + + def filtered(event): + if event.payload.get('key') == key: + return handler(event) + + on_event('key', source=target.events)(filtered) + return handler + + return register + + def stop(): """Finish after the active handler, or wake an idle event receiver.""" global _stopping diff --git a/tdev-website/website/components/CookieComponent/index.tsx b/tdev-website/website/components/CookieComponent/index.tsx index 704762dc5..a85e96d5c 100644 --- a/tdev-website/website/components/CookieComponent/index.tsx +++ b/tdev-website/website/components/CookieComponent/index.tsx @@ -1,6 +1,7 @@ import { useStore } from '@tdev-hooks/useStore'; import clsx from 'clsx'; import { observer } from 'mobx-react-lite'; +import type { KeyboardEvent } from 'react'; import styles from './styles.module.scss'; interface CookieComponentProps { @@ -21,6 +22,12 @@ const CHOCOLATE_CHIPS = [ const CookieComponent = observer(({ cookieId, className }: CookieComponentProps) => { const siteStore = useStore('siteStore'); const cookie = siteStore.cookieStore.useCookie(cookieId); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault(); + } + cookie.handleKey(event.key); + }; return (
@@ -30,6 +37,7 @@ const CookieComponent = observer(({ cookieId, className }: CookieComponentProps) aria-label={`Cookie ${cookieId} anklicken`} disabled={!cookie.isConnected} onClick={cookie.handleClick} + onKeyDown={handleKeyDown} >
{ - const { clockId } = props; +const CARD_CLASS_NAMES = { body: clsx(styles.clockCard), card: clsx(styles.card) }; +const CLOCK_TICKS = Array.from({ length: 60 }, (_, index) => ( +
+)); + +const Clock = observer(({ clockId }: ClockProps) => { const clockStore = useExtensionStore(clockExtension); const clock = clockStore.useClock(clockId); + const header = useMemo( + () => ( +

+ Uhr: +

+ ), + [clockId] + ); return ( - - Uhr: - - } - > +
- {[...Array(60).keys()].map((i) => ( -
- ))} + {CLOCK_TICKS} diff --git a/tdev-website/website/pyodide/cookie-klicker/Component/index.tsx b/tdev-website/website/pyodide/cookie-klicker/Component/index.tsx index 5ab3b5876..bdb936a39 100644 --- a/tdev-website/website/pyodide/cookie-klicker/Component/index.tsx +++ b/tdev-website/website/pyodide/cookie-klicker/Component/index.tsx @@ -1,8 +1,8 @@ import { useExtensionStore } from '@tdev/pyodide-code/hooks/useExtensionStore'; -import cookieExtension from '../client'; import clsx from 'clsx'; import { observer } from 'mobx-react-lite'; import type { KeyboardEvent } from 'react'; +import cookieExtension from '../client'; import styles from './styles.module.scss'; interface CookieComponentProps { diff --git a/tdev-website/website/pyodide/cookie-klicker/worker.ts b/tdev-website/website/pyodide/cookie-klicker/worker.ts index 0f5e3425d..c88aeaf85 100644 --- a/tdev-website/website/pyodide/cookie-klicker/worker.ts +++ b/tdev-website/website/pyodide/cookie-klicker/worker.ts @@ -1,46 +1,48 @@ -import { defineWorkerExtension } from '@tdev/pyodide-code/extensions'; +import { defineWorkerExtension, type WorkerContext } from '@tdev/pyodide-code/extensions'; import { DEFAULT_COOKIE_COLOR, type CookieUpdate } from './protocol'; export default defineWorkerExtension((context) => { - const createCookie = (id: string) => { - const events = context.events.source(id); - let rotation = 0; - let color = DEFAULT_COOKIE_COLOR; - return { - events, - get rotation() { - return rotation; - }, - get color() { - return color; - }, - set_rotation: (value: number) => { - if (!Number.isFinite(value)) { - throw new Error('Cookie rotation must be a finite number.'); - } - rotation = ((value % 360) + 360) % 360; - context.send(id, { rotation }); - }, - set_color: (value: string) => { - if (typeof value !== 'string' || !value.trim()) { - throw new Error('Cookie color must be a non-empty CSS color string.'); - } - color = value; - context.send(id, { color }); - } - }; - }; const cookies = new Map>(); return { cookie_klicker: { use_cookie: (id: string) => { - let cookie = cookies.get(id); - if (!cookie) { - cookie = createCookie(id); - cookies.set(id, cookie); + const existingCookie = cookies.get(id); + if (existingCookie) { + return existingCookie; } + const cookie = createCookie(context, id); + cookies.set(id, cookie); return cookie; } } }; }); + +const createCookie = (context: WorkerContext, id: string) => { + const events = context.events.source(id); + let rotation = 0; + let color = DEFAULT_COOKIE_COLOR; + return { + events, + get rotation() { + return rotation; + }, + get color() { + return color; + }, + set_rotation: (value: number) => { + if (!Number.isFinite(value)) { + throw new Error('Cookie rotation must be a finite number.'); + } + rotation = ((value % 360) + 360) % 360; + context.send(id, { rotation }); + }, + set_color: (value: string) => { + if (typeof value !== 'string' || !value.trim()) { + throw new Error('Cookie color must be a non-empty CSS color string.'); + } + color = value; + context.send(id, { color }); + } + }; +}; From 73a42f4a7f710b55a1590ba172fdafc8028f9950 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Thu, 29 Oct 2026 13:25:32 +0100 Subject: [PATCH 19/32] feat: Add execution phase handling and UI updates for code execution state --- .../pyodide-code/components/Header/index.tsx | 2 +- packages/tdev/pyodide-code/messages.ts | 14 ++++++++++- .../tdev/pyodide-code/models/PyodideCode.ts | 15 ++++++++++++ .../tdev/pyodide-code/stores/PyodideStore.ts | 3 +++ .../pyodide-code/workers/pyodide.worker.ts | 1 + .../documents/CodeEditor/Actions/RunCode.tsx | 15 +++++++----- .../CodeEditor/Actions/styles.module.scss | 23 +++++++++++++++++++ 7 files changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/tdev/pyodide-code/components/Header/index.tsx b/packages/tdev/pyodide-code/components/Header/index.tsx index 8d5976af6..26339a421 100644 --- a/packages/tdev/pyodide-code/components/Header/index.tsx +++ b/packages/tdev/pyodide-code/components/Header/index.tsx @@ -20,7 +20,7 @@ const Header = observer((props: Props) => { return ( {!code.meta.slim && } - {code.canExecute && } + {code.canExecute && } {code.isExecuting &&