From b7f9888516a4ab8c408b5d89d9d5f4046f891ea8 Mon Sep 17 00:00:00 2001 From: peachismomo Date: Sat, 17 Oct 2026 15:09:21 +0800 Subject: [PATCH] fix: update demo plugins --- sandboxes/orca_preset_transaction_demo.py | 325 ++----- sandboxes/orca_preset_update_demo.py | 1083 +++------------------ 2 files changed, 219 insertions(+), 1189 deletions(-) diff --git a/sandboxes/orca_preset_transaction_demo.py b/sandboxes/orca_preset_transaction_demo.py index 8882225310..5cdfd80197 100644 --- a/sandboxes/orca_preset_transaction_demo.py +++ b/sandboxes/orca_preset_transaction_demo.py @@ -4,294 +4,93 @@ # [tool.orcaslicer.plugin] # id = "preset-transaction-demo" # name = "Preset Transaction Demo" -# description = "Manual test for the staged begin_update()/set()/commit() preset API." +# description = "Stages several settings on one preset with begin_update()/set()/commit() (demo)." # author = "OrcaSlicer contributors" -# version = "0.1.0" +# version = "0.2.0" # type = "script" # /// -"""Preset Transaction Demo — a manual test harness for the staged preset API. +"""Preset transaction demo: change several settings with one commit. -Install this file in the local ``orca_plugins`` directory, enable the plugin, then run -"Preset Transaction Demo" from the Plugins dialog. The window drives -``orca.host.preset..begin_update(...)`` and shows the returned -``PresetUpdateResult``. +Enable the plugin, then run "Preset Transaction Demo" from the Plugins dialog. +Edit CATEGORY, PRESET_NAME, FILAMENT_SLOT and UPDATES below to try other presets +and settings. -This is an asynchronous Script capability: ``execute`` is ``async def`` and runs on the -background Script worker's own asyncio event loop. ``begin_update()`` is awaitable — it -suspends the coroutine until the normal preset-selection / unsaved-edit dialog is answered, -without blocking Orca's UI thread. ``set()`` and ``commit()`` remain synchronous and are -only valid on that worker. +``begin_update()`` resolves the target through Orca's normal preset-selection +flow, so it may open Orca's usual "unsaved changes" dialog. The call is +synchronous: the background Script worker waits until the user answers, while +the UI thread stays responsive. ``set()`` only stages values; ``commit()`` +validates and applies the whole batch. The edited preset stays unsaved (dirty) +until the user saves it through the normal preset workflow; nothing here writes +to disk. -The window's own messages arrive on the UI thread, so ``on_message`` cannot await directly. -Instead the handler enqueues each message onto the worker loop (``call_soon_threadsafe``) and -``execute`` consumes them, one coroutine at a time, so every command can ``await -begin_update``. Closing the window enqueues a sentinel that ends ``execute``. - -Categories - * process / printer: target is the active preset, or the name typed into the target - field, resolved through the normal preset-selection flow; - * filament: target is the 1-based slot number, with an optional preset name that - selects (and assigns) that preset for the slot. - -Buttons exercise the documented outcomes: - * "Commit" stages every key/value row and commits the batch; - * "Duplicate key" stages the same key twice and shows the rejected conflict; - * "Invalid key" stages a key that belongs to no preset and shows the commit error. - -Nothing here saves to disk: an applied change leaves the edited preset dirty until the -user saves it through Orca's normal preset workflow. Values use Orca's serialized config -syntax (numbers plain, percentages with ``%``, booleans ``1``/``0``, lists ``;``-separated). +Values are Orca's serialized config strings: numbers plain, percentages with +"%", booleans "1"/"0", choices by their internal key, lists ";"-separated. """ -import asyncio - import orca -CATEGORIES = ("process", "printer", "filament") +# --------------------------------------------------------------------------- +# Edit these. CATEGORY and UPDATES belong together: each presets category only +# accepts its own setting keys. +# --------------------------------------------------------------------------- -PAGE = """ - - - -
Target -
- - -
- -
- - -
-
Edited now: ?
-
+# Preset category: "process", "printer", or "filament". +CATEGORY = "process" -
Staged settings (serialized values) -
= -
-
= -
-
+# Target preset name for process/printer. "" keeps the currently active preset. +# For filament this optionally selects and assigns the preset for FILAMENT_SLOT; +# "" keeps the preset the slot already has. +PRESET_NAME = "" -
- - - - -
-

