Skip to content

Releases: pykeko/Moorhen-PyKeko

pk-v0.3.9 — small overnight-hunt hardening

Choose a tag to compare

@hilgersmt hilgersmt released this 12 Jul 11:47

v0.3.9 — small overnight-hunt hardening

Three JS-only quality-of-life fixes accumulated overnight. Same WASM as v0.3.8.

moorhen_run_js MCP verb now returns the actual value

Previously moorhen_run_js({script: "return 42"}) returned {} at the MCP client because MoorhenControlApi.runJs discarded the script's return value (line 376: await api.exe(script); return {ok:true};) — the MCP wrapper then read r.kind / r.repr (both undefined) and JSON-stringified to {}, silently swallowing every successful result. Now pipes through the same summarizeForRepl helper evalJs uses; response is {ok, kind, repr} on success and {ok:false, error} on throw / syntax error.

Bare expressions like runJs("1+1") still return undefined — that's MoorhenScriptApi.exe's scoping contract (wrapped in an async arrow function; bare expressions in a function body don't return). Callers use return 1+1 for value form. moorhen_eval has an expression-first fallback for that convenience; runJs doesn't, by design.

evaluateSelection("") errors clearly instead of silent count=0

MoorhenControlApi.evaluateSelection("") (or with whitespace-only input) used to return {ok:true, count:0, cids:[]} — indistinguishable from a valid expression that legitimately matched nothing. A caller passing an unfilled template variable got zero atoms back with no signal that the input was bogus. Now:

{ok:false, error:"Selection: expression is empty. Pass a non-empty selection (e.g. 'chain A', 'resn 6ZN', or a saved-selection name)."}

distance warning identifies which side is empty

Previously distance: empty selection — user had to guess which of sel1/sel2 was the problem. Now says exactly:

distance: empty selection — sel1='//A/481/SG' matched 0 atoms; sel2='//A/481/CB' matched 1 atom

Small but load-bearing when a residue-typing mistake (e.g. 481 is ASN not CYS, so SG doesn't exist) causes the surprising empty result.

Also worth knowing (verified overnight, no fix needed)

  • .pykeko autosave restoration works end-to-end. Recovery toast fires 1 min after relaunch; File → Recover autosave menu lists candidates; autosave bytes decode correctly via the same protobuf path File → Open session uses.
  • Undo/redo cycles cleanly for Coot mol-level operations (delete → 236 atoms; undo → 276 restored; redo → 236 again).
  • Session vectorData (H-bonds, distances, etc.) round-trips correctly through the load path.
  • Symmetry perf at large radii (400 Å → 3.6 s, 126k trace segments) is by design of the fixed 3×3×3 unit-cell search envelope; not blocking normal use.

Reinstall via the dmg. No new WASM, no new preferences, no schema changes.

pk-v0.3.8 — distance persistence + parser diagnostics + mol-scoped selections

Choose a tag to compare

@hilgersmt hilgersmt released this 12 Jul 07:46

v0.3.8 — bundled fixes from the overnight hunt

Three real user-facing fixes landed since v0.3.7.

Distance measurements now persist end-to-end (closes #203)

v0.3.6 fixed the scripted distance foo, //A/45, //A/46 command — now v0.3.8 fills the noted gap: the interactive m-key click distance handler in mgWebGL.tsx:5719 also dispatches a persistent vector to Redux vectorsSlice (uniqueId prefix pykeko-distance-mclick-). Both scripted AND interactively-drawn distances now survive .pykeko save/reload + autosave/recover cycles alongside H-bonds and other overlays.

Bonus fix caught along the way: labelMode: "custom" isn't a valid VectorsLabelMode (allowed values: none | start | end | middle). vite's TS check tolerated it in the v0.3.6 cmdDistance code because env.dispatch(...) erases types structurally, but mgWebGL's direct import surfaced the mismatch. Both sites now use "middle" — labels render mid-line, as intended.

PyMOL selection parser: warn on silently-skipped chars

Same class as the / bug fixed in v0.3.5. Any char not in whitespace | digit | PUNCT | isIdentStart was silently swallowed by the tokenizer at MoorhenPymolSelectionParser.ts:196-197. Users typing common typos got accidental scope:

  • resi 100..200resi 100 (stray . and 200 gone)
  • chain A%chain A
  • chain A@Bchain A

Now tokenize() returns {toks, skipped} and parseSelection() collapses consecutive runs of the same skipped char into a single line: [pymol] parser: skipped unrecognized char(s) in "<src>" — '.' at pos 9, '%' x1 starting at pos 11. Behavior unchanged for callers; only new output is the diagnostic.

Mol-name-scoped PyMOL commands stop silently coloring the wrong molecules

Three related bugs in the mol-scoped selection path meant color red, 5L0E and chain A (with 5L0E and 1CRN both loaded) was coloring chain A on BOTH molecules instead of just 5L0E.

  1. Tokenizer lowercased digit-led idents: MoorhenPymolSelectionParser.ts:127 did .toLowerCase() on 5L0E5l0e, then scopeOf's fallback mol.name === name failed the case-sensitive compare against "5L0E", and the whole scope silently fell back to allMols. Now: preserves original case (digit-led idents are never keywords).
  2. scopeOf now case-insensitive: even with case preserved at tokenize time, users typing 1crn and polymer should still resolve. PyMOL convention is case-insensitive object matching.
  3. matchPred("object") returned false: MoorhenPymolFilter.ts:215-218 treated object names as always-false at the per-atom level, which killed runtime-evaluated intersections like 1CRN and polymer (per-atom and(object, polymer) short-circuited to false regardless of scope). Now returns true — object names are a SCOPE-level concept, so once we're inside a scoped mol they should match every atom.

Verified end-to-end on 5L0E + 1CRN both loaded:

  • color red, 5L0E and chain A → 5L0E only, cid //A
  • color blue, 1crn and polymer → 1CRN only, cid //A/1-46/*
  • color green, 1CRN and chain A → 1CRN only, cid //A
  • color yellow, 5l0e and resn 6ZN → 5L0E only, cid //A/911/*||//B/908/*

Multi-mol scope without prefix (color red, chain A) already worked correctly — applied per-mol — and continues to.

Same WASM

No coot rebuild. JS-only fixes. Reinstall via the dmg.

pk-v0.3.7 — autosave .pykeko sessions every 5 min

Choose a tag to compare

@hilgersmt hilgersmt released this 12 Jul 04:46

v0.3.7 — autosave .pykeko sessions

New feature. Never lose a session again. Every 5 minutes the app writes a lean protobuf-encoded snapshot to ~/Documents/PyKeko-autosave/<mol-name>-<ISO-timestamp>.pykeko. On the next launch, if a recent autosave exists, a non-modal toast surfaces it.

Requested + design-approved by @hilgersmt on 2026-07-12 evening.

How it works

  • Cadence: every 5 minutes.
  • Guard: skips if 0 molecules are loaded, if the state hash (proxied by protobuf byte length) hasn't changed since the last save, or if the MoorhenTimeCapsule ref isn't wired yet.
  • Write path: renderer calls fetchSession(false) (lean; no MTZ payloads) → encodes via moorhensession.Session.encode → hands bytes to __moorhenControl.autosave(bytes, molName). No native dialog.
  • Location: ~/Documents/PyKeko-autosave/. User-visible in Finder — grab any autosave and email it, back it up, drag it into another PyKeko launch.
  • Filename shape: <sanitised-mol-name>-<ISO-timestamp>.pykeko, e.g. 5L0E-2026-07-12T20_04_16.pykeko. Colon in the timestamp is swapped for _ so Finder can display it.
  • Retention: last 20 files per launch, older files pruned on each new write.
  • In-flight guard: concurrent ticks are dropped (never encodes twice in parallel).
  • Warmup: first tick fires 60 s after mount; interval fires every 5 min thereafter.
  • No-op in browser build: skips if __moorhenControl.autosave isn't exposed.

Recovery UX

  • Startup snackbar: if the newest autosave is < 24 hours old, a non-modal info toast appears in the corner: "Autosave from N min ago available — File → Recover autosave" (12 s auto-hide). Fires regardless of the on/off toggle so users who disabled autosave later can still recover from an older one.
  • File → Recover autosave… menu item (desktop build only): pulls the autosave list, iterates newest-first with a serial window.confirm per candidate showing name + age + size. On the first accept, calls autosaveLoad(path)Session.decodeloadSession (same code path Open session… uses).

Preference

Preferences → Backups → "Autosave session every 5 minutes (~/Documents/PyKeko-autosave/)" — enabled by default, toggle to disable. Turning it off stops the interval but leaves the recovery toast intact so old autosaves are still discoverable.

Under the hood

  • New main.js IPC handlers: pykeko:autosave, pykeko:autosave-list, pykeko:autosave-load. Path enforcement on load: must be inside the autosave dir.
  • Preload exposes __moorhenControl.autosave / .autosaveList / .autosaveLoad.
  • New React component MoorhenAutosaveManager mounts alongside MoorhenControlBridge in MainContainer.tsx. Two useEffects: one-shot recovery detection, and the gated interval.
  • New Redux field: backupSettings.enablePykekoAutosave. PreferencesList slot 52 with defaultValue: true.

Verified

  • 25 dialog-free writes → 20 files retained (retention cap works).
  • Real 5L0E load → 60 s warmup → 1,157,493 bytes written; [MoorhenAutosaveManager] wrote … log line confirms mount + successful tick.
  • Static-verified in the compiled bundle: Autosave from, Recover autosave, pykeko-recover-autosave all present.

Reinstall via the dmg to get autosave.

pk-v0.3.6 — distance save/reload + gemmi + atom-list normalisation

Choose a tag to compare

@hilgersmt hilgersmt released this 12 Jul 02:20

v0.3.6 — bundled fixes: distance-save + +-list gemmi normalisation

Two bug fixes that surfaced during the post-v0.3.5 hunt.

Distance measurements now survive .pykeko save/reload

The bug: a user carefully measures a covalent bond distance (via m-key + click or scripted distance command), saves the session, opens tomorrow → distance is gone. Every other overlay (H-bonds, salt bridges, disulfides, clashes) survived, because they dispatch to Redux vectorsSlice → session vectorData. Distance measurements bypassed Redux entirely and lived in gl.measuredAtoms — a WebGL-layer property the session serializer doesn't touch.

The fix: cmdDistance in the PyMOL translator now ALSO dispatches an addVectors action alongside the existing gl.measuredAtoms push. uniqueId prefix pykeko-distance-<name> so distances can be batch-removed distinct from other overlay categories. Vector shape mirrors the H-bond schema (dashedcylinder + labelMode: "custom" + yellow default colour). MoorhenScriptAPI.buildEnv() now exposes addVectors and removeVectorsMatchingIDString so the translator can dispatch.

Verified: 2 distance measurements → session vectorData.length === 2 after getSessionBlob; both survive a protobuf round-trip.

Not yet covered: the interactive m-key click handler in mgWebGL.tsx:5719 still writes only to gl.measuredAtoms. Interactive-drawn distances (as opposed to scripted distance ...) still lose on save. Follow-up.

gemmiAtomsForCid("//A/45/CA+CB") returned 0 atoms silently

The bug: gemmi's Selection parser accepts comma-separated atom lists (CA,CB) but not Moorhen's +-separated form (CA+CB). Coot's own parser handles both, so the mismatch showed up only when a caller used gemmiAtomsForCid for atom coordinates or metadata — most visibly, distance foo, //A/45/CA+CB, //A/50/CA returned "empty selection".

The fix: normalise the atom slot (segment 4) from + to , at the gemmi call site in gemmiAtomsForCid. Every caller works. Preserves the compound || separator; only touches atom-slot chars.

Verified: //A/481/CA+CB → 2 atoms; //A/475-490/CA+CB → 31; compound //A/481/CA+CB||//A/482/CA+CB → 4 atoms.

Nothing else changed

Same WASM as v0.3.5 (patched Moorhen matcher path, coot-side unchanged). JS-only bug fixes. Reinstall via the dmg.

PyKeko v0.3.15 — w add-water + distance label fixes

Choose a tag to compare

@hilgersmt hilgersmt released this 12 Jul 23:35

v0.3.15 — two user-reported bugs fixed

1. w (add water at pointer) no longer vanishes

Pressing w placed a water at the view centre correctly, then auto-refined it (refine_residues_using_atom_cid, weight 4000). A lone, just-placed water has no geometry restraints, so the map term drags the O atom down the density gradient — ~71 Å from the pointer in live testing — landing off-screen and disconnected from the model. The atom was added (coot's atom count went up) but it vanished from view, which read as "w does nothing."

Fix: drop the auto-refine and place the water at the pointer, matching the "Add water at pointer" label. Refinement (sphere refine / the Refine tools) stays a separate, deliberate step. Placement no longer requires an active map. Verified: water lands 0.01 Å from the pointer and stays.

2. Distance measurements no longer show a doubled label

Both interactive m-click distances and the scripted distance command drew the number twice — the native measure system's 3-decimal, unit-less X.XXX and the persistent X.XX Å label, overlapping at the pair midpoint (the smaller text underneath the main label). The persistent vector is now the single renderer; the native duplicate is gone. Clearing measurements also removes the persistent distance vectors so they aren't orphaned. Verified: single label, no duplicate, clear removes it.


Renderer commit: a92e0086 · Full changelog: CLAUDE.md

PyKeko v0.3.14 — rotation feel matches Coot/PyMOL

Choose a tag to compare

@hilgersmt hilgersmt released this 12 Jul 22:19

v0.3.14 — rotation feel matches Coot/PyMOL

Three coordinated fixes to make the viewport feel like Coot / PyMOL out of the box. User-verified visually before shipping.

1. Perspective projection is now ON by default (was OFF)

The Preferences default has always claimed `doPerspectiveProjection: true`, but the mgWebGL constructor initialised the local flag to `false`. That overrode the Redux hydration path on fresh launch and left the renderer stuck in orthographic — which is why the toggle in Preferences appeared to "not work" for many users.

2. Field of view narrowed to ~20° (was ~57°)

`mat4.perspective` FOV was set to 1.0 radian (~57°). PyMOL's default `field_of_view` is 20°; Coot uses a similarly narrow FOV. The wide 57° made edge atoms feel warped during rotation.

3. Horizontal mouse-drag direction flipped to Coot convention

The line-for-line PyMOL trackball port had rightward drag rotating the right side AWAY from the viewer. Coot's convention (and most users' muscle memory) is the opposite: rightward drag brings the right side TOWARD the viewer. Vertical drag is unchanged.

Not touched

  • Mouse sensitivity slider still works the same.
  • `rotationStyle` preference (`trackball` vs `gimbal`) still switches models.
  • The trackball math itself is unchanged apart from the horizontal axis flip.

Reinstall via the dmg. No new WASM, no new preferences, no schema changes.

PyKeko v0.3.13 — m-click distance pairing

Choose a tag to compare

@hilgersmt hilgersmt released this 12 Jul 14:28

v0.3.13 — m-click distances are atom-pair, not a chain

Reported this morning: with the m-click distance handler shipped in v0.3.8, clicks 3, 4, 5 kept drawing distances between consecutive clicks (2-3, 3-4, 4-5) instead of grouping into pairs.

Now: click A, click B → distance A-B drawn. Click C → nothing drawn yet (C is the start of a new pair). Click D → distance C-D drawn. Etc.

Matches the pattern the label-click branch immediately above the measure branch has always used. Fix is a one-liner: cap the current click-group at 2 atoms and open a fresh group for the next click.

Reinstall via the dmg. No new WASM, no new preferences, no schema changes.

PyKeko v0.3.12 — loadCoords / loadMap validation

Choose a tag to compare

@hilgersmt hilgersmt released this 12 Jul 14:17

v0.3.12 — loadCoords / loadMap input validation + 0-atom guard

Same class of fix as v0.3.10 + v0.3.11, applied to the coordinate and map load entry points. Same WASM.

Fixes

  • `loadCoordsFromString(null/number/'')` leaked deep iterator / `isDeleted` errors from Coot's parse path. Now: clean boundary error.
  • `loadCoordsFromString('random garbage')` used to return `{molNo, atomCount:0}` — a lying success shape that also left a zombie zero-atom molecule in the state. Now: molecule is deleted and a clear error thrown: `file 'garbage' parsed to 0 atoms — is the input a valid PDB / mmCIF?`
  • `loadCoordsFromURL(null)` used to resolve `fetch('null')` as a relative URL, load nothing, and report success with 0 atoms. Now: boundary error.
  • `loadMapFromMtz(null/'')` used to throw the meaningless string `'undefined'`. Now: clear message.
  • `loadMapFromMtz('not-b64')` used to leak atob's DOMException. Now: `invalid base64 — …`.

The 0-atom guard is the load-bearing one — MCP callers and scripts that pipe files in from disk no longer end up with silent parse failures manifesting as "why is my molecule invisible?"

Reinstall via the dmg. No new WASM, no new preferences, no schema changes.

PyKeko v0.3.11 — saved-selection API validation

Choose a tag to compare

@hilgersmt hilgersmt released this 12 Jul 13:42

v0.3.11 — saved-selection API validation

Same class of fix as v0.3.10, applied to the other API cluster (saved selections). Same WASM.

Four leaks patched

Overnight hunt (Cycle 19) found the saved-selection endpoints leaked internal error strings and silently accepted junk:

  • setSelection('foo', null) used to leak "Cannot read properties of null (reading 'trim')" — the parser's own error escaped through the try/catch. Now: "Expression must be a string, got null."
  • setSelection('foo', 42) leaked "_.trim is not a function". Now: "Expression must be a string, got number."
  • setSelection('bar', '') returned {ok:true} and silently persisted an empty selection that matches nothing. Now: "Expression is empty. Pass a selection like 'chain A', 'resn 6ZN', or a saved-selection name."
  • deleteSelection(null) and deleteSelection('') returned {ok:true} even though no entry was addressed. Now: rejected with a clear error; and successful calls report {existed: true|false} so callers can tell "removed" from "wasn't there."

Reinstall via the dmg. No new WASM, no new preferences, no schema changes.

PyKeko v0.3.10 — control API input validation

Choose a tag to compare

@hilgersmt hilgersmt released this 12 Jul 13:10

v0.3.10 — control API input validation hardening

Small JS-only follow-up to v0.3.9. Same WASM.

The nine entry points now reject bad input at the JS boundary

Overnight hunt (Cycle 18) discovered that several MoorhenControlApi methods either silently no-oped or actually hung the cootCommand queue when called with mistyped arguments:

  • deleteCid('', 0) and deleteCid(42, 0) never returned — the empty/numeric CID was forwarded to delete_using_cid where it sat waiting forever.
  • deleteCid(null, 0) returned {deleted: null, molNo: 0} — a lying success shape.
  • goToResidue('') returned {centeredOn: '', molNo: 0} — same lie.
  • deleteCid(0, cid) with reversed args returned {} silently.
  • setActiveMap('not a number'), runPymol(42), runJs(42) all returned {} silently.

Now each throws (or, for runJs, returns {ok:false, error:…}) with a concrete message:

Error: cid: expected non-empty CID string (e.g. '//A', '//A/10', '/*/A/*/*')
Error: cid: expected string, got number
Error: setActiveMap: expected numeric mapMolNo, got string

Affected methods: deleteCid, goToResidue, refine, autoFitRotamer, flipPeptide, addTerminalResidue, runPymol, runJs, setActiveMap.

This matters mostly for MCP callers and PyMOL scripting — the error is now catchable and legible instead of manifesting as "why did nothing happen?" or "why did my session freeze?".

Also bundles the v0.3.9-tail showInteractions({types:['nonesuch']}) fix that was committed but never packaged.

Reinstall via the dmg. No new WASM, no new preferences, no schema changes.