diff --git a/src/mol-canvas3d/canvas3d.ts b/src/mol-canvas3d/canvas3d.ts index 99838ccdef859a9757fd565285aea2449ea6bea4..0950c4ced563b65fe1efd695d65de4bd84dbef0f 100644 --- a/src/mol-canvas3d/canvas3d.ts +++ b/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 } diff --git a/src/mol-canvas3d/controls/bindings.ts b/src/mol-canvas3d/controls/bindings.ts deleted file mode 100644 index 923b2824db4f9a1b216668a87642bf558c526e60..0000000000000000000000000000000000000000 --- a/src/mol-canvas3d/controls/bindings.ts +++ /dev/null @@ -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, - } - } -} \ No newline at end of file diff --git a/src/mol-canvas3d/controls/trackball.ts b/src/mol-canvas3d/controls/trackball.ts index 6898857e0ca142226ffa7f698296029dd7648c1d..3fd77fb7f9a316a6167c921cd1c9a1ae28c5c871 100644 --- a/src/mol-canvas3d/controls/trackball.ts +++ b/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 } diff --git a/src/mol-canvas3d/helper/interaction-events.ts b/src/mol-canvas3d/helper/interaction-events.ts index 2340dcf4a19a7fa0d3bb8a14132f34b8d88a1092..3c597abea2f62e7465286cfbfc2dfa6a188981e3 100644 --- a/src/mol-canvas3d/helper/interaction-events.ts +++ b/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(() => { diff --git a/src/mol-plugin/behavior/behavior.ts b/src/mol-plugin/behavior/behavior.ts index 12f0389f137f4bc4832dd7803098908b9754d98c..60e1286a0ac20088d46ff10e72335f911ec56e53 100644 --- a/src/mol-plugin/behavior/behavior.ts +++ b/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) { } } } \ No newline at end of file diff --git a/src/mol-plugin/behavior/dynamic/camera.ts b/src/mol-plugin/behavior/dynamic/camera.ts index e2b70b3ad290ae5f27c64642d20dfbedb6d4b8a7..6441b9518f15fb821f5b3354f600ca56fc1967a6 100644 --- a/src/mol-plugin/behavior/dynamic/camera.ts +++ b/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' } }); \ No newline at end of file diff --git a/src/mol-plugin/behavior/dynamic/representation.ts b/src/mol-plugin/behavior/dynamic/representation.ts index 680e20d87ef9195694e7b0bb1deda7f97f2829a6..488d2540a17f20415e161ad7b0e287a2e410e922 100644 --- a/src/mol-plugin/behavior/dynamic/representation.ts +++ b/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' } }); diff --git a/src/mol-plugin/behavior/dynamic/selection/structure-representation-interaction.ts b/src/mol-plugin/behavior/dynamic/selection/structure-representation-interaction.ts index eb315686ac60abbd1a9e665851524618b146ad64..0fd9ad7bcec45451eb2c1eacd21a2b57eda4a00d 100644 --- a/src/mol-plugin/behavior/dynamic/selection/structure-representation-interaction.ts +++ b/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 }); \ No newline at end of file diff --git a/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts b/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts index efbf984c8b414e94571038c3253d63ec4597f92b..23bf5248d954aeeb989a880941eee79d89c3472f 100644 --- a/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts +++ b/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); } } } \ No newline at end of file diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts index fbd4c0a2d424070c870272933f22f4b4daf9e38c..078f17989af87b595cb239031f3af681af3742bd 100644 --- a/src/mol-plugin/context.ts +++ b/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: { diff --git a/src/mol-plugin/index.ts b/src/mol-plugin/index.ts index 72b2e78270e506c9c30fe8f89d3c725fdb774757..4ab87556731378f86b8669fc568b0d7f847896de 100644 --- a/src/mol-plugin/index.ts +++ b/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 }), diff --git a/src/mol-plugin/ui/sequence/residue.tsx b/src/mol-plugin/ui/sequence/residue.tsx index 35a97ea2609cb516fe10f911e2d11755969c4d91..e2ffd4fc34eef796dd84567a944c7e4221f631b2 100644 --- a/src/mol-plugin/ui/sequence/residue.tsx +++ b/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) => { diff --git a/src/mol-plugin/ui/sequence/sequence.tsx b/src/mol-plugin/ui/sequence/sequence.tsx index d221eb5288d5d62e263f019ee6fa4ce14fcc3c15..50234a175c063d2e8249ad23fb663ea3fe43c970 100644 --- a/src/mol-plugin/ui/sequence/sequence.tsx +++ b/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) { diff --git a/src/mol-plugin/ui/viewport.tsx b/src/mol-plugin/ui/viewport.tsx index 1c034b06385ca7a0dbf4feb5c69afe8de6e5df23..3839a41d23bdb48995bd2f4b3cdeadcabfb98d2a 100644 --- a/src/mol-plugin/ui/viewport.tsx +++ b/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); }); diff --git a/src/mol-plugin/util/interactivity.ts b/src/mol-plugin/util/interactivity.ts index 1197cd560e05f71ccfe505091383d1da3f55e83a..1bb7bc1d280d4aac12c997636eb4759cded1bac1 100644 --- a/src/mol-plugin/util/interactivity.ts +++ b/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); + } } } } \ No newline at end of file diff --git a/src/mol-plugin/util/structure-element-selection.ts b/src/mol-plugin/util/structure-element-selection.ts index cbc8c83201fc2899d048e602031ec7660628f1a9..fb1a96537c847e1f3c9c1aa86fd446a255c46836 100644 --- a/src/mol-plugin/util/structure-element-selection.ts +++ b/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[] = []; diff --git a/src/mol-plugin/util/structure-selection-helper.ts b/src/mol-plugin/util/structure-selection-helper.ts index 49ca344c4e77f4e2f1f6a35b1c789ad3b9be47c8..e06e9c50ae4770227ce3fc87b693e9d299bbd595 100644 --- a/src/mol-plugin/util/structure-selection-helper.ts +++ b/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 } } diff --git a/src/mol-util/binding.ts b/src/mol-util/binding.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a10616325a5540e251d74c9efc4f7a0cf92dbb6 --- /dev/null +++ b/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(' + ') +} \ No newline at end of file