Ready.

+# 1-based filament slot. Only used when CATEGORY is "filament". +FILAMENT_SLOT = 1 - -""" +# Settings staged in order, then committed together. +UPDATES = ( + ("layer_height", "0.25"), + ("wall_loops", "3"), +) -async def _begin(category, preset_name, slot): - """Create a transaction for one category, following the two begin_update arities. - - begin_update() is awaitable: it suspends until any selection / unsaved-edit dialog is - answered, without blocking Orca's UI thread. - """ - editor = getattr(orca.host.preset, category) - # The binding takes a plain string, so an empty field means "" (keep the current / slot target). - if category == "filament": - return await editor.begin_update(slot, preset_name) - return await editor.begin_update(preset_name) - - -def _result(payload, category): - return { - "category": category, - "target_name": payload.target_name, - "selection_changed": payload.selection_changed, - "slot_number": payload.slot_number, - "slot_assignment_changed": payload.slot_assignment_changed, - "shared_slots": list(payload.shared_slots), - "keys": list(payload.keys), - "dirty": payload.dirty, - } - - -def _current(bundle, category, slot): - if category == "filament": - presets = list(bundle.current_filament_presets()) - if 1 <= slot <= len(presets) and presets[slot - 1] is not None: - return "%s (slot %d)" % (presets[slot - 1].name, slot) - return "" % slot - if category == "printer": - return bundle.current_printer_preset().name - return bundle.current_process_preset().name +def _begin_update(): + """Bind CATEGORY's target and return a transaction to stage changes on.""" + if CATEGORY not in ("process", "printer", "filament"): + raise ValueError('CATEGORY must be "process", "printer", or "filament"') + if CATEGORY == "filament": + # Filament updates are addressed by their 1-based slot number. + return orca.host.preset.filament.begin_update(FILAMENT_SLOT, PRESET_NAME) + return getattr(orca.host.preset, CATEGORY).begin_update(PRESET_NAME) class PresetTransactionDemo(orca.script.ScriptPluginCapabilityBase): - window = None - category = "process" - slot = 1 - loop = None - def get_name(self): return "Preset transaction demo" - async def execute(self): - """Open the window and drive its messages, awaiting begin_update per command. - - Runs on the background Script worker's asyncio loop. The window's on_message fires on - the UI thread, so it only enqueues; execute consumes the queue here, where awaits are - legal, and keeps the loop alive until the window closes. - """ + def execute(self): + # A plain def runs on the background Script worker, so begin_update() may + # block here through Orca's selection / unsaved-edit prompts. try: - self.loop = asyncio.get_running_loop() - self._messages = asyncio.Queue() - self.category = "process" - self.slot = 1 - self.window = orca.host.ui.create_window( - title="Preset transaction demo", html=PAGE, width=760, height=520, - on_message=self._enqueue, on_close=self._on_close, + transaction = _begin_update() + for key, value in UPDATES: + transaction.set(key, value) + result = transaction.commit() + except Exception as error: + return orca.ExecutionResult.failure( + orca.PluginResult.RecoverableError, + "Preset transaction demo failed: %s" % error, ) - try: - while True: - message = await self._messages.get() - if message is None: # sentinel: the window closed - break - await self._handle(message) - finally: - self.window = None - return orca.ExecutionResult.success("Preset transaction demo closed.") - except Exception as error: - text = "Preset transaction demo failed: %s" % error - orca.host.ui.message(text, title="Preset transaction demo", buttons="ok", icon="error") - return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError, text) - # --- UI thread -> worker loop hand-off ------------------------------------------------ - def _enqueue(self, message): - """on_message: runs on the UI thread. Hand the message to the worker loop.""" - try: - self.loop.call_soon_threadsafe(self._messages.put_nowait, message) - except Exception: - # The loop is already gone (plugin unloading); drop the message. - pass - - def _on_close(self): - """on_close: runs on the UI thread. Wake execute so it returns and the loop closes.""" - try: - self.loop.call_soon_threadsafe(self._messages.put_nowait, None) - except Exception: - pass - - # --- worker loop ---------------------------------------------------------------------- - async def _handle(self, message): - message = message or {} - category = message.get("category", "process") - if category not in CATEGORIES: - self._post(error="Unknown category %r" % (category,)) - return - self.category = category - self.slot = message.get("slot", 1) if isinstance(message.get("slot"), int) else 1 - preset_name = message.get("preset_name") or "" - - try: - command = message.get("command") - if command == "refresh": - self._post() - return - - if command == "duplicate": - transaction = await _begin(category, preset_name, self.slot) - transaction.set("layer_height", "0.2") - transaction.set("layer_height", "0.25") - self._post(error="Duplicate key was not rejected (unexpected)") - return - - if command == "invalid": - transaction = await _begin(category, preset_name, self.slot) - transaction.set("this_is_not_a_preset_setting", "1") - transaction.commit() - self._post(error="Invalid key was not rejected (unexpected)") - return - - if command == "commit": - pairs = message.get("pairs") or [] - if not pairs: - self._post(error="Stage at least one key/value row.") - return - transaction = await _begin(category, preset_name, self.slot) - for entry in pairs: - key = entry.get("key") - if not isinstance(key, str) or not key: - raise RuntimeError("A setting key is required in every row") - value = entry.get("value") - transaction.set(key, value if isinstance(value, str) else str(value)) - result = transaction.commit() - self._post( - payload=_result(result, category), - message="Committed %d setting(s) to %s \"%s\"." - % (len(result.keys), category, result.target_name), - ) - return - - self._post(error="Unknown command %r" % (command,)) - except Exception as error: - self._post(error="%s: %s" % (type(error).__name__, error)) - - def _post(self, payload=None, message="", error=""): - payload = dict(payload or {}) - if message: - payload["message"] = message - if error: - payload["error"] = error - try: - payload["current"] = _current(orca.host.preset_bundle(), self.category, self.slot) - except Exception as state_error: - payload["current"] = "unavailable: %s" % state_error - if self.window is not None and self.window.is_open(): - self.window.post(payload) + return orca.ExecutionResult.success( + 'Committed %d setting(s) to the %s preset "%s"; %s.' + % ( + len(result.keys), + result.category, + result.target_name, + "it stays unsaved until you save it" + if result.dirty + else "it already matches its saved state", + ) + ) @orca.plugin diff --git a/sandboxes/orca_preset_update_demo.py b/sandboxes/orca_preset_update_demo.py index 3547ca3008..44c9923ec7 100644 --- a/sandboxes/orca_preset_update_demo.py +++ b/sandboxes/orca_preset_update_demo.py @@ -4,951 +4,182 @@ # [tool.orcaslicer.plugin] # id = "preset-update-demo" # name = "Preset Update Demo" -# description = "Edits any setting of the currently edited process, printer, or filament preset through the preset host API." +# description = "Browse and edit settings in process, printer, and filament presets." # author = "OrcaSlicer contributors" -# version = "0.2.0" +# version = "0.4.0" # type = "script" # /// -"""Preset Update Demo — edit any setting of the current preset from a plugin window. - -Install this file in the local ``orca_plugins`` directory, enable the plugin, then run -"Preset Update Demo" from the Plugins dialog. The window lists every setting of the -preset the matching settings tab is editing right now — process, printer, or filament — -with its current serialized value, and writes a value back through -``orca.host.preset..update(...)``. - -Process and printer each have one edited preset, taken from -``current_process_preset()`` / ``current_printer_preset()``. Filament presets are -addressed by slot instead: the window reads one preset per slot from -``current_filament_presets()`` (slot numbers are one-based, matching Orca's labels), -shows the selected slot's preset, and writes through -``orca.host.preset.filament.update(slot, key, value)``. - -Any assigned slot can be edited: the backend selects that slot's preset through Orca's -normal preset selection before it writes, so applying can switch the filament tab and Orca -may ask about unsaved changes first. Only an empty slot, whose mount resolves to no preset, -is unavailable — the window says so instead of offering an edit. A slot whose preset is not -the one the filament settings already have open gets an informational note that applying -will switch to it; it is never presented as a refusal. Two slots that mount the same preset -are one preset, so editing through either of them changes both; the window marks those -slots and says which they are. - -Every row is one ``config_keys()`` entry carrying ``config_value(key)``. - -What ``update()`` does, and what it does not: - - * it runs the same validation and UI update path as editing the setting by hand, so - the change lands in the preset the tab is editing and is never written to disk here - — the preset shows as modified in OrcaSlicer once it differs from the saved one; - * it accepts only settings of that preset category. A key that belongs to another - category, or a value the setting would reject, raises an error and writes nothing; - * it is refused when the selected printer's technology does not use that tab (the - print and filament tabs do nothing under SLA), so this demo covers the three FFF - categories only; - * a setting whose page has not been built yet still runs its owning settings group's - checks and callbacks; behavior that requires an actual widget (such as widget-specific - parsing or clamping) only runs when the field exists; - * on a filament slot it selects that slot's preset first, so Orca may prompt about - unsaved changes in the filament tab before the value is written. - -Values are OrcaSlicer's serialized config syntax — the same text a preset file stores — -so numbers are plain, percentages keep their ``%``, booleans are ``1``/``0``, choices use -the setting's internal key, and lists are semicolon-separated. -""" +"""Interactive preset editor. Snapshots are read on the UI thread; edits run on execute's worker.""" +import queue import orca -# The three preset categories the main tabs edit. Each key is both the value the page -# sends and the attribute name on orca.host.preset that carries the matching editor. -KINDS = ( - {"kind": "process", "label": "Process"}, - {"kind": "printer", "label": "Printer"}, - {"kind": "filament", "label": "Filament"}, -) -KIND_KEYS = {item["kind"] for item in KINDS} - - -def _editor(kind): - """The host.PresetEditor for a category (orca.host.preset.process / printer). - - Filament is not reached through this helper: it is addressed by slot, so it calls - orca.host.preset.filament.update(slot, key, value) directly. - """ - return getattr(orca.host.preset, kind) - - -def _current_preset(bundle, kind): - """The single preset the matching settings tab edits right now, as a host.Preset. - - Only the process and printer categories have one such preset; filament presets are - addressed by slot instead (see _slot_presets). - """ - if kind == "process": - return bundle.current_process_preset() - return bundle.current_printer_preset() - - -def _slot_presets(bundle): - """The filament preset mounted in each slot, in slot order; a slot may be None.""" - return list(bundle.current_filament_presets()) - - -def _preset_is_open(preset, edited): - """Whether this slot's preset is the one the filament settings already have open. - - A slot with an assigned preset can be edited either way: applying to another slot makes - the backend select that slot's preset first. This only distinguishes the slot that - switches nothing from the ones that will switch the filament tab. - """ - return preset is not None and preset.name == edited.name - - -def _filament_slots(presets, edited): - """One entry per mounted slot: its one-based number, preset name, and open flag. - - The number is the slot label Orca shows; the name is None for a slot that mounts no - preset, which is the only kind that cannot be edited. - """ - return [ - { - "slot": index + 1, - "name": None if preset is None else preset.name, - "open": _preset_is_open(preset, edited), - } - for index, preset in enumerate(presets) - ] - - -def _as_slot(value): - """A requested slot as an int, or None when it is not a whole number.""" - if isinstance(value, bool): - return None - if isinstance(value, int): - return value - if isinstance(value, float) and value.is_integer(): - return int(value) - return None - - -def _clamp_slot(slot, count): - """The slot to display: the requested one, kept inside the available range.""" - if count <= 0: - return 0 - number = _as_slot(slot) - if number is None or number < 1: - return 1 - return number if number <= count else count - - -def _parse_slot(slot, count): - """Validate a requested filament slot against the mounted slots (one-based).""" - if count == 0: - raise RuntimeError("No filament slots are mounted") - number = _as_slot(slot) - if number is None: - raise RuntimeError("A filament slot number is required") - if number < 1 or number > count: - raise RuntimeError("Filament slot %d is out of range (1-%d)" % (number, count)) - return number - - -def _rows(preset): - """Every setting of the preset, sorted by key, as [{k, v}]. - - config_value() returns None for a key the preset does not carry; the page shows - those as empty rather than "None". - """ - rows = [] - for key in sorted(preset.config_keys()): - value = preset.config_value(key) - rows.append({"k": key, "v": "" if value is None else str(value)}) - return rows - - -def _state(bundle, kind, slot, message="", error=""): - """The payload the page renders: shown preset, rows, slots, result line.""" - payload = {"command": "state", "kind": kind, "message": message, "error": error} - if kind == "filament": - presets = _slot_presets(bundle) - edited = bundle.filaments.edited_preset() - effective = _clamp_slot(slot, len(presets)) - preset = None if effective == 0 else presets[effective - 1] - payload["slots"] = _filament_slots(presets, edited) - payload["slot"] = effective - # An empty slot has nothing to edit; any assigned slot is editable, and one that is - # not already open makes the backend select it first. - payload["assigned"] = preset is not None - payload["switches"] = preset is not None and not _preset_is_open(preset, edited) - payload["edited_name"] = edited.name - else: - preset = _current_preset(bundle, kind) - payload["preset"] = None if preset is None else { - "name": preset.name, - "is_dirty": bool(preset.is_dirty), - "is_system": bool(preset.is_system), - } - payload["rows"] = [] if preset is None else _rows(preset) - return payload - - -def _apply(kind, key, value, slot=None): - """Set one setting of the preset the page is showing, through the host API. - - A filament preset is addressed by slot number; process and printer each resolve - their single edited preset themselves, so no slot is passed to them. - """ - if kind == "filament": - orca.host.preset.filament.update(slot, key, value) - return - _editor(kind).update(key, value) - - -PAGE = r""" - - - - - - -
- -

