Browse Source

refactored bindings and interactivity

Alexander Rose 5 years ago
parent
commit
186929269b

+ 4 - 2
src/mol-canvas3d/canvas3d.ts

@@ -88,7 +88,7 @@ const requestAnimationFrame = typeof window !== 'undefined' ? window.requestAnim
 const DefaultRunTask = (task: Task<unknown>) => task.run()
 
 namespace Canvas3D {
-    export interface HighlightEvent { current: Representation.Loci, modifiers?: ModifiersKeys }
+    export interface HoverEvent { current: Representation.Loci, buttons: ButtonsType, modifiers: ModifiersKeys }
     export interface ClickEvent { current: Representation.Loci, buttons: ButtonsType, modifiers: ModifiersKeys }
 
     export function fromCanvas(canvas: HTMLCanvasElement, props: Partial<Canvas3DProps> = {}, runTask = DefaultRunTask) {
@@ -145,7 +145,9 @@ namespace Canvas3D {
             reprRenderObjects.forEach((_, _repr) => {
                 const _loci = _repr.getLoci(pickingId)
                 if (!isEmptyLoci(_loci)) {
-                    if (!isEmptyLoci(loci)) console.warn('found another loci')
+                    if (!isEmptyLoci(loci)) {
+                        console.warn('found another loci, this should not happen')
+                    }
                     loci = _loci
                     repr = _repr
                 }

+ 0 - 53
src/mol-canvas3d/controls/bindings.ts

@@ -1,53 +0,0 @@
-/**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author Alexander Rose <alexander.rose@weirdbyte.de>
- */
-
-import { ButtonsType, ModifiersKeys } from '../../mol-util/input/input-observer';
-
-const B = ButtonsType
-const M = ModifiersKeys
-
-export interface Bindings {
-    drag: {
-        rotate: Bindings.Trigger
-        rotateZ: Bindings.Trigger
-        pan: Bindings.Trigger
-        zoom: Bindings.Trigger
-        focus: Bindings.Trigger
-        focusZoom: Bindings.Trigger
-    },
-    scroll: {
-        zoom: Bindings.Trigger
-        focus: Bindings.Trigger
-        focusZoom: Bindings.Trigger
-    }
-}
-
-export namespace Bindings {
-    export type Trigger = { buttons: ButtonsType, modifiers?: ModifiersKeys }
-    export const EmptyTrigger = { buttons: ButtonsType.Flag.None }
-
-    export function match(trigger: Trigger, buttons: ButtonsType, modifiers: ModifiersKeys) {
-        const { buttons: b, modifiers: m } = trigger
-        return ButtonsType.has(b, buttons) &&
-            (!m || ModifiersKeys.areEqual(m, modifiers))
-    }
-
-    export const Default: Bindings = {
-        drag: {
-            rotate: { buttons: B.Flag.Primary, modifiers: M.create() },
-            rotateZ: { buttons: B.Flag.Primary, modifiers: M.create({ shift: true }) },
-            pan: { buttons: B.Flag.Secondary, modifiers: M.create() },
-            zoom: EmptyTrigger,
-            focus: { buttons: B.Flag.Forth, modifiers: M.create() },
-            focusZoom: { buttons: B.Flag.Auxilary, modifiers: M.create() },
-        },
-        scroll: {
-            zoom: { buttons: B.Flag.Auxilary, modifiers: M.create() },
-            focus: { buttons: B.Flag.Auxilary, modifiers: M.create({ shift: true }) },
-            focusZoom: EmptyTrigger,
-        }
-    }
-}

+ 29 - 12
src/mol-canvas3d/controls/trackball.ts

@@ -10,11 +10,28 @@
 
 import { Quat, Vec2, Vec3, EPSILON } from '../../mol-math/linear-algebra';
 import { Viewport } from '../camera/util';
-import InputObserver, { DragInput, WheelInput, PinchInput } from '../../mol-util/input/input-observer';
+import InputObserver, { DragInput, WheelInput, PinchInput, ButtonsType, ModifiersKeys } from '../../mol-util/input/input-observer';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { Camera } from '../camera';
-import { Bindings } from './bindings';
 import { absMax } from '../../mol-math/misc';
+import { Binding } from '../../mol-util/binding';
+
+const B = ButtonsType
+const M = ModifiersKeys
+const Trigger = Binding.Trigger
+
+export const DefaultTrackballBindings = {
+    dragRotate: Binding(Trigger(B.Flag.Primary, M.create()), 'Rotate the 3D scene by dragging using ${trigger}'),
+    dragRotateZ: Binding(Trigger(B.Flag.Primary, M.create({ shift: true })), 'Rotate the 3D scene around the z-axis by dragging using ${trigger}'),
+    dragPan: Binding(Trigger(B.Flag.Secondary, M.create()), 'Pan the 3D scene by dragging using ${trigger}'),
+    dragZoom: Binding.Empty,
+    dragFocus: Binding(Trigger(B.Flag.Forth, M.create()), 'Focus the 3D scene by dragging using ${trigger}'),
+    dragFocusZoom: Binding(Trigger(B.Flag.Auxilary, M.create()), 'Focus and zoom the 3D scene by dragging using ${trigger}'),
+
+    scrollZoom: Binding(Trigger(B.Flag.Auxilary, M.create()), 'Zoom the 3D scene by scrolling using ${trigger}'),
+    scrollFocus: Binding(Trigger(B.Flag.Auxilary, M.create({ shift: true })), 'Focus the 3D scene by dragging using ${trigger}'),
+    scrollFocusZoom: Binding.Empty,
+}
 
 export const TrackballControlsParams = {
     noScroll: PD.Boolean(true, { isHidden: true }),
@@ -32,7 +49,7 @@ export const TrackballControlsParams = {
     minDistance: PD.Numeric(0.01, {}, { isHidden: true }),
     maxDistance: PD.Numeric(1e150, {}, { isHidden: true }),
 
-    bindings: PD.Value(Bindings.Default, { isHidden: true })
+    bindings: PD.Value(DefaultTrackballBindings, { isHidden: true })
 }
 export type TrackballControlsProps = PD.Values<typeof TrackballControlsParams>
 
@@ -287,12 +304,12 @@ namespace TrackballControls {
         function onDrag({ pageX, pageY, buttons, modifiers, isStart }: DragInput) {
             _isInteracting = true;
 
-            const dragRotate = Bindings.match(p.bindings.drag.rotate, buttons, modifiers)
-            const dragRotateZ = Bindings.match(p.bindings.drag.rotateZ, buttons, modifiers)
-            const dragPan = Bindings.match(p.bindings.drag.pan, buttons, modifiers)
-            const dragZoom = Bindings.match(p.bindings.drag.zoom, buttons, modifiers)
-            const dragFocus = Bindings.match(p.bindings.drag.focus, buttons, modifiers)
-            const dragFocusZoom = Bindings.match(p.bindings.drag.focusZoom, buttons, modifiers)
+            const dragRotate = Binding.match(p.bindings.dragRotate, buttons, modifiers)
+            const dragRotateZ = Binding.match(p.bindings.dragRotateZ, buttons, modifiers)
+            const dragPan = Binding.match(p.bindings.dragPan, buttons, modifiers)
+            const dragZoom = Binding.match(p.bindings.dragZoom, buttons, modifiers)
+            const dragFocus = Binding.match(p.bindings.dragFocus, buttons, modifiers)
+            const dragFocusZoom = Binding.match(p.bindings.dragFocusZoom, buttons, modifiers)
 
             getMouseOnCircle(pageX, pageY)
             getMouseOnScreen(pageX, pageY)
@@ -337,16 +354,16 @@ namespace TrackballControls {
 
         function onWheel({ dx, dy, dz, buttons, modifiers }: WheelInput) {
             const delta = absMax(dx, dy, dz)
-            if (Bindings.match(p.bindings.scroll.zoom, buttons, modifiers)) {
+            if (Binding.match(p.bindings.scrollZoom, buttons, modifiers)) {
                 _zoomEnd[1] += delta * 0.0001
             }
-            if (Bindings.match(p.bindings.scroll.focus, buttons, modifiers)) {
+            if (Binding.match(p.bindings.scrollFocus, buttons, modifiers)) {
                 _focusEnd[1] += delta * 0.0001
             }
         }
 
         function onPinch({ fraction, buttons, modifiers }: PinchInput) {
-            if (Bindings.match(p.bindings.scroll.zoom, buttons, modifiers)) {
+            if (Binding.match(p.bindings.scrollZoom, buttons, modifiers)) {
                 _isInteracting = true;
                 _zoomEnd[1] += (fraction - 1) * 0.1
             }

+ 12 - 9
src/mol-canvas3d/helper/interaction-events.ts

@@ -11,13 +11,15 @@ import InputObserver, { ModifiersKeys, ButtonsType } from '../../mol-util/input/
 import { RxEventHelper } from '../../mol-util/rx-event-helper';
 
 type Canvas3D = import('../canvas3d').Canvas3D
+type HoverEvent = import('../canvas3d').Canvas3D.HoverEvent
+type ClickEvent = import('../canvas3d').Canvas3D.ClickEvent
 
 export class Canvas3dInteractionHelper {
     private ev = RxEventHelper.create();
 
     readonly events = {
-        highlight: this.ev<import('../canvas3d').Canvas3D.HighlightEvent>(),
-        click: this.ev<import('../canvas3d').Canvas3D.ClickEvent>(),
+        hover: this.ev<HoverEvent>(),
+        click: this.ev<ClickEvent>(),
     };
 
     private cX = -1;
@@ -52,14 +54,14 @@ export class Canvas3dInteractionHelper {
             return;
         }
 
-        // only highlight the latest
         if (!this.inside || this.currentIdentifyT !== t) {
             return;
         }
 
         const loci = this.getLoci(this.id);
+        // only broadcast the latest hover
         if (!Representation.Loci.areEqual(this.prevLoci, loci)) {
-            this.events.highlight.next({ current: loci, modifiers: this.modifiers });
+            this.events.hover.next({ current: loci, buttons: this.buttons, modifiers: this.modifiers });
             this.prevLoci = loci;
         }
     }
@@ -76,12 +78,13 @@ export class Canvas3dInteractionHelper {
         this.inside = false;
         if (this.prevLoci.loci !== EmptyLoci) {
             this.prevLoci = Representation.Loci.Empty;
-            this.events.highlight.next({ current: this.prevLoci });
+            this.events.hover.next({ current: this.prevLoci, buttons: this.buttons, modifiers: this.modifiers });
         }
     }
 
-    move(x: number, y: number, modifiers: ModifiersKeys) {
+    move(x: number, y: number, buttons: ButtonsType, modifiers: ModifiersKeys) {
         this.inside = true;
+        this.buttons = buttons;
         this.modifiers = modifiers;
         this.cX = x;
         this.cY = y;
@@ -98,7 +101,7 @@ export class Canvas3dInteractionHelper {
     modify(modifiers: ModifiersKeys) {
         if (this.prevLoci.loci === EmptyLoci || ModifiersKeys.areEqual(modifiers, this.modifiers)) return;
         this.modifiers = modifiers;
-        this.events.highlight.next({ current: this.prevLoci, modifiers: this.modifiers });
+        this.events.hover.next({ current: this.prevLoci, buttons: this.buttons, modifiers: this.modifiers });
     }
 
     dispose() {
@@ -107,8 +110,8 @@ export class Canvas3dInteractionHelper {
 
     constructor(private canvasIdentify: Canvas3D['identify'], private getLoci: Canvas3D['getLoci'], input: InputObserver, private maxFps: number = 15) {
         input.move.subscribe(({x, y, inside, buttons, modifiers }) => {
-            if (!inside || buttons) { return; }
-            this.move(x, y, modifiers);
+            if (!inside) return;
+            this.move(x, y, buttons, modifiers);
         });
 
         input.leave.subscribe(() => {

+ 1 - 1
src/mol-plugin/behavior/behavior.ts

@@ -152,7 +152,7 @@ namespace PluginBehavior {
             this.subs = [];
         }
 
-        constructor(protected plugin: PluginContext) {
+        constructor(protected plugin: PluginContext, protected params: P) {
         }
     }
 }

+ 34 - 15
src/mol-plugin/behavior/dynamic/camera.ts

@@ -1,32 +1,51 @@
 /**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
 import { Loci } from '../../../mol-model/loci';
-import { ParamDefinition } from '../../../mol-util/param-definition';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { PluginBehavior } from '../behavior';
 import { ButtonsType, ModifiersKeys } from '../../../mol-util/input/input-observer';
+import { Binding } from '../../../mol-util/binding';
 
-export const FocusLociOnSelect = PluginBehavior.create<{ minRadius: number, extraRadius: number, durationMs?: number }>({
-    name: 'focus-loci-on-select',
+const B = ButtonsType
+const M = ModifiersKeys
+const Trigger = Binding.Trigger
+
+const DefaultFocusLociBindings = {
+    clickCenterFocus: Binding(Trigger(B.Flag.Primary, M.create()), 'Center and focus the clicked element.'),
+}
+const FocusLociParams = {
+    minRadius: PD.Numeric(8, { min: 1, max: 50, step: 1 }),
+    extraRadius: PD.Numeric(4, { min: 1, max: 50, step: 1 }, { description: 'Value added to the bounding-sphere radius of the Loci.' }),
+    durationMs: PD.Numeric(250, { min: 0, max: 1000, step: 1 }, { description: 'Camera transition duration.' }),
+
+    bindings: PD.Value(DefaultFocusLociBindings, { isHidden: true }),
+}
+type FocusLociProps = PD.Values<typeof FocusLociParams>
+
+export const FocusLoci = PluginBehavior.create<FocusLociProps>({
+    name: 'camera-focus-loci',
     category: 'interaction',
-    ctor: class extends PluginBehavior.Handler<{ minRadius: number, extraRadius: number, durationMs?: number }> {
+    ctor: class extends PluginBehavior.Handler<FocusLociProps> {
         register(): void {
             this.subscribeObservable(this.ctx.behaviors.interaction.click, ({ current, buttons, modifiers }) => {
-                if (!this.ctx.canvas3d || buttons !== ButtonsType.Flag.Primary || !ModifiersKeys.areEqual(modifiers, ModifiersKeys.None)) return;
+                if (!this.ctx.canvas3d) return;
 
-                const sphere = Loci.getBoundingSphere(current.loci);
-                if (!sphere) return;
-                this.ctx.canvas3d.camera.focus(sphere.center, Math.max(sphere.radius + this.params.extraRadius, this.params.minRadius), this.params.durationMs);
+                const p = this.params
+                if (Binding.match(this.params.bindings.clickCenterFocus, buttons, modifiers)) {
+                    const sphere = Loci.getBoundingSphere(current.loci);
+                    if (sphere) {
+                        const radius = Math.max(sphere.radius + p.extraRadius, p.minRadius);
+                        this.ctx.canvas3d.camera.focus(sphere.center, radius, p.durationMs);
+                    }
+                }
             });
         }
     },
-    params: () => ({
-        minRadius: ParamDefinition.Numeric(8, { min: 1, max: 50, step: 1 }),
-        extraRadius: ParamDefinition.Numeric(4, { min: 1, max: 50, step: 1 }, { description: 'Value added to the bounding-sphere radius of the Loci.' }),
-        durationMs: ParamDefinition.Numeric(250, { min: 0, max: 1000, step: 1 }, { description: 'Camera transition duration.' })
-    }),
-    display: { name: 'Focus Loci on Select' }
+    params: () => FocusLociParams,
+    display: { name: 'Focus Loci on Canvas' }
 });

+ 78 - 5
src/mol-plugin/behavior/dynamic/representation.ts

@@ -13,29 +13,74 @@ import { PluginBehavior } from '../behavior';
 import { Interactivity } from '../../util/interactivity';
 import { StateTreeSpine } from '../../../mol-state/tree/spine';
 import { StateSelection } from '../../../mol-state';
+import { ButtonsType, ModifiersKeys } from '../../../mol-util/input/input-observer';
+import { Binding } from '../../../mol-util/binding';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+
+const B = ButtonsType
+const M = ModifiersKeys
+const Trigger = Binding.Trigger
+
+//
+
+const DefaultHighlightLociBindings = {
+    hoverHighlightOnly: Binding(Trigger(B.Flag.None), 'Highlight hovered element using ${trigger}'),
+    hoverHighlightOnlyExtend: Binding(Trigger(B.Flag.None, M.create({ shift: true })), 'Extend highlight from selected to hovered element along polymer using ${trigger}'),
+}
+const HighlightLociParams = {
+    bindings: PD.Value(DefaultHighlightLociBindings, { isHidden: true }),
+}
+type HighlightLociProps = PD.Values<typeof HighlightLociParams>
 
 export const HighlightLoci = PluginBehavior.create({
     name: 'representation-highlight-loci',
     category: 'interaction',
-    ctor: class extends PluginBehavior.Handler {
+    ctor: class extends PluginBehavior.Handler<HighlightLociProps> {
         private lociMarkProvider = (interactionLoci: Interactivity.Loci, action: MarkerAction) => {
             if (!this.ctx.canvas3d) return;
             this.ctx.canvas3d.mark({ loci: interactionLoci.loci }, action)
         }
         register() {
+            this.subscribeObservable(this.ctx.behaviors.interaction.hover, ({ current, buttons, modifiers }) => {
+                if (!this.ctx.canvas3d) return
+
+                if (Binding.match(this.params.bindings.hoverHighlightOnly, buttons, modifiers)) {
+                    this.ctx.interactivity.lociHighlights.highlightOnly(current)
+                }
+
+                if (Binding.match(this.params.bindings.hoverHighlightOnlyExtend, buttons, modifiers)) {
+                    this.ctx.interactivity.lociHighlights.highlightOnlyExtend(current)
+                }
+            });
             this.ctx.interactivity.lociHighlights.addProvider(this.lociMarkProvider)
         }
         unregister() {
             this.ctx.interactivity.lociHighlights.removeProvider(this.lociMarkProvider)
         }
     },
+    params: () => HighlightLociParams,
     display: { name: 'Highlight Loci on Canvas' }
 });
 
+//
+
+const DefaultSelectLociBindings = {
+    clickSelect: Binding.Empty,
+    clickSelectExtend: Binding(Trigger(B.Flag.Primary, M.create({ shift: true })), 'Try to extend selection to clicked element along polymer.'),
+    clickSelectOnly: Binding(Trigger(B.Flag.Secondary, M.create({ control: true })), 'Select only the clicked element.'),
+    clickSelectToggle: Binding(Trigger(B.Flag.Primary, M.create({ control: true })), 'Toggle clicked element.'),
+    clickDeselect: Binding.Empty,
+    clickDeselectAllOnEmpty: Binding(Trigger(B.Flag.Secondary, M.create({ control: true })), 'Clear the selection when the clicked element is empty.'),
+}
+const SelectLociParams = {
+    bindings: PD.Value(DefaultSelectLociBindings, { isHidden: true }),
+}
+type SelectLociProps = PD.Values<typeof SelectLociParams>
+
 export const SelectLoci = PluginBehavior.create({
     name: 'representation-select-loci',
     category: 'interaction',
-    ctor: class extends PluginBehavior.Handler {
+    ctor: class extends PluginBehavior.Handler<SelectLociProps> {
         private spine: StateTreeSpine.Impl
         private lociMarkProvider = (interactionLoci: Interactivity.Loci, action: MarkerAction) => {
             if (!this.ctx.canvas3d) return;
@@ -53,7 +98,34 @@ export const SelectLoci = PluginBehavior.create({
             }
         }
         register() {
-            this.ctx.interactivity.lociSelections.addProvider(this.lociMarkProvider)
+            this.subscribeObservable(this.ctx.behaviors.interaction.click, ({ current, buttons, modifiers }) => {
+                if (!this.ctx.canvas3d) return
+
+                if (Binding.match(this.params.bindings.clickSelect, buttons, modifiers)) {
+                    this.ctx.interactivity.lociSelects.select(current)
+                }
+
+                if (Binding.match(this.params.bindings.clickSelectExtend, buttons, modifiers)) {
+                    this.ctx.interactivity.lociSelects.selectExtend(current)
+                }
+
+                if (Binding.match(this.params.bindings.clickSelectOnly, buttons, modifiers)) {
+                    this.ctx.interactivity.lociSelects.selectOnly(current)
+                }
+
+                if (Binding.match(this.params.bindings.clickSelectToggle, buttons, modifiers)) {
+                    this.ctx.interactivity.lociSelects.selectToggle(current)
+                }
+
+                if (Binding.match(this.params.bindings.clickDeselect, buttons, modifiers)) {
+                    this.ctx.interactivity.lociSelects.deselect(current)
+                }
+
+                if (Binding.match(this.params.bindings.clickDeselectAllOnEmpty, buttons, modifiers)) {
+                    this.ctx.interactivity.lociSelects.deselectAllOnEmpty(current)
+                }
+            });
+            this.ctx.interactivity.lociSelects.addProvider(this.lociMarkProvider)
 
             this.subscribeObservable(this.ctx.events.state.object.created, ({ ref }) => this.applySelectMark(ref));
 
@@ -67,13 +139,14 @@ export const SelectLoci = PluginBehavior.create({
             });
         }
         unregister() {
-            this.ctx.interactivity.lociSelections.removeProvider(this.lociMarkProvider)
+            this.ctx.interactivity.lociSelects.removeProvider(this.lociMarkProvider)
         }
-        constructor(ctx: PluginContext, params: {}) {
+        constructor(ctx: PluginContext, params: SelectLociProps) {
             super(ctx, params)
             this.spine = new StateTreeSpine.Impl(ctx.state.dataState.cells)
         }
     },
+    params: () => SelectLociParams,
     display: { name: 'Select Loci on Canvas' }
 });
 

+ 48 - 51
src/mol-plugin/behavior/dynamic/selection/structure-representation-interaction.ts

@@ -2,12 +2,12 @@
  * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
 import { Structure, StructureElement } from '../../../../mol-model/structure';
 import { PluginBehavior } from '../../../../mol-plugin/behavior';
 import { PluginCommands } from '../../../../mol-plugin/command';
-import { PluginContext } from '../../../../mol-plugin/context';
 import { PluginStateObject } from '../../../../mol-plugin/state/objects';
 import { StateTransforms } from '../../../../mol-plugin/state/transforms';
 import { StructureRepresentation3DHelpers } from '../../../../mol-plugin/state/transforms/representation';
@@ -16,11 +16,23 @@ import { MolScriptBuilder as MS } from '../../../../mol-script/language/builder'
 import { StateObjectCell, StateSelection, StateTransform } from '../../../../mol-state';
 import { BuiltInColorThemes } from '../../../../mol-theme/color';
 import { BuiltInSizeThemes } from '../../../../mol-theme/size';
-import { ColorNames } from '../../../../mol-util/color/names';
-import { ButtonsType } from '../../../../mol-util/input/input-observer';
+import { ButtonsType, ModifiersKeys } from '../../../../mol-util/input/input-observer';
 import { Representation } from '../../../../mol-repr/representation';
+import { Binding } from '../../../../mol-util/binding';
+import { ParamDefinition as PD } from '../../../../mol-util/param-definition';
 
-type Params = { }
+const B = ButtonsType
+const M = ModifiersKeys
+const Trigger = Binding.Trigger
+
+const DefaultStructureRepresentationInteractionBindings = {
+    clickShowInteractionOnly: Binding(Trigger(B.Flag.Secondary, M.create()), 'Show only the interaction of the clicked element.'),
+    clickClearInteractionOnEmpty: Binding(Trigger(B.Flag.Secondary, M.create({ control: true })), 'Clear all interactions when the clicked element is empty.'),
+}
+const StructureRepresentationInteractionParams = {
+    bindings: PD.Value(DefaultStructureRepresentationInteractionBindings, { isHidden: true }),
+}
+type StructureRepresentationInteractionProps = PD.Values<typeof StructureRepresentationInteractionParams>
 
 enum Tags {
     Group = 'structure-interaction-group',
@@ -32,7 +44,7 @@ enum Tags {
 
 const TagSet: Set<Tags> = new Set([Tags.Group, Tags.ResidueSel, Tags.ResidueRepr, Tags.SurrSel, Tags.SurrRepr])
 
-export class StructureRepresentationInteractionBehavior extends PluginBehavior.WithSubscribers<Params> {
+export class StructureRepresentationInteractionBehavior extends PluginBehavior.WithSubscribers<StructureRepresentationInteractionProps> {
 
     private createResVisualParams(s: Structure) {
         return StructureRepresentation3DHelpers.createParams(this.plugin, s, {
@@ -44,7 +56,7 @@ export class StructureRepresentationInteractionBehavior extends PluginBehavior.W
     private createSurVisualParams(s: Structure) {
         return StructureRepresentation3DHelpers.createParams(this.plugin, s, {
             repr: BuiltInStructureRepresentations['ball-and-stick'],
-            color: [BuiltInColorThemes.uniform, () => ({ value: ColorNames.gray })],
+            color: [BuiltInColorThemes['element-symbol'], () => ({ saturation: -3, lightness: 0.6 })],
             size: [BuiltInSizeThemes.uniform, () => ({ value: 0.33 } )]
         });
     }
@@ -122,69 +134,54 @@ export class StructureRepresentationInteractionBehavior extends PluginBehavior.W
         });
 
         this.subscribeObservable(this.plugin.behaviors.interaction.click, ({ current, buttons, modifiers }) => {
-            if (buttons !== ButtonsType.Flag.Secondary) return;
-
-            if (current.loci.kind === 'empty-loci') {
-                if (modifiers.control && buttons === ButtonsType.Flag.Secondary) {
-                    this.clear(StateTransform.RootRef);
-                    return;
-                }
-            }
+            const { clickShowInteractionOnly, clickClearInteractionOnEmpty } = this.params.bindings
 
-            // TODO: support link loci as well?
-            if (!StructureElement.Loci.is(current.loci)) return;
+            if (current.loci.kind === 'empty-loci' && Binding.match(clickClearInteractionOnEmpty, buttons, modifiers)) {
+                this.clear(StateTransform.RootRef);
+            } else if (Binding.match(clickShowInteractionOnly, buttons, modifiers)) {
+                // TODO: support link loci as well?
+                if (!StructureElement.Loci.is(current.loci)) return;
 
-            const parent = this.plugin.helpers.substructureParent.get(current.loci.structure);
-            if (!parent || !parent.obj) return;
+                const parent = this.plugin.helpers.substructureParent.get(current.loci.structure);
+                if (!parent || !parent.obj) return;
 
-            if (Representation.Loci.areEqual(lastLoci, current)) {
-                lastLoci = Representation.Loci.Empty;
-                this.clear(parent.transform.ref);
-                return;
-            }
-
-            lastLoci = current;
+                if (Representation.Loci.areEqual(lastLoci, current)) {
+                    lastLoci = Representation.Loci.Empty;
+                    this.clear(parent.transform.ref);
+                    return;
+                }
 
-            const core = MS.struct.modifier.wholeResidues([
-                StructureElement.Loci.toExpression(current.loci)
-            ]);
+                lastLoci = current;
 
-            const surroundings = MS.struct.modifier.includeSurroundings({
-                0: core,
-                radius: 5,
-                'as-whole-residues': true
-            });
+                const core = MS.struct.modifier.wholeResidues([
+                    StructureElement.Loci.toExpression(current.loci)
+                ]);
 
-            // const surroundings = MS.struct.modifier.exceptBy({
-            //     0: MS.struct.modifier.includeSurroundings({
-            //         0: core,
-            //         radius: 5,
-            //         'as-whole-residues': true
-            //     }),
-            //     by: core
-            // });
+                const surroundings = MS.struct.modifier.includeSurroundings({
+                    0: core,
+                    radius: 5,
+                    'as-whole-residues': true
+                });
 
-            const { state, builder, refs } = this.ensureShape(parent);
+                const { state, builder, refs } = this.ensureShape(parent);
 
-            builder.to(refs[Tags.ResidueSel]!).update(StateTransforms.Model.StructureSelectionFromExpression, old => ({ ...old, expression: core }));
-            builder.to(refs[Tags.SurrSel]!).update(StateTransforms.Model.StructureSelectionFromExpression, old => ({ ...old, expression: surroundings }));
+                builder.to(refs[Tags.ResidueSel]!).update(StateTransforms.Model.StructureSelectionFromExpression, old => ({ ...old, expression: core }));
+                builder.to(refs[Tags.SurrSel]!).update(StateTransforms.Model.StructureSelectionFromExpression, old => ({ ...old, expression: surroundings }));
 
-            PluginCommands.State.Update.dispatch(this.plugin, { state, tree: builder, options: { doNotLogTiming: true, doNotUpdateCurrent: true } });
+                PluginCommands.State.Update.dispatch(this.plugin, { state, tree: builder, options: { doNotLogTiming: true, doNotUpdateCurrent: true } });
+            }
         });
     }
 
-    async update(params: Params) {
+    async update(params: StructureRepresentationInteractionProps) {
         return false;
     }
-
-    constructor(public plugin: PluginContext) {
-        super(plugin);
-    }
 }
 
 export const StructureRepresentationInteraction = PluginBehavior.create({
     name: 'create-structure-representation-interaction',
     display: { name: 'Structure Representation Interaction' },
     category: 'interaction',
-    ctor: StructureRepresentationInteractionBehavior
+    ctor: StructureRepresentationInteractionBehavior,
+    params: () => StructureRepresentationInteractionParams
 });

+ 1 - 1
src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts

@@ -287,7 +287,7 @@ export namespace VolumeStreaming {
         }
 
         constructor(public plugin: PluginContext, public info: VolumeServerInfo.Data) {
-            super(plugin);
+            super(plugin, {} as any);
         }
     }
 }

+ 1 - 1
src/mol-plugin/context.ts

@@ -91,7 +91,7 @@ export class PluginContext {
             isUpdating: this.ev.behavior<boolean>(false)
         },
         interaction: {
-            highlight: this.ev.behavior<Interactivity.HighlightEvent>({ current: Interactivity.Loci.Empty }),
+            hover: this.ev.behavior<Interactivity.HoverEvent>({ current: Interactivity.Loci.Empty, modifiers: ModifiersKeys.None, buttons: 0 }),
             click: this.ev.behavior<Interactivity.ClickEvent>({ current: Interactivity.Loci.Empty, modifiers: ModifiersKeys.None, buttons: 0 })
         },
         labels: {

+ 1 - 1
src/mol-plugin/index.ts

@@ -63,7 +63,7 @@ export const DefaultPluginSpec: PluginSpec = {
         PluginSpec.Behavior(PluginBehaviors.Representation.HighlightLoci),
         PluginSpec.Behavior(PluginBehaviors.Representation.SelectLoci),
         PluginSpec.Behavior(PluginBehaviors.Representation.DefaultLociLabelProvider),
-        PluginSpec.Behavior(PluginBehaviors.Camera.FocusLociOnSelect, { minRadius: 8, extraRadius: 4, durationMs: 250 }),
+        PluginSpec.Behavior(PluginBehaviors.Camera.FocusLoci),
         // PluginSpec.Behavior(PluginBehaviors.Labels.SceneLabels),
         PluginSpec.Behavior(PluginBehaviors.CustomProps.MolstarSecondaryStructure, { autoAttach: true }),
         PluginSpec.Behavior(PluginBehaviors.CustomProps.PDBeStructureQualityReport, { autoAttach: true, showTooltip: true }),

+ 6 - 3
src/mol-plugin/ui/sequence/residue.tsx

@@ -14,12 +14,15 @@ import { Color } from '../../../mol-util/color';
 export class Residue extends PurePluginUIComponent<{ seqIdx: number, label: string, parent: Sequence<any>, marker: number, color: Color }> {
 
     mouseEnter = (e: React.MouseEvent) => {
+        const buttons = getButtons(e.nativeEvent)
         const modifiers = getModifiers(e.nativeEvent)
-        this.props.parent.highlight(this.props.seqIdx, modifiers);
+        this.props.parent.hover(this.props.seqIdx, buttons, modifiers);
     }
 
-    mouseLeave = () => {
-        this.props.parent.highlight();
+    mouseLeave = (e: React.MouseEvent) => {
+        const buttons = getButtons(e.nativeEvent)
+        const modifiers = getModifiers(e.nativeEvent)
+        this.props.parent.hover(undefined, buttons, modifiers);
     }
 
     mouseDown = (e: React.MouseEvent) => {

+ 5 - 5
src/mol-plugin/ui/sequence/sequence.tsx

@@ -51,21 +51,21 @@ export class Sequence<P extends SequenceProps> extends PluginUIComponent<P, Sequ
 
     componentDidMount() {
         this.plugin.interactivity.lociHighlights.addProvider(this.lociHighlightProvider)
-        this.plugin.interactivity.lociSelections.addProvider(this.lociSelectionProvider)
+        this.plugin.interactivity.lociSelects.addProvider(this.lociSelectionProvider)
     }
 
     componentWillUnmount() {
         this.plugin.interactivity.lociHighlights.removeProvider(this.lociHighlightProvider)
-        this.plugin.interactivity.lociSelections.removeProvider(this.lociSelectionProvider)
+        this.plugin.interactivity.lociSelects.removeProvider(this.lociSelectionProvider)
     }
 
-    highlight(seqId?: number, modifiers?: ModifiersKeys) {
-        const ev = { current: Interactivity.Loci.Empty, modifiers }
+    hover(seqId: number | undefined, buttons: ButtonsType, modifiers: ModifiersKeys) {
+        const ev = { current: Interactivity.Loci.Empty, buttons, modifiers }
         if (seqId !== undefined) {
             const loci = this.props.sequenceWrapper.getLoci(seqId);
             if (!StructureElement.Loci.isEmpty(loci)) ev.current = { loci };
         }
-        this.plugin.behaviors.interaction.highlight.next(ev)
+        this.plugin.behaviors.interaction.hover.next(ev)
     }
 
     click(seqId: number | undefined, buttons: ButtonsType, modifiers: ModifiersKeys) {

+ 1 - 1
src/mol-plugin/ui/viewport.tsx

@@ -127,7 +127,7 @@ export class Viewport extends PluginUIComponent<{ }, ViewportState> {
         this.subscribe(canvas3d.input.resize, this.handleResize);
 
         this.subscribe(canvas3d.interaction.click, e => this.plugin.behaviors.interaction.click.next(e));
-        this.subscribe(canvas3d.interaction.highlight, e => this.plugin.behaviors.interaction.highlight.next(e));
+        this.subscribe(canvas3d.interaction.hover, e => this.plugin.behaviors.interaction.hover.next(e));
         this.subscribe(this.plugin.layout.events.updated, () => {
             setTimeout(this.handleResize, 50);
         });

+ 72 - 74
src/mol-plugin/util/interactivity.ts

@@ -5,7 +5,7 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { Loci as ModelLoci, EmptyLoci } from '../../mol-model/loci';
+import { Loci as ModelLoci, EmptyLoci, EveryLoci, isEmptyLoci } from '../../mol-model/loci';
 import { ModifiersKeys, ButtonsType } from '../../mol-util/input/input-observer';
 import { Representation } from '../../mol-repr/representation';
 import { StructureElement, Link } from '../../mol-model/structure';
@@ -20,7 +20,7 @@ import { capitalize } from '../../mol-util/string';
 export { Interactivity }
 
 class Interactivity {
-    readonly lociSelections: Interactivity.LociSelectionManager;
+    readonly lociSelects: Interactivity.LociSelectManager;
     readonly lociHighlights: Interactivity.LociHighlightManager;
 
     private _props = PD.getDefaultValues(Interactivity.Params)
@@ -28,14 +28,14 @@ class Interactivity {
     get props() { return { ...this._props } }
     setProps(props: Partial<Interactivity.Props>) {
         Object.assign(this._props, props)
-        this.lociSelections.setProps(this._props)
+        this.lociSelects.setProps(this._props)
         this.lociHighlights.setProps(this._props)
     }
 
     constructor(readonly ctx: PluginContext, props: Partial<Interactivity.Props> = {}) {
         Object.assign(this._props, props)
 
-        this.lociSelections = new Interactivity.LociSelectionManager(ctx, this._props);
+        this.lociSelects = new Interactivity.LociSelectManager(ctx, this._props);
         this.lociHighlights = new Interactivity.LociHighlightManager(ctx, this._props);
 
         PluginCommands.Interactivity.SetProps.subscribe(ctx, e => this.setProps(e.props));
@@ -62,16 +62,17 @@ namespace Interactivity {
     const GranularityOptions = Object.keys(Granularity).map(n => [n, capitalize(n)]) as [Granularity, string][]
 
     export const Params = {
-        granularity: PD.Select('residue', GranularityOptions),
+        granularity: PD.Select('residue', GranularityOptions, { description: 'Controls if selections are expanded to whole residues, chains, structures, or left as atoms and coarse elements' }),
     }
-    export type Props = PD.Values<typeof Params>
+    export type Params = typeof Params
+    export type Props = PD.Values<Params>
 
-    export interface HighlightEvent { current: Loci, modifiers?: ModifiersKeys }
+    export interface HoverEvent { current: Loci, buttons: ButtonsType, modifiers: ModifiersKeys }
     export interface ClickEvent { current: Loci, buttons: ButtonsType, modifiers: ModifiersKeys }
 
     export type LociMarkProvider = (loci: Loci, action: MarkerAction) => void
 
-    export abstract class LociMarkManager<MarkEvent extends any> {
+    export abstract class LociMarkManager {
         protected providers: LociMarkProvider[] = [];
         protected sel: StructureElementSelectionManager
 
@@ -114,27 +115,21 @@ namespace Interactivity {
             for (let p of this.providers) p(current, action);
         }
 
-        abstract apply(e: MarkEvent): void
-
         constructor(public readonly ctx: PluginContext, props: Partial<Props> = {}) {
             this.sel = ctx.helpers.structureSelectionManager
             this.setProps(props)
         }
     }
 
-    export class LociHighlightManager extends LociMarkManager<HighlightEvent> {
-        private prev: Loci = { loci: EmptyLoci, repr: void 0 };
+    //
 
-        apply(e: HighlightEvent) {
-            const { current, modifiers } = e
+    export class LociHighlightManager extends LociMarkManager {
+        private prev: Loci = { loci: EmptyLoci, repr: void 0 };
 
-            const normalized: Loci<ModelLoci> = this.normalizedLoci(current)
+        highlightOnly(current: Loci) {
+            const normalized = this.normalizedLoci(current)
             if (StructureElement.Loci.is(normalized.loci)) {
-                let loci: StructureElement.Loci = normalized.loci;
-                if (modifiers && modifiers.shift) {
-                    loci = this.sel.tryGetRange(loci) || loci;
-                }
-
+                const loci = normalized.loci;
                 this.mark(this.prev, MarkerAction.RemoveHighlight);
                 const toHighlight = { loci, repr: normalized.repr };
                 this.mark(toHighlight, MarkerAction.Highlight);
@@ -148,78 +143,81 @@ namespace Interactivity {
             }
         }
 
-        constructor(ctx: PluginContext, props: Partial<Props> = {}) {
-            super(ctx, props)
-            ctx.behaviors.interaction.highlight.subscribe(e => this.apply(e));
+        highlightOnlyExtend(current: Loci) {
+            const normalized = this.normalizedLoci(current)
+            if (StructureElement.Loci.is(normalized.loci)) {
+                const loci = this.sel.tryGetRange(normalized.loci) || normalized.loci;
+                this.mark(this.prev, MarkerAction.RemoveHighlight);
+                const toHighlight = { loci, repr: normalized.repr };
+                this.mark(toHighlight, MarkerAction.Highlight);
+                this.prev = toHighlight;
+            }
         }
     }
 
-    export class LociSelectionManager extends LociMarkManager<ClickEvent> {
-        toggleSel(current: Loci<ModelLoci>) {
-            if (this.sel.has(current.loci)) {
-                this.sel.remove(current.loci);
-                this.mark(current, MarkerAction.Deselect);
+    //
+
+    export class LociSelectManager extends LociMarkManager {
+        selectToggle(current: Loci<ModelLoci>) {
+            const normalized = this.normalizedLoci(current)
+            if (StructureElement.Loci.is(normalized.loci)) {
+                this.toggleSel(normalized);
             } else {
-                this.sel.add(current.loci);
-                this.mark(current, MarkerAction.Select);
+                this.mark(normalized, MarkerAction.Toggle);
             }
         }
 
-        // TODO create better API that is independent of a `ClickEvent`
-        apply(e: ClickEvent) {
-            const { current, buttons, modifiers } = e
-            const normalized: Loci<ModelLoci> = this.normalizedLoci(current)
-            if (normalized.loci.kind === 'empty-loci') {
-                if (modifiers.control && buttons === ButtonsType.Flag.Secondary) {
-                    // clear the selection on Ctrl + Right-Click on empty
-                    const sels = this.sel.clear();
-                    for (const s of sels) this.mark({ loci: s }, MarkerAction.Deselect);
-                }
-            } else if (StructureElement.Loci.is(normalized.loci)) {
-                if (modifiers.control && buttons === ButtonsType.Flag.Secondary) {
-                    // select only the current element on Ctrl + Right-Click
-                    const old = this.sel.get(normalized.loci.structure);
-                    this.mark({ loci: old }, MarkerAction.Deselect);
-                    this.sel.set(normalized.loci);
-                    this.mark(normalized, MarkerAction.Select);
-                } else if (modifiers.control && buttons === ButtonsType.Flag.Primary) {
-                    // toggle current element on Ctrl + Left-Click
-                    this.toggleSel(normalized as Representation.Loci<StructureElement.Loci>);
-                } else if (modifiers.shift && buttons === ButtonsType.Flag.Primary) {
-                    // try to extend sequence on Shift + Left-Click
-                    let loci: StructureElement.Loci = normalized.loci;
-                    if (modifiers.shift) {
-                        loci = this.sel.tryGetRange(loci) || loci;
-                    }
-                    this.toggleSel({ loci, repr: normalized.repr });
-                }
-            } else {
-                if (!ButtonsType.has(buttons, ButtonsType.Flag.Secondary)) return;
-                for (let p of this.providers) p(normalized, MarkerAction.Toggle);
+        selectExtend(current: Loci<ModelLoci>) {
+            const normalized = this.normalizedLoci(current)
+            if (StructureElement.Loci.is(normalized.loci)) {
+                const loci = this.sel.tryGetRange(normalized.loci) || normalized.loci;
+                this.toggleSel({ loci, repr: normalized.repr });
+            }
+        }
+
+        select(current: Loci<ModelLoci>) {
+            const normalized = this.normalizedLoci(current)
+            if (StructureElement.Loci.is(normalized.loci)) {
+                this.sel.add(normalized.loci);
             }
+            this.mark(normalized, MarkerAction.Select);
         }
 
-        add(current: Loci<ModelLoci>) {
-            const normalized: Loci<ModelLoci> = this.normalizedLoci(current, false)
-            this.sel.add(normalized.loci);
+        selectOnly(current: Loci<ModelLoci>) {
+            this.deselectAll()
+            const normalized = this.normalizedLoci(current)
+            if (StructureElement.Loci.is(normalized.loci)) {
+                this.sel.set(normalized.loci);
+            }
             this.mark(normalized, MarkerAction.Select);
         }
 
-        remove(current: Loci<ModelLoci>) {
-            const normalized: Loci<ModelLoci> = this.normalizedLoci(current, false)
-            this.sel.remove(normalized.loci);
+        deselect(current: Loci<ModelLoci>) {
+            const normalized = this.normalizedLoci(current)
+            if (StructureElement.Loci.is(normalized.loci)) {
+                this.sel.remove(normalized.loci);
+            }
             this.mark(normalized, MarkerAction.Deselect);
         }
 
-        only(current: Loci<ModelLoci>) {
-            const sels = this.sel.clear();
-            for (const s of sels) this.mark({ loci: s }, MarkerAction.Deselect);
-            this.add(current);
+        deselectAll() {
+            this.sel.clear();
+            this.mark({ loci: EveryLoci }, MarkerAction.Deselect);
+        }
+
+        deselectAllOnEmpty(current: Loci<ModelLoci>) {
+            const normalized = this.normalizedLoci(current)
+            if (isEmptyLoci(normalized.loci)) this.deselectAll()
         }
 
-        constructor(ctx: PluginContext, props: Partial<Props> = {}) {
-            super(ctx, props)
-            ctx.behaviors.interaction.click.subscribe(e => this.apply(e));
+        private toggleSel(current: Loci<ModelLoci>) {
+            if (this.sel.has(current.loci)) {
+                this.sel.remove(current.loci);
+                this.mark(current, MarkerAction.Deselect);
+            } else {
+                this.sel.add(current.loci);
+                this.mark(current, MarkerAction.Select);
+            }
         }
     }
 }

+ 1 - 0
src/mol-plugin/util/structure-element-selection.ts

@@ -87,6 +87,7 @@ class StructureElementSelectionManager {
         return EmptyLoci;
     }
 
+    /** Removes all selections and returns them */
     clear() {
         const keys = this.entries.keys();
         const selections: StructureElement.Loci[] = [];

+ 3 - 3
src/mol-plugin/util/structure-selection-helper.ts

@@ -154,13 +154,13 @@ export class StructureSelectionHelper {
     private _set(modifier: SelectionModifier, loci: Loci) {
         switch (modifier) {
             case 'add':
-                this.plugin.interactivity.lociSelections.add({ loci })
+                this.plugin.interactivity.lociSelects.select({ loci })
                 break
             case 'remove':
-                this.plugin.interactivity.lociSelections.remove({ loci })
+                this.plugin.interactivity.lociSelects.deselect({ loci })
                 break
             case 'only':
-                this.plugin.interactivity.lociSelections.only({ loci })
+                this.plugin.interactivity.lociSelects.selectOnly({ loci })
                 break
         }
     }

+ 103 - 0
src/mol-util/binding.ts

@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ButtonsType, ModifiersKeys } from './input/input-observer';
+import { interpolate, stringToWords } from './string';
+
+export { Binding }
+
+interface Binding {
+    trigger: Binding.Trigger
+    description: string
+}
+
+function Binding(trigger: Binding.Trigger, description = '') {
+    return Binding.create(trigger, description)
+}
+
+namespace Binding {
+    export function create(trigger: Trigger, description = ''): Binding {
+        return { trigger, description }
+    }
+
+    export const Empty: Binding = { trigger: {}, description: '' }
+    export function isEmpty(binding: Binding) {
+        return binding.trigger.buttons === undefined && binding.trigger.modifiers === undefined
+    }
+
+    export function match(binding: Binding, buttons: ButtonsType, modifiers: ModifiersKeys) {
+        return Trigger.match(binding.trigger, buttons, modifiers)
+    }
+
+    export function format(binding: Binding, name = '') {
+        const help = binding.description || stringToWords(name)
+        return interpolate(help, { trigger: Trigger.format(binding.trigger) })
+    }
+
+    export interface Trigger {
+        buttons?: ButtonsType,
+        modifiers?: ModifiersKeys
+    }
+
+    export function Trigger(buttons?: ButtonsType, modifiers?: ModifiersKeys) {
+        return Trigger.create(buttons, modifiers)
+    }
+
+    export namespace Trigger {
+        export function create(buttons?: ButtonsType, modifiers?: ModifiersKeys): Trigger {
+            return { buttons, modifiers }
+        }
+        export const Empty: Trigger = {}
+
+        export function match(trigger: Trigger, buttons: ButtonsType, modifiers: ModifiersKeys): boolean {
+            const { buttons: b, modifiers: m } = trigger
+            return b !== undefined &&
+                (b === buttons || ButtonsType.has(b, buttons)) &&
+                (!m || ModifiersKeys.areEqual(m, modifiers))
+        }
+
+        export function format(trigger: Trigger) {
+            const s: string[] = []
+            const b = formatButtons(trigger.buttons)
+            if (b) s.push(b)
+            const m = formatModifiers(trigger.modifiers)
+            if (m) s.push(m)
+            return s.join(' + ')
+        }
+    }
+}
+
+const B = ButtonsType
+
+function formatButtons(buttons?: ButtonsType) {
+    const s: string[] = []
+    if (buttons === undefined) {
+        s.push('any button')
+    } else if (buttons === 0) {
+        s.push('no button')
+    } else {
+        if (B.has(buttons, B.Flag.Primary)) s.push('left button')
+        if (B.has(buttons, B.Flag.Secondary)) s.push('right button')
+        if (B.has(buttons, B.Flag.Auxilary)) s.push('wheel/middle button')
+        if (B.has(buttons, B.Flag.Forth)) s.push('three fingers')
+    }
+    return s.join(' + ')
+}
+
+function formatModifiers(modifiers?: ModifiersKeys) {
+    const s: string[] = []
+    if (modifiers) {
+        if (modifiers.alt) s.push('alt')
+        if (modifiers.control) s.push('control')
+        if (modifiers.meta) s.push('meta/command')
+        if (modifiers.shift) s.push('shift')
+
+        if (s.length === 0) s.push('no key')
+    } else {
+        s.push('any key')
+    }
+    return s.join(' + ')
+}