Input: mouse and keyboard
Map of PyMOL’s input path, from the Qt widget down toSceneDrag. Every claim is anchored to a
path:line in packages/engine/, which is unmodified upstream. Where an API does not exist
it is called out explicitly as NOT PRESENT.
Where the port stands. packages/viewport/src/input/ holds the whole surface:
mouse.ts (1:1 pointer forwarding + drag coalescing), keys.ts (the keymapping.py translation),
butmode.ts + modes.ts (the 57-action / 80-slot table, mirrored in TypeScript — see §17),
camera.ts (RPC-driven actions for a GL-free backend), coords.ts (the y-flip and dpr maths of
§2), coalescer.ts, shortcuts.ts, mouseConfig.ts. Resize negotiation is
packages/viewport/src/resize.ts; the mouse-mode UI is apps/web/src/features/mouse/; key
bindings are apps/web/src/features/keyboard/ and apps/web/src/features/shortcuts/.
0. Source inventory (what feeds this area)
1. The event pipeline
- All scene mouse handling is deferred.
CScene::click/drag/releaseonly push a lambda ontoOrthoDefer(packages/engine/layer1/Scene.cpp:4113,4118,4129,4146); the actualSceneClick/SceneDrag/SceneReleaserun later on the render/idle thread.when(aUtilGetSecondstimestamp) is captured at enqueue time, because all click/double-click timing uses it. PyMOL_Buttonmultiplexes keys and mouse on thestateargument (packages/engine/layer5/PyMOL.cpp:2896-2917):state == -1→PyMOL_Key(ascii),state == -2→PyMOL_Special(GLUT special code),state == 0→ mouse down,state == 1→ mouse up.- Modifier mask is
SHIFT=1, CTRL=2, ALT=4(packages/engine/layer1/Ortho.h:20-22), built on the Qt side byget_modifiers(packages/engine/modules/pmg_qt/keymapping.py:44-58); note Qt Meta (⌘ on macOS) is folded into the same bit as Control (keymapping.py:51-52). - Buttons are
left=0, middle=1, right=2, wheel-forward=3, wheel-backward=4 (pymol_gl_widget.py:45-49,packages/engine/layer0/os_gl_glut.h:21-22), plus syntheticSINGLE_LEFT=100, SINGLE_MIDDLE=101, SINGLE_RIGHT=102, DOUBLE_LEFT=200, DOUBLE_MIDDLE=201, DOUBLE_RIGHT=202(packages/engine/layer0/os_gl_glut.h:23-28).
2. Coordinate system: y-flip, framebuffer scale, reshape
packages/engine/modules/pmg_qt/pymol_gl_widget.py:170-176:
- Origin bottom-left.
y_pymol = (widget_height_in_CSS_px - y_css) * dpr. Note the flip is applied to the logical height before scaling — reproducing this exactly matters for sub-pixel/off-by-one on non-integer DPR. - Device pixels. x and y are multiplied by
fb_scale = devicePixelRatio(pymol_gl_widget.py:220). fb_scaleis also pushed into the engine as an integer setting:self.cmd.set('display_scale_factor', int(self.fb_scale))(pymol_gl_widget.py:222). That drives_gScaleFactor(packages/engine/layer1/Setting.cpp:2946-2951) and henceDIP2PIXEL()(packages/engine/layer0/PyMOLGlobals.h:28-29), which sizes every internal-GUI hit rectangle (e.g.cControlBoxSize DIP2PIXEL(17)inpackages/engine/layer1/Control.cpp:36,SceneScrollBarWidth DIP2PIXEL(13)inpackages/engine/layer1/Scene.h:47). Changing it triggersOrthoCommandIn(G, "viewport")(packages/engine/layer1/Setting.cpp:2951).- Reshape is
pymol.reshape(w, h, force)→_cmd._reshape→PyMOL_Reshape(packages/engine/layer5/PyMOL.cpp:2397-2405), which setsG->Option->winX/winYthenOrthoReshape.resizeGLmultiplies byfb_scalefor QOpenGLWidget only (pymol_gl_widget.py:209-214); for the legacy QGLWidget path the scaling is done inpaintGLviaglViewport(pymol_gl_widget.py:202-206). The engine’s window size is in device pixels.
{x: round(cssX * dpr), y: round((cssH - cssY) * dpr)} with
cssH = canvas.clientHeight (packages/viewport/src/input/coords.ts). On ResizeObserver /
devicePixelRatio change it sends reshape(wDevice, hDevice, force) and then
cmd.set('display_scale_factor', round(dpr)), in that order, mirroring updateFbScale +
resizeGL (packages/viewport/src/resize.ts, which also debounces: a window drag emits one
resize per frame and each costs an FBO re-storage on the engine thread). The engine has no window,
so the browser is the authority on size and the bridge is the authority on what it managed to
allocate; the handshake is last-write-wins.
Stereo x-wrap. OrthoButton rewrites x through get_wrap_x when WrapXFlag
(packages/engine/layer1/Ortho.cpp:2513-2521, helper at :204-231), and SceneClick/SceneDrag do the same with
get_stereo_x (packages/engine/layer1/SceneMouse.cpp:599-627) for side-by-side stereo modes. For the web client
(mono) this is inert but must not be broken: send raw coordinates and let the backend decide.
3. Ortho-level dispatch: blocks, grab, deferral
OrthoButton (packages/engine/layer1/Ortho.cpp:2493-2557):
- Wheel events are suppressed while a real button is held: if
buttonis scroll andI->ActiveButtonis 0..2 and different, return immediately (:2503-2510). - On
P_GLUT_DOWN:I->ActiveButton = button; target block =I->GrabbedByif set, elsefindBlock(x,y)(:2531-2540).findBlockiteratesBlocksin reverse and callsrecursiveFind(:2980-2990). - On
P_GLUT_UP: release is delivered toGrabbedByand toClickedIn(:2543-2556) — i.e. potentially twice. Faithful reimplementation should just forward the event and let the backend do this. - Blocks attached: Scene (
cOrthoScene,packages/engine/layer1/Scene.cpp:4243), and ascOrthoTool: ButMode (packages/engine/layer1/ButMode.cpp:575), Control (packages/engine/layer1/Control.cpp:862), Movie (packages/engine/layer1/Movie.cpp:56), Seq (packages/engine/layer1/Seq.cpp:710), Wizard (packages/engine/layer1/Wizard.cpp:835), Executive/object panel (packages/engine/layer3/Executive.cpp:16671), PopUp menus (packages/engine/layer4/PopUp.cpp:263), plus hidden Pop (packages/engine/layer1/Pop.cpp:71). OrthoGrab/OrthoUngrab(:1191,:1215) implement pointer capture.OrthoFakeDrag(:305-310) replays the last drag atLastX/LastY/LastModifiers— used for timing-driven pop-ups; it is triggered from Python viacmd._fake_drag(packages/engine/modules/pymol/cmd.py:171,packages/engine/layer4/Cmd.cpp:540).
_button; only the 3D canvas forwards. OrthoGrab semantics still apply
inside the canvas (box-select rubber band, scene-button drag), which is why the canvas takes
setPointerCapture on pointerdown and releases it on pointerup.
4. The button→action table (ButMode)
4.1 Table shape
CButMode::Mode[cButModeInputCount] with cButModeInputCount = 80
(packages/engine/layer1/ButMode.h:216). Slot layout (ButMode.h:118-214):
4.2 ButModeTranslate(G, button, mod) (packages/engine/layer1/ButMode.cpp:603-757)
- L/M/R → base 0/1/2, then
+3Shift,+6Ctrl,+9CtSh,+68Alt,+71AltShift,+74CtrlAlt,+77CtrlAltShift (:730-756). - Wheel → slot 12..15 by modifier, then re-mapped by direction (
:617-678):Slab→ScaleSlabExpand/Shrink;MovS→MoveSlabForward/Backward;MvSZ→MoveSlabAndZoomForward/Backward;IMSZ→ inverted;MovZ→ZoomForward/Backward;IMvZ→ inverted. Anything else returns-1(wheel does nothing). - Single/Double → base 16..21, then
+6Shift,+12Ctrl,+18CtSh,+24Alt,+30AltShift,+36CtrlAlt,+42CtrlAltShift (:678-726). ButModeCheckPossibleSingleClick(:583-601) returns true iff the corresponding single slot is bound (>= 0).
4.3 Action codes and their on-screen 5-char labels
Frompackages/engine/layer1/ButMode.h:23-113 and the labels in ButModeInit (packages/engine/layer1/ButMode.cpp:500-555), and
the Python names in packages/engine/modules/pymol/controlling.py:57-123:
Wheel-only pseudo-modes 101–108 (
ButMode.h:106-113) are produced by ButModeTranslate, never
stored.
5. cmd.button() bit packing
packages/engine/modules/pymol/controlling.py:799-868. Names are resolved through the abbreviation matcher
Shortcut (packages/engine/modules/pymol/shortcut.py:20-203), so cmd.button('l','shft','+Box') works.
cmd.button(button, modifier, action) is the
only supported write path. There is no getter — ButModeGet exists in C
(packages/engine/layer1/ButMode.h:225) and is NOT exposed to Python (grepped). The panel
therefore mirrors the Python-side mode_dict to render the matrix
(packages/viewport/src/input/modes.ts); no C++ accessor was added. See §17.
6. Mouse rings and mode cycling
ring_dict (controlling.py:127-164):
cmd.config_mouse(ring)setsbutton_mode=0, replaces the globalmouse_ring, then callscmd.mouse()(controlling.py:168-202).cmd.mouse(action)(controlling.py:609-686):'forward'/'backward'stepbutton_modemodulo ring length;'select_forward'/'select_backward'stepmouse_selection_modein 0..6 (:637-646); a bare mode name jumps directly; negativebutton_modeencodes a mode outside the ring as-1 - index_into(mode_name_list)(:657-660,:670). After applying, it setsbutton_mode_nameand callscmd.button()for every row, thenunpick()for non-editing modes ordeselect()for editing modes (:679-680), and finallyrefresh_wizard()(:685).cmd.edit_mode(active)(controlling.py:688-717) is the legacy toggle between*_viewingand*_editingfor the current family. Used by the Builder panel (packages/engine/modules/pmg_qt/builder.py:1341).- Display names (
controlling.py:206-217):3-Button Lights,3-Button Maestro,3-Button Viewing,3-Button Editing,3-Button Motions,2-Button Viewing,2-Btn. Selecting,2-Button Editing,2-Button Lights,1-Button Viewing. mode_name_listorder is load-bearing (controlling.py:219-232) — the comment says “okay to append new mode name, but don’t insert: order & position matter”, becausebutton_modepersists negatively by index.- The in-viewport ring cycler: clicking the ButMode block cycles modes; clicking its top two lines
cycles the selection level instead; right-click opens the
mouse_configmenu; Shift or right-button or scroll-backward reverses direction (packages/engine/layer1/ButMode.cpp:147-188). mouse_configmenu items (packages/engine/modules/pymol/menu.py:82-101): 3-Button Motions / 3-Button Editing / 3-Button Viewing / 3-Button Lights / 3-Button All Modes / (sep) / 2-Button Editing / 2-Button Viewing / 2-Button Lights.
7. Complete button × modifier → action matrices
Transcribed verbatim frompackages/engine/modules/pymol/controlling.py:234-548. w = wheel.
Rows not listed for a mode are unbound (-1) unless PyMOL’s default table filled them.
7.1 three_button_viewing (:320-348) — the default UX
Single: L=
+/-, M=cent, R=menu; Single L+alt=cent; Single L+ctrl=cent.
Double: L=menu, M=none, R=pkat.
7.2 three_button_editing (:349-377)
Single: L=
pkat, M=cent, R=menu; L+alt=cent; L+ctrl=cent.
Double: L=torf, M=drgm, R=pktb.
7.3 three_button_motions (:378-407)
Single: L=
pkat, M=cent, R=menu; L+alt=cent; L+ctrl=cent.
Double: L=menu then overwritten by torf (duplicate row at :398-399 — the later
cmd.button call wins; this is a latent bug worth preserving-or-fixing deliberately),
M=drgm, R=pktb.
7.4 three_button_lights (:235-262)
Single: L=
none, M=cent, R=menu; L+alt=cent. Double: all none.
Which light is edited is edit_light clamped 1..9 (packages/engine/layer1/SceneMouse.cpp:1973-1974, :1998-1999).
7.5 three_button_maestro (:291-319)
Single: L=
sele, M=cent, R=menu; L+shft=+/-; L+alt=cent.
Double: L=menu, M=none, R=pkat. (w,ctrl = none deliberately, “disable since ctrl-middle is irtz”.)
7.6 two_button_viewing (:408-435)
Single: L=
pkat, M=none, R=menu; L+alt=cent. Double: L=menu, M=none, R=cent.
7.7 two_button_selecting (:436-463)
Single: L=
+/-, R=menu; L+alt=cent. Double: L=menu (declared twice, :456-457),
M=none, R=cent.
7.8 two_button_editing (:464-491)
Single: L=
pkat, M=none, R=menu; L+alt=cent. Double: L=menu, M=none, R=cent.
7.9 two_button_lights (:263-290) — reachable only via cmd.mouse('two_button_lights')
Single: M=
none, R=menu, L+alt=cent. Double: L=menu, R=cent.
7.10 one_button_viewing (:492-534) — the only mode using alsh/ctal/ctas
Single L: none=
+/-, shft=none, ctrl=menu, ctsh=pkat, alt=cent, alsh/ctal/ctas=none.
Double: L=menu, M/R=none.
7.11 default (:535-547) — mirrors PyMOL_SetDefaultMouse (packages/engine/layer5/PyMOL.cpp:2979-3021)
L=rota, M=move, R=movz; R+shft=clip; M+ctsh=orig;
wheel none/shft/ctrl/ctsh = slab/movs/mvsz/movz; double-middle=none; single-middle=cent.
In C, every unfilled single/double slot is cButModeSimpleClick and every unfilled L/M/R+mod slot is
cButModePotentialClick (packages/engine/layer5/PyMOL.cpp:3011-3019), and FB_Scene results feedback is masked off
to suppress click spam (:3021).
8. Click / double-click / single-click semantics
All inpackages/engine/layer1/SceneMouse.cpp:
- Double-click (
SceneClickCheckDoubleClick,:146-170): withincDoubleTime = 0.35 s(:26), within 10 px in both axes, same button → button is promoted toP_GLUT_DOUBLE_*. Only attempted if the corresponding single slot is bound or no modifier is held (:151). - “Possible single click” (
:664-674): set whenButModeCheckPossibleSingleClickor no modifier; also forced on ifbutton_mode_namestarts with'1'(i.e.1-Button Viewing). - On release (
SceneRelease,:1141-1172): if the press→release gap exceeds0.25 s + ApproxRenderTime, the single click is cancelled; otherwise state becomes 2 with aSingleClickDelay = 0.15 s. If the matching double slot iscButModeNone, the delay is zeroed so the single click fires immediately (:1165-1170). - Deferred single-click dispatch (
packages/engine/layer1/Scene.cpp:2439-2450): inSceneIdle, oncenow - LastReleaseTime > SingleClickDelay,SceneDeferClickWhen(..., LastButton + P_GLUT_SINGLE_LEFT, ...)synthesises the single-click event. - Drag cancels single click: >0.15 s since press (
SceneDrag,:1222-1227) or >4 px total movement (:2028-2035). - Threshold for torsion picks:
cButModePkTorBndsetsI->Threshold = 3px so the first 3 px of movement do not drag (:909-913, consumed at:1461-1466and:1514-1519).
9. Drag semantics per action (SceneDrag, packages/engine/layer1/SceneMouse.cpp:1205-2036)
rota(RotXYZ) — virtual trackball,scale = 0.45 * min(W,H)(:1312-1315).virtual_trackball(default 1,packages/engine/layer1/SettingInfo.h:414) selects three formulations (:1774-1804):2= twist-aware relative-to-origin,1= classic centre-relative,0= pure delta. Rotation magnitude ismouse_scale * 2 * 180 * asin(|n1×n2|)/π, clamped bymouse_limit * |v1-v2| / scale(:1840-1850).mouse_scaledefault 1.3,mouse_limitdefault 100 (SettingInfo.h:296-297).rotz/irtz— in-plane twistomegaaboutaxis2(:1861-1865,:1886-1904);irtzuses(LastX - x)/2degrees about Z.move(TransXY) —v2 = (dx, dy) * SceneGetExactScreenVertexScale(origin)(:1725-1760), thenroving_origin/roving_detailfollow-ups.movz(TransZ) —factor = mouse_z_scale * dy/400 * max(5, -pos.z); sign flips unlesslegacy_mouse_zoom(:1905-1924). Defaults:mouse_z_scale1.0 (SettingInfo.h:719),legacy_mouse_zoom0 (:542).clip/clpn/clpf— near/far shifted bydx/10,dy/10(:1925-1957).rotl/movl/mvzl— mutate thelight/light2… setting vector by0.01 * d(:1958-2022).- Object/fragment/view drags (
roto/movo/mvoz/rotv/movv/mvvz/rotf/movf/mvfz/torf/mova/mvaz/pktb) route toEditorDrag,ObjectMoleculeMoveAtom,ObjectMoleculeMoveAtomLabel,ObjectSliceDrag,ObjectDistMoveLabel(:1499-1724). Label picks (cPickableLabel) are re-targeted to the atom for object/fragment/view modes (:945-962). - Gadget drag (color ramps etc.) is handled under
cButModePickAtom(:1319-1367) and again in the object branch (:1522-1564); it usesRenderContext::UnitWindowvsCamerascaling. - Movie/TTT capture:
movie_auto_storegrabs the object and setsReinterpolateFlag(:828-843,:964-980,:1603-1614,:1189-1201). SceneNoteMouseInteraction(:37-43) aborts view animation and optionally restarts the frame timer (mouse_restart_movie_delay, default 0,SettingInfo.h:499).
10. Wheel / scroll
- Qt:
wheelEvent→get_wheel_button→ 3 (up) or 4 (down), then a synthetic down+up pair is sent (pymol_gl_widget.py:190-196). get_wheel_delta(keymapping.py:100-122) ignores horizontal scroll unless Shift is held, in which case horizontal delta is used (emulating shift-wheel = horizontal).- Wheel actions are direction-resolved in
ButModeTranslate(§4.2) and executed inSceneClick(SceneMouse.cpp:717-802): slab scale ±0.2*mouse_wheel_scale, slab move ±0.1*mouse_wheel_scale, zoom ±0.1*mouse_wheel_scale * (front+back)/2.mouse_wheel_scaledefault 0.5 (SettingInfo.h:623). - Wheel over the ButMode block cycles mouse modes backwards (
ButMode.cpp:159-160).
11. Pinch-zoom gesture
pymol_gl_widget.py:122 grabs Qt.GestureType.PinchGesture; gestureEvent (:138-168):
- On
GestureStarted, snapshotpinch_start_z = cmd.get_view()[11]. RotationAngleChanged→cmd.turn('z', last - current).ScaleFactorChanged→z = pinch_start_z / totalScaleFactor;view[11] = z;view[15] -= delta; view[16] -= delta(front/back clip follow the origin);cmd.set_view(view). There is an explicit workaround fortotalScaleFactor == 1.0(QTBUG-48138).
set_view rather than
_button/_drag — a useful precedent for the browser (see §16).
12. Box (rubber-band) selection
- Entered from
cButModeRectAdd/RectSub/Rect/SeleAddBox/SeleSetBox/SeleSubBox→SceneLoopClick(SceneMouse.cpp:45-57), which seedsLoopRect, setsLoopFlag, callsOrthoSetLoopRect(G, true, rect)(packages/engine/layer1/Ortho.cpp:253-260) andOrthoGrab. - Drag updates
right/bottomonly (:59-66) — so the rect is drawn from the anchor. - Release normalises the rect, calls
ExecutiveSelectRect(G, rect, mode)(:68-91). ExecutiveSelectRect(packages/engine/layer3/Executive.cpp:7432-7586) runsSceneMultipick(packages/engine/layer1/ScenePicking.cpp:332) over the rect, creates_tmp_rect_sele, then set/add/subtract against the active selection using the currentsel_mode_kw, honouringlog_box_selections.- The rubber band itself is drawn by Ortho, so in the web port it becomes a React/CSS overlay —
but the result must still come from the backend
SceneMultipick.
13. Picking and selection semantics
13.1 Picking mechanism
SceneDoXYPick(G, x, y, click_side) (packages/engine/layer1/ScenePicking.cpp:17-38) does a GPU color-index
render pass and PyMOLReadPixels(...) (:149) to identify (object, atom index, bond index, state). pick32bit controls bit depth (:56); pick_shading forces flat shading (:234, :272).
pick_surface (default 0, SettingInfo.h:812) and pickable (default 1, :134) gate what can be
hit; cmd.mask()/unmask() (controlling.py:870-925) flip pickable per atom.
This is the single hardest constraint for the web port: picking is authoritative on the backend
and requires the backend’s own GL context. See §17.
13.2 Selection levels (mouse_selection_mode)
SceneGetSeleModeKeyword (packages/engine/layer1/Scene.cpp:504-510) maps the setting to a selection-expansion
keyword (Scene.cpp:460-468):
Labels at
packages/engine/layer1/ButMode.cpp:370-392; default 1 at SettingInfo.h:449. Cycled by
cmd.mouse('select_forward'/'select_backward') (controlling.py:637-646), which wraps 0..6.
When the current single-left action is pkat, the ButMode block shows
Picking Atoms (and Joints) instead and clicking it does not cycle the level
(ButMode.cpp:163-173, :363-366).
13.3 What each pick action does
SceneClickObject (SceneMouse.cpp:233-373) and SceneClickTransformObject (:375-480):
sele(SeleSet) /+/-(SeleToggle) — buildsel_mode_kw(obj\index)andSelectorCreateinto the active selection name fromExecutiveGetActiveSeleName; toggle uses the symmetric-difference expression at:99-101. Honoursauto_hide_selectionsandauto_show_selections(:131-134), thenWizardDoSelect(:135`).pk1(PickAtom1) — createspk1, activates the editor withpkresi=1, logscmd.edit("...",pkresi=1)(:404-429).pkat(PickAtom) — multi-atom editor pickingpk1..pk4viaEditorGetNextMultiatom; clicking an already-picked atom unpicks it (:430-471).pkbd/pktb—SceneClickPickBond(:490-549) createspk1+pk2from the bond, logscmd.edit("a","b"), and forpktbprepares a torsion drag.orig—ExecutiveOriginat the atom (:282-310);cent—ExecutiveCenter(:311-330).drgm/drgo— issuecmd.drag("bymol (...)")/cmd.drag("byobject (...)")(:264-281);cmd.dragis defined atpackages/engine/modules/pymol/editing.py:1020.menu—MenuActivate2Arg(..., "pick_sele", name, name)if the atom is in the active selection, else"pick_menu"(:382-403); with nothing picked,MenuActivate3fv(..., "main_menu", LastClickVertex)(:883-887). Menu contents live inpackages/engine/modules/pymol/menu.py:1682(main_menu),:1709(pick_sele).clik(SimpleClick) — no selection side-effects; callsPyMOL_SetClickReady(name, index, button, mod, x, ScreenHeight-(y+1), pos, state+1, bond)(:1044-1058) or with an empty name if nothing was hit (:586-589). Note the second y-flip here — the click callback reports y in top-left origin.- Nothing picked with
seleclears the active selection tonone; with+/-it disables it (SceneClickPickNothing,:558-597). Note the missingbreakat:573—SeleSetfalls through intoSeleToggle, so a miss both clears and disables. Preserve or fix deliberately.
13.4 Click-ready callback (for clik)
PyMOL_GetClickString (packages/engine/layer5/PyMOL.cpp:2624-2725) returns a newline-separated key=value blob:
type=none|object|object:molecule|object:cgo, object=, index= (1-based), bond=, rank=,
id=, segi=, chain=, resn=, resi=, name=, alt=, click= (one of
left|single_left|single_middle|single_right|double_left|double_middle|double_right),
mod_keys= (space-separated ctrl alt shift), x=, y=.
Exposed as pymol._cmd.get_click_string(_COb, reset) (packages/engine/layer4/Cmd.cpp:1420-1436, table entry
:6451). There is no cmd.get_click_string Python wrapper — I grepped packages/engine/modules/ and found
none. The bridge must call _cmd.get_click_string directly or a wrapper must be added.
13.5 Selection indicator (the pink dots)
Rendered byExecutiveRenderSelectionsFromTargets (packages/engine/layer3/Executive.cpp:8462-8567) as a point CGO.
Default colour (1.0, 0.2, 0.6) unless rec->sele_color is set (:8310-8313). Width comes from
ExecutiveGetAdjustedSelectionWidth (:8362-8380): selection_width_scale * |stick_radius| / SceneGetScreenVertexScale, clamped to [selection_width, selection_width_max]. Defaults:
selection_width 3.0, selection_width_max 10.0, selection_width_scale 2.0
(SettingInfo.h:164, 489, 490); selection_round_points 0 (:559),
selection_overlay 1.0 (:165), selection_visible_only 0 (:570, used at Executive.cpp:8466).
Multi-pass outline widths at Executive.cpp:8419-8450.
auto_indicate_flags creates the indicate selection (Executive.cpp:9442-9445, name at :128).
The indicator is geometry PyMOL already computes, so it crosses the wire as a point buffer
rather than being re-derived client-side from atom coordinates.
14. Non-scene mouse targets inside the viewport
- Movie control bar (
packages/engine/layer1/Control.cpp): 9 buttons,NButton = 9(:62), hit-testwhich_button(:243-255). Release actions (:288-385): 0 rewind, 1 back, 2 stop (also clearssculptingandrock), 3 play/pause (Ctrl → rewind+play), 4 forward, 5 ending (Ctrl → middle), 6 toggleseq_view(label “S”), 7 togglerock, 8full_screen(label “F”). Buttons 3/6/7 render in an “active” colour when engaged (:645-649). The left nub drags the internal GUI width (:257-286), and a double-click within 0.35 s collapses/ restores it (:448-469). - Scene buttons / scrollbar inside the scene block (
SceneMouse.cpp:179-223,:642-709,:1233-1303): left = activate scene with interpolation, middle = rapid browse (Ctrl disables animation), right = drag-to-reorder orscene_menupop-up; dragging reorders viacmd.scene_order([...]). - Wizard block (
packages/engine/layer1/Wizard.cpp:483+) — buttons and pop-ups. - Object/Executive panel, Sequence viewer, Movie panel — other areas’ docs; they all arrive
through the same
OrthoButtonpath today. - Spaceball / SDOF (
packages/engine/layer1/Control.cpp:83-216,packages/engine/layer4/Cmd.cpp:3665_sdof): 6-DOF queue,SDOF_NORMAL_MODE/SDOF_DRAG_MODE/SDOF_CLIP_MODEtoggled by device buttons 1/2. Out of scope for a browser client unless WebHID is used — recommend explicitly dropping. - Drag & drop of files onto the canvas (
pymol_gl_widget.py:256-270) — accepts URLs, local files go togui.load_dialog(url). Maps to HTML5 drag&drop + upload/path handoff.
15. Keyboard
15.1 Qt → PyMOL key codes (packages/engine/modules/pmg_qt/keymapping.py)
keyMap (:10-17): Escape→27, Tab→9, Backspace→8, Return/Enter→13, Delete→127.
specialMap (:19-41): Left→100, Up→101, Right→102, Down→103, PageUp→104, PageDown→105,
Home→106, End→107, Insert→108, F1..F12→1..12. These match
packages/engine/modules/pymol/internal.py:398-421 (special_key_codes) and packages/engine/layer0/os_gl_glut_pretend.h:14-21.
keyPressEventToPyMOLButtonArgs (:61-97) returns (k, state, 0, 0, mod):
- special key →
state = -2; - otherwise
state = -1,k = keyMap.get(key, -1), falling back toord(ev.text()); - if still -1 and Ctrl held →
k = key - 64(Qt key codes are uppercase ASCII, so Ctrl-A→1); - if Alt held →
k = key(the raw uppercase key code); - out-of-range (
k > 255 or k < 0) is dropped.
packages/engine/modules/pmg_qt/pymol_qt_gui.py:50-54), i.e. keyboard is global to the app,
not canvas-scoped; the GL widget only takes focus on click (pymol_gl_widget.py:111,
Qt.FocusPolicy.ClickFocus). Tab is intercepted by an event filter so it does completion instead of
focus-change (pymol_qt_gui.py:440-455).
15.2 C-side key routing
PyMOL_Key (packages/engine/layer5/PyMOL.cpp:2353-2359): try WizardDoKey (packages/engine/layer1/Wizard.cpp:328-342, calls
the wizard’s do_key(k,x,y,mod) in Python), else OrthoKey.
PyMOL_Special (:2361-2395): try WizardDoSpecial (Wizard.cpp:462-477); Up/Down always go to
OrthoSpecial (command-line history); Left/Right go there only if OrthoArrowsGrabbed
(i.e. there is text on the command line and text is visible — Ortho.cpp:403-407, :392-397);
otherwise it runs _special k,x,y,mod through the parser (:2385-2392).
OrthoKey (packages/engine/layer1/Ortho.cpp:841-1032) is the internal command line:
mod == 4(Alt) →cmd._alt(chr(k)), except'@'which is re-dispatched with no modifier (“option G produces ’@’ on some non-US keyboards”,:803-812).mod == 3(Ctrl+Shift) →cmd._ctsh(chr(k+64))(:854-855).- Printable (
k > 32 && k != 127) → inserted into the command line (add_normal_char,:821-838). 32space — if the line is empty:presentationon →cmd.scene('','next'), Shift →rewind;mplay; otherwisemtoggle, Shift →rewind;mplay(:860-878).127delete,8backspace — line editing (:879-903).1Ctrl-A → beginning of line if arrows grabbed, else_ctrl('A')(:911-916).5Ctrl-E → end of line, else_ctrl('E')(:905-910).4Ctrl-D → delete char / filename completion query (:917-936).9Tab →PCompletetab-completion (Ctrl-I with Ctrl held →_ctrl('I')) (:937-958).27Escape → inpresentationmode quits; else dismisses splash, else togglestext(Shift → togglesoverlay) (:959-978).13Enter → parse current line; if empty and a movie panel exists:mview toggle/ Shiftmview toggle_interp/ Ctrlmview toggle,freeze=1/ Ctrl+Shiftmview toggle_interp,object=same; inpresentationmodemtoggle(:979-1001).11Ctrl-K → truncate line (:1002-1013).22Ctrl-V →cmd.paste()if the line has text, else_ctrl('V')(:1014-1025).- default →
cmd._ctrl(chr(k+64))(:1026-1028).
OrthoSpecial (:322-389) implements Up/Down = history recall, Left/Right = cursor movement
within the internal command line.
15.3 Python key dispatch (packages/engine/modules/pymol/internal.py)
modifier_keys = ['', 'SHFT', 'CTRL', 'CTSH', 'ALT'] (:390-396) — indexed by the numeric modifier
mask, so a mask of 4 (Alt) yields 'ALT'. _special(k,x,y,m) (:447-480) converts the code to a
name, prefixes the modifier, tries _invoke_key, then falls back to scene names and
view names (including prefix auto-completion) before printing “No key mapping and no scene or
view for ‘%s’”. _ctrl/_alt/_ctsh (:488-511) invoke CTRL-x / ALT-X (upper-cased) /
CTSH-x. _cmmd (:500-507) dispatches macOS ⌘ bindings out of cmd.cmmd.
_invoke_key(key) (:426-445) looks up cmd.key_mappings[key]; the value is either a command
string (run via cmd.do) or a (fn, args, kwargs) triple.
cmd.set_key(key, fn|string, arg, kw) (controlling.py:719-797) — also usable as a decorator
(:766-770). Validation: modifier must be in internal.modifier_keys; multi-char names must be in
internal.special_key_names (lower-cased unless it starts with F); single letters require a
modifier and cannot use SHFT alone. Docstring lists the redefinable set as:
F1..F12, left, right, pgup, pgdn, home, insert, CTRL-A..CTRL-Z, ALT-0..ALT-9, ALT-A..ALT-Z.
15.4 Full default key binding table
Verbatim frompackages/engine/modules/pymol/shortcut_dict.py:10-136 (key → command, description):
Navigation / movie / scene
CTRL-letter
ALT-digit — fragment attach (all
editor.attach_fragment('pk1', …))
ALT-1 formamide 5,0 (amide N→C) · ALT-2 formamide 4,0 (amide C→N) · ALT-3 sulfone 3,1 ·
ALT-4 cyclobutane 4,0 · ALT-5 cyclopentane 5,0 · ALT-6 cyclohexane 7,0 ·
ALT-7 cycloheptane 8,0 · ALT-8 cyclopentadiene 5,0 · ALT-9 benzene 6,0 ·
ALT-0 formaldehyde 2,0. (shortcut_dict.py:50-59)
ALT-letter — amino-acid / fragment attach (shortcut_dict.py:60-82)
A ala · B ace · C cys · D asp · E glu · F phe · G gly · H his · I ile ·
J acetylene (fragment 2,0) · K lys · L leu · M met · N asn · P pro · Q gln · R arg ·
S ser · T thr · V val · W trp · Y tyr · Z nme. (No ALT-O, ALT-U, ALT-X.)
CTSH-letter — chemical editing (shortcut_dict.py:90-111)
A redo · B replace Br,1,1 · C replace C,4,4 · D remove_picked · E invert ·
F replace F,1,1 · G replace H,1,1 · I replace I,1,1 · J alter pk1,formal_charge=-1. ·
K alter pk1,formal_charge=1. · L replace Cl,1,1 · N replace N,4,3 · O replace O,4,2 ·
P replace P,4,1 · R h_fill · S replace S,4,2 · T bond;unpick ·
U alter pk1,formal_charge=0. · W cycle_valence · X cmd.auto_measure() ·
Y attach H,1,1 · Z undo.
Function keys (shortcut_dict.py:112-135)
For n = 1..12: CTRL-Fn → scene Fn, store, CTSH-Fn → scene SHFT-Fn, store.
Bare F1..F12 and SHFT-F1..SHFT-F12 are not in the table; they fall through
_special → scene/view name lookup (internal.py:466-478), which is exactly how the stored
scenes above are recalled.
Clipboard ring (packages/engine/modules/pymol/keyboard.py:38-84): editing_ring supports
copy / cut / paste / invert, using a hidden persistent object created with
cmd.create(..., extract=…) and restoring auto_hide_selections around it (:23-30).
15.5 Reserved / non-rebindable keys
ShortcutManager.reserved_keys = ('CTRL-S','CTRL-E','CTRL-O','CTRL-M','up','down')
(packages/engine/modules/pymol/shortcut_manager.py:21). Note CTRL-M is ASCII 13 (Enter) and CTRL-E/CTRL-A
are consumed by line editing when the command line has content (Ortho.cpp:905-916).
15.6 Shortcut editor dialog (packages/engine/modules/pmg_qt/shortcut_menu_gui.py)
Widgets to reproduce in React:
- Filter
QLineEditwith placeholder “Filter”, live regex filtering (:79-84). - Refresh
QPushButtonwithrefresh.svgicon, tooltip “Refresh the table to reflect any external changes” (:86-94). QTableViewwith 3 columns: Key, Command (click to edit), Description (:164-165). Key is read-only; editing a command callscmd.set_keyand marks the row “user defined” (:394-415). Deleted rows show"Deleted"(:174-177,:225-243).- Buttons: Create New (
:107-114), Delete Selected (:116-123), Reset Selected (:125-131), Reset All (:133-138), Save (:140-145). - Sub-dialogs loaded from
.uiforms:create_shortcut(fieldskeyEdit,commandEdit,createButton,helpButton),help_shortcut,change_confirm(confirmButton,cancelButton,doNotShowCheckBox) —:61-63,:277-286,:347-355. - Key capture:
eventFilteronkeyEditconverts a live key event to PyMOL notation (keyevent_to_string:300-314,process_keyevent_string:316-342), mapping Control/Meta→CTRL, Control+Shift→CTSH, Alt→ALT, Shift→SHFT, and renamingPageUp→pgup, PageDown→pgdn, Home→home, Insert→insert, Up→up, Down→down, Left→left, Right→right, End→end(:32-41). Reserved keys are silently rejected (:288-290). - Persistence:
~/.pymol/shortcuts_save.json(packages/engine/modules/pymol/save_shortcut.py:6,save_shortcuts:18-35,load_shortcuts_dict:37-54). Loaded at startup (pymol_qt_gui.py:418-419). - Reconciliation logic to port as-is:
ShortcutManager.check_saved_dict/check_key_mappings/reset_all_default/create_new_shortcut(shortcut_manager.py:23-139).
16. Precise browser-event mapping specification
16.1 Pointer events on the canvas
Use Pointer Events (not MouseEvent) so pen/touch unify, andsetPointerCapture for grab.
with
- Mouse tracking is always on in Qt (
setMouseTracking(True),pymol_gl_widget.py:108) — passive moves are delivered even with no button down.OrthoDragno-ops unless something is grabbed/clicked (Ortho.cpp:2588-2594), so sending everypointermoveis correct but wasteful. So passive moves are sent only between pointerdown and pointerup, coalesced to the latest position per budget window and flushed before any button event (packages/viewport/src/input/coalescer.ts). The coalescer runs off a clock rather thanrequestAnimationFrame, because rAF stops dead in a hidden or occluded tab and an rAF-driven flush turns a whole drag into one jump atpointerup. e.buttonfor pointerup is the released button; ensure a synthetic_button(b,1,…)is sent onpointercancel,blur, andvisibilitychangeso the backend never stays in a dragging state.e.getCoalescedEvents()should be ignored — PyMOL’s drag math is incremental (LastX/LastY), so replaying coalesced points would multiply rotation speed.- Middle-click must
preventDefault()onauxclick/pointerdownto stop autoscroll. - Do not synthesise double/single clicks in the browser: PyMOL does that itself in
SceneClickCheckDoubleClick/SceneIdle(§8). Browserdblclickmust be suppressed.
16.2 Keyboard
Attach at the document/app level (matchingpymol_qt_gui.py:50), but gate: if focus is inside a
React text input, do not forward. Translation:
e.keyCodeis deprecated but is the only cheap source of the uppercase-ASCII code thatkeymapping.pyrelies on for Ctrl/Alt. Prefer deriving it frome.code(KeyA→ 65,Digit0→ 48) to stay standards-compliant, and document the equivalence.- Ctrl+letter and Alt+letter are browser/OS shortcuts (Ctrl-T new tab, Ctrl-W close, Alt-F menu…).
preventDefault()recovers most but not Ctrl-W/Ctrl-T/Ctrl-N in most browsers. PyMOL bindsCTRL-T(bond;unpick) andCTRL-F(wizard find), so those collide. The bindings stay as upstream defines them and are rebindable from the shortcut editor (apps/web/src/features/shortcuts/); the browser wins wherepreventDefault()cannot. - macOS: Qt folds Meta (⌘) into the CTRL bit (
keymapping.py:51-52), soe.metaKey→ bit 2. The separate_cmmdpath (internal.py:500-507,Ortho.cpp:775-786) is only reachable from the native macOS GLUT build and is not ported. - Send on
keydownonly (Qt sends onkeyPressEvent); ignorekeyup. Ignore auto-repeat only if a binding is expensive — Qt does not ignore it. Tabmust bepreventDefault()-ed so it reaches the PyMOL command line for completion (mirrorspymol_qt_gui.py:449-455).
16.3 devicePixelRatio changes
Listen tomatchMedia(\(resolution: ${dpr}dppx)`)change +ResizeObserver`. On change:
- resize the WebGL drawing buffer,
cmd.set('display_scale_factor', Math.round(dpr))(pymol_gl_widget.py:222) — note it must be an integer or the C side warns and forces 1 (packages/engine/layer1/Setting.cpp:2953-2958),_reshape(wDevice, hDevice, true). Wrap in try/catch: the Qt code notessetfails “with modal draw (mpng …, modal=1)” (pymol_gl_widget.py:223-225).
16.4 Pinch / trackpad zoom
Two sources: Safarigesturestart/gesturechange/gestureend, and Chrome/Firefox which report
trackpad pinch as wheel with ctrlKey === true. This collides with PyMOL’s Ctrl+wheel = mvsz.
Handled by mirroring gestureEvent (pymol_gl_widget.py:138-168):
wheelwithe.ctrlKeyand no physical Ctrl pressed (trackkeydown/keyupstate) → treat as pinch: on first event snapshotview[11]fromcmd.get_view(), thenz = startZ / totalScale; view[11] = z; view[15] -= (z - old); view[16] -= (z - old); cmd.set_view(view).- Two-finger rotate →
cmd.turn('z', deltaDegrees). - If a real Ctrl key is down, fall through to
_button(3|4, …)somvszstill works.
17. Why the backend stays authoritative for the camera
Everything authoritative depends on the backend view matrix, which is why the browser never owns it:- Picking is a GPU colour-pick pass in the backend’s own GL context
(
packages/engine/layer1/ScenePicking.cpp:17-38,PyMOLReadPixelsat:149) — it renders with the backend’s camera. If the browser camera differs by one frame, clicks hit the wrong atom. - Box select uses
SceneMultipickover a screen rect (Executive.cpp:7432-7438) — same problem, amplified. - Drag/edit math uses
SceneGetExactScreenVertexScaleandMatrixInvTransformC44fAs33f3f(I->m_view.rotMatrix(), ...)on the backend (SceneMouse.cpp:1490-1491,:1596-1597,:1729) — atom positions computed from mouse deltas depend on the backend’s rotation matrix and zoom. - Clip planes, slab, roving detail,
mouse_z_scale,virtual_trackballare backend settings applied insideSceneDrag; reimplementing them client-side means forking numerically sensitive code (SceneMouse.cpp:1762-2026). - Scenes,
mview,zoom animate=-1, rock, movie playback all animate the backend camera (ControlRock,Control.cpp:415-439;SceneIdlesweep,Scene.cpp:2410-2427), so the browser has to follow the backend regardless.
The two input paths that came out of this
Mode P (backend has a GL context). Every pointer event is forwarded verbatim as_button/_drag and SceneClick/SceneDrag/SceneRelease decide what it means. Bit-exact for
all 57 actions, zero reimplementation, picking always consistent.
packages/viewport/src/input/mouse.ts is that path; it only ever coalesces consecutive drags
(safe: SceneDrag reads the current position against the press position) and never reorders them.
Mode G (backend started --no-gl). Raw input is accepted and silently never applied:
CScene::click/drag/release only call OrthoDefer (Scene.cpp:4113, :4129, :4146), and the
queue is drained by ExecutiveDrawNow, which runs only while PyMOL_GetIdleAndReady is true —
and that only advances while DrawnFlag is set, which only PyMOL_Draw sets. Measured: a 20-step
drag moved get_view()[2] by exactly 0. So on a GL-free backend the client drives the session the
way a script does — turn, move, clip, rotate, translate, torsion, select — which take
effect immediately because they are ordinary API calls rather than queued scene events.
packages/viewport/src/input/camera.ts is that path.
Both paths resolve the gesture through the same ButMode arithmetic, redone on every drag
sample with the modifier that sample carried, exactly as SceneDrag does
(packages/engine/layer1/SceneMouse.cpp:1308, mode = ButModeTranslate(G, I->Button, mod)), so
releasing Shift mid-drag changes the action mid-drag.
ButModeGet/ButModeTranslate are mirrored, not exposed
They exist in C (packages/engine/layer1/ButMode.h:225, packages/engine/layer1/ButMode.cpp:603)
and are NOT PRESENT in Python — only the write path cmd.button
(packages/engine/modules/pymol/controlling.py:799-868) exists. No C++ accessor was added. The
authoritative binding table is the Python one (controlling.mode_dict, mouse_ring,
mode_name_dict), applied via cmd.button(), with the current mode read from
cmd.get('button_mode') / cmd.get('button_mode_name'). packages/viewport/src/input/butmode.ts
mirrors it, expands it into the same 80 slots the C core keeps, and resolves it with the same
arithmetic; butmode.test.ts and modes.test.ts diff every table against the real
controlling.py and ButMode.cpp in the tree, so the mirror cannot drift silently.
camera.ts does not guess at the actions whose C implementation has no Python equivalent —
DrgM/DrgO/DgRt (they consume EditorDrag state the client cannot see), the light actions,
and the click-only actions (PkAt, Menu, Cent, Orig, …) which belong to the press, not
the drag. Those are counted as unsupported and issue nothing. Gains are approximate — degrees per
pixel and Angstroms per pixel are constants there, where PyMOL derives them from a virtual
trackball and SceneGetExactScreenVertexScale. The action a gesture maps to is exact; how far
one pixel takes you is not.
18. The bridge surface for this area
Three things stay client-side because upstream has no Python surface for them:
- the mouse config table — mirrored in
packages/viewport/src/input/butmode.ts(§17), not fetched; - the loop rect for box select —
OrthoSetLoopRect(Ortho.cpp:253) is only drawn internally, so the browser draws its own from the same press/current coordinates (apps/web/src/features/console/OrthoLoopRect.tsx); get_click_string—_cmd.get_click_string(Cmd.cpp:1420) has nocmd.*wrapper upstream, so theclik/ SimpleClick pathway is reached through the raw_cmdentry point.
19. Constraints this area lives under
- Picking requires the backend’s GL context.
SceneDoXYPickrenders a pick pass and callsPyMOLReadPixels(ScenePicking.cpp:149), so every pick costs a full render. A GL-less bridge cannot pick at all —packages/viewport/src/picking/route.tschooses between the backend pass and a client-side ray on that basis. - Camera divergence breaks picking silently — the wrong atom is selected with no error. This is the reason §17 keeps the view authoritative on the backend.
- Browser keyboard hijacking.
CTRL-T,CTRL-FandCTRL-W-adjacent defaults collide with the browser and some cannot bepreventDefault()-ed. - Middle-click and right-click need
preventDefaultonauxclick/contextmenu; some Linux browsers still paste on middle-click. - Ctrl+wheel is trackpad pinch in Chrome/Firefox, colliding with PyMOL’s Ctrl+wheel =
mvsz(§16.4). - The button table can drift. The client mirrors
mode_dict(§17); a plugin callingcmd.buttondirectly changesButMode’s real state without changing the mirror. The mirror tests pin it against the source, but they cannot see runtime writes. - DPR/y-flip off-by-one. Qt flips using the logical height before scaling
(
pymol_gl_widget.py:174); a naive(h_device - y_device)differs on fractional DPR and mis-picks edge pixels. - Latent upstream bugs a faithful port inherits: duplicate
double_leftrow inthree_button_motions(controlling.py:398-399), duplicate row intwo_button_selecting(:456-457), missingbreakinSceneClickPickNothing(SceneMouse.cpp:573), double release dispatch inOrthoButton(Ortho.cpp:2543-2556),ButModeSet(cButModeMiddleCtSh)written twice inPyMOL_SetDefaultMouse(PyMOL.cpp:3005— the source itself comments “SET TWICE?!?”). - SDOF / spaceball (
Control.cpp:83-216,_sdof) has no browser equivalent short of WebHID and is not ported. - Deferred execution ordering. All scene input is queued through
OrthoDefer; a transport that reorders or drops messages corrupts drag state, so input rides one strictly ordered connection with no parallel channels. - Timing-based semantics (0.35 s double-click, 0.25 s single-click window, 0.15 s delay) are
measured on the backend against
UtilGetSeconds, so transport jitter inflates the measured press-to-release gap. The client stampswhenfrom the event itself, not from send time (packages/viewport/src/input/coords.ts), and the bridge passes that through.