Preset update

-
- - -
- -
- Loading… - - - 0 settings -
- - - -
-
- -
-
- -
-
Select a setting
-
Current value
-
—
- - -
-
- - -
-
- Values use OrcaSlicer's serialized config syntax — the same text a preset - file stores. Numbers are plain (0.2, 3) and percentages keep - their sign (15%). On/off is 1 or 0. A choice uses - the setting's own key, e.g. grid. Lists are semicolon-separated - (0.4;0.4;0.4, or "#FF0000";"#00FF00"). Text keeps its - escapes, so a line break stays \n. A key that does not belong to this - preset, or a value the setting would reject, comes back as an error and writes - nothing. The edit lands in the preset the tab is editing and stays unsaved. -
-
-
- -
- +PAGE = r''' +
◈

Preset editor

+
+
Selected setting

Choose a setting

Current value
—
Choose a category, preset and filament slot. Edit any listed key, then apply. Orca may ask to switch presets or resolve unsaved changes. Values stay unsaved until saved in Orca.
Loading presets…
- -""" +const $=id=>document.getElementById(id),S={kind:'process',slot:1,preset:'',rows:[],selected:null,busy:true,notice:'',noticeError:false}; +function send(command,extra={}){$('status').textContent='Working…';S.busy=true;controls();orca.postMessage({command,kind:S.kind,slot:S.slot,preset:$('preset').value,...extra})} +function controls(){ $('kind').disabled=S.busy;$('slot').disabled=S.busy;$('preset').disabled=S.busy;$('apply').disabled=S.busy||!S.selected;$('reload').disabled=S.busy } +function load(){send('state')} +function paint(m){const notice=S.notice,noticeError=S.noticeError;S.notice='';S.noticeError=false;S.busy=false;S.kind=m.kind;S.slot=m.slot||1;S.rows=m.rows||[];S.selected=S.rows.some(r=>r.k===S.selected)?S.selected:null; + $('slotlabel').hidden=S.kind!=='filament';$('slot').hidden=S.kind!=='filament';$('slot').innerHTML=(m.slots||[]).map(x=>``).join('');$('slot').value=S.slot; + $('preset').innerHTML=(m.presets||[]).map(x=>``).join('');$('preset').value=m.preset_name||'';$('dirty').textContent=m.dirty?'Modified':'Saved'; + const q=$('search').value.toLowerCase(),rows=S.rows.filter(r=>(r.k+' '+r.v).toLowerCase().includes(q));$('rows').innerHTML=rows.map(r=>``).join('')||'
No matching settings
'; + const r=S.rows.find(x=>x.k===S.selected);$('key').textContent=r?r.k:'Choose a setting';$('current').textContent=r?r.v:'—';$('value').disabled=!r;$('value').value=r?r.v:'';$('reset').disabled=!r;controls();$('status').textContent=m.error||notice||m.message||`${S.rows.length} settings · changes remain unsaved`;$('status').className='status'+((m.error||noticeError)?' error':'') } +function esc(x){return String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))} +$('kind').onchange=()=>{S.kind=$('kind').value;S.selected=null;$('preset').value='';load()};$('slot').onchange=()=>{S.slot=Number($('slot').value);S.selected=null;$('preset').value='';load()};$('preset').onchange=()=>{S.selected=null;load()};$('reload').onclick=load;$('close').onclick=()=>orca.close();$('search').oninput=()=>paint({kind:S.kind,slot:S.slot,rows:S.rows,presets:[...$('preset').options].map(o=>o.value),preset_name:$('preset').value}); +$('rows').onclick=e=>{let b=e.target.closest('[data-key]');if(!b)return;S.selected=b.dataset.key;let r=S.rows.find(x=>x.k===S.selected);$('key').textContent=r.k;$('current').textContent=r.v;$('value').value=r.v;$('value').disabled=false;$('reset').disabled=false;$('apply').disabled=false;document.querySelectorAll('.row').forEach(x=>x.classList.toggle('sel',x===b))};$('reset').onclick=()=>{let r=S.rows.find(x=>x.k===S.selected);if(r)$('value').value=r.v};$('apply').onclick=()=>send('apply',{key:S.selected,value:$('value').value});orca.onMessage(m=>{if(m.command==='state')paint(m);else if(m.command==='refresh'){S.notice=m.error||m.message||'';S.noticeError=!!m.error;load()}});load(); +''' class PresetUpdateDemo(orca.script.ScriptPluginCapabilityBase): window = None - kind = "process" # the category the open window is showing, for a re-run refresh - slot = 1 # the filament slot the open window is showing + _commands = None + _cancelled = False def get_name(self): - return "Preset update demo" + return "Preset editor demo" def execute(self): - try: - if self.window is not None and self.window.is_open(): - self._post_state(self.kind, self.slot) - return orca.ExecutionResult.success("Preset update demo refreshed.") - self.window = orca.host.ui.create_window( - title="Preset update demo", html=PAGE, width=820, height=560, - on_message=self.on_message, on_close=self.on_close, - ) - return orca.ExecutionResult.success("Preset update demo opened.") - except Exception as error: - message = "Preset update demo failed: %s" % error - orca.host.ui.message(message, title="Preset update demo", buttons="ok", icon="error") - return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError, message) - - def on_close(self): - self.window = None - - def _post_state(self, kind="process", slot=1, message="", error=""): - try: - payload = _state(orca.host.preset_bundle(), kind, slot, message, error) - except Exception as state_error: - payload = {"command": "state", "kind": kind, "slot": slot, "slots": [], - "assigned": False, "switches": False, "edited_name": None, - "preset": None, "rows": [], "message": message, - "error": str(state_error)} if self.window is not None and self.window.is_open(): - self.window.post(payload) - - def on_message(self, message): - kind = "process" - slot = 1 + return orca.ExecutionResult.success("Preset editor is already open.") + # The queue exists before the window so on_cancelled() can always unblock this worker, even + # when a forced unload destroys the window without firing on_close. + commands = queue.Queue() + self._commands = commands try: - message = message or {} - kind = message.get("kind", "process") - if not isinstance(kind, str) or kind not in KIND_KEYS: - raise RuntimeError("Unknown preset category %r" % (kind,)) - self.kind = kind - slot = message.get("slot", 1) - - command = message.get("command") - if command == "state": - self.slot = slot - self._post_state(kind, slot) - return - if command == "apply": - key = message.get("key") - value = message.get("value") - if not isinstance(key, str) or not key: - raise RuntimeError("A setting key is required") - if not isinstance(value, str): - raise RuntimeError("A value string is required") - where = "" - if kind == "filament": - presets = _slot_presets(orca.host.preset_bundle()) - # Validate the slot strictly: an out-of-range slot must fail loudly - # rather than edit a different filament. A slot with no preset has - # nothing to write, which is the only slot shape the backend refuses. - slot = _parse_slot(slot, len(presets)) - if presets[slot - 1] is None: - raise RuntimeError("Filament slot %d has no preset to edit" % slot) - _apply(kind, key, value, slot) - where = " on slot %d" % slot - else: - _apply(kind, key, value) - shown = value if value else "(empty)" - self.slot = slot - self._post_state( - kind, slot, - message='Set %s = "%s"%s. The edit stays in the preset tab until you save it.' - % (key, shown, where), - ) - return - raise RuntimeError("Unknown command %r" % (command,)) + if self._cancelled: + return orca.ExecutionResult.success("Preset editor closed.") + self.window = orca.host.ui.create_window( + title="Preset editor", html=PAGE, width=900, height=620, + on_message=self._on_message, + on_close=self._on_close, + ) + while True: + message = commands.get() + if self._cancelled or message.get("command") == "close": + return orca.ExecutionResult.success("Preset editor closed.") + try: + notice = {"command": "refresh", "message": self._apply(message)} + except Exception as error: + notice = {"command": "refresh", "error": str(error)} + if self.window is not None and self.window.is_open(): + self.window.post(notice) except Exception as error: - safe_kind = kind if isinstance(kind, str) and kind in KIND_KEYS else "process" - self._post_state(safe_kind, slot, error=str(error)) + return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError, + "Preset editor failed: %s" % error) + finally: + if self._commands is commands: + self._commands = None + self.window = None + + # Both UI callbacks run on the wx main thread with the GIL held. + def _on_message(self, message): + message = dict(message) if isinstance(message, dict) else {} + if message.get("command") == "apply": + self._enqueue(message) + return + try: + state = self._snapshot(message) + except Exception as error: + state = {"command": "state", "kind": message.get("kind", "process"), + "slot": message.get("slot", 1), "rows": [], "presets": [], "slots": [], + "preset_name": "", "dirty": False, "error": str(error)} + window = self.window + if window is not None and window.is_open(): + window.post(state) + + def _on_close(self): + self._enqueue({"command": "close"}) + + def on_cancelled(self): + # Called before the unload drain. Enqueueing a close lets a worker parked in get() exit; a + # callback that arrives after the worker already left sees no queue and is harmless. + self._cancelled = True + self._enqueue({"command": "close"}) + + def _enqueue(self, message): + commands = self._commands + if commands is not None: + commands.put(message) + + def _apply(self, message): + kind = message.get("kind", "process") + if kind not in ("process", "printer", "filament"): + raise ValueError("Unknown preset category") + name = message.get("preset") or "" + if not isinstance(name, str): + raise ValueError("Preset name must be a string") + key, value = message.get("key"), message.get("value") + if not isinstance(key, str) or not key or not isinstance(value, str): + raise ValueError("Setting key and serialized value required") + editor = getattr(orca.host.preset, kind) + if kind == "filament": + slot = int(message.get("slot", 1)) + if slot < 1: + raise ValueError("Filament slot numbers are 1-based") + transaction = editor.begin_update(slot, name) + else: + transaction = editor.begin_update(name) + transaction.set(key, value) + result = transaction.commit() + status = "Set %s on %s. Changes remain unsaved." % (key, result.target_name) + if result.selection_changed: + status += " Orca switched the active preset." + if kind == "filament" and len(result.shared_slots) > 1: + status += " Shared by filament slots: %s." % ", ".join(str(s) for s in result.shared_slots) + return status + + # Read-only snapshot. Runs on the UI thread so no live Preset or PresetBundle object is read + # from the Script worker, and never calls begin_update() or another blocking preset operation. + def _snapshot(self, message): + kind = message.get("kind", "process") + if kind not in ("process", "printer", "filament"): + raise ValueError("Unknown preset category") + try: + slot = int(message.get("slot", 1)) + except (TypeError, ValueError): + slot = 1 + requested = message.get("preset") or "" + if not isinstance(requested, str): + requested = "" + bundle = orca.host.preset_bundle() + slots = [] + if kind == "filament": + assigned_names = list(bundle.current_filament_preset_names()) + presets = list(bundle.current_filament_presets()) + slots = [{"slot": index + 1, + "name": presets[index].name if presets[index] is not None + else (assigned_names[index] or None)} + for index in range(len(presets))] + if not 1 <= slot <= len(presets): + slot = 1 + preset = bundle.filaments.find_preset(requested) if requested else None + if preset is None and presets: + preset = presets[slot - 1] + available = list(bundle.filaments.preset_names()) + else: + collection = bundle.prints if kind == "process" else bundle.printers + available = list(collection.preset_names()) + preset = collection.find_preset(requested) if requested else None + if preset is None: + preset = (bundle.current_process_preset() if kind == "process" + else bundle.current_printer_preset()) + rows = [] + if preset is not None: + for key in sorted(preset.config_keys()): + value = preset.config_value(key) + rows.append({"k": key, "v": "" if value is None else str(value)}) + return {"command": "state", "kind": kind, "slot": slot, "slots": slots, + "presets": available, "preset_name": preset.name if preset is not None else "", + "dirty": bool(preset.is_dirty) if preset is not None else False, "rows": rows} @orca.plugin