Skip to content

Commit

Permalink
Context menu, copy button, multi-component actions (#11690)
Browse files Browse the repository at this point in the history
Context menu, copy button, multi-component actions

https://github.com/user-attachments/assets/14243102-3848-43fc-82bb-a48648536985

- The 'More' menu can now be opened under the mouse, through the context menu action (right click/control-click on Mac/menu button on keyboard).
- Add copy-components button to menu.
- The menu can now be opened while multiple components are selected; if the clicked component was among the selected components, the selection will be preserved. Some menu actions--currently *copy* and *delete*, apply to all selected components. These actions will change their displayed labels when multiple components are selected. If a single-component action is executed, the component it was applied to will become the sole selection.

Fixes #11633, #11634

(cherry picked from commit 0b6b1f0)
  • Loading branch information
kazcw authored and jdunkerley committed Nov 29, 2024
1 parent fbe9e65 commit e82ce10
Show file tree
Hide file tree
Showing 41 changed files with 1,031 additions and 422 deletions.
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@
suitable type][11612].
- [Visualizations on components are slightly transparent when not
focused][11582].
- [New design for vector-editing widget][11620]
- [The component menu can be opened by right-click; supports operations on
multiple components; has a 'Copy Component' button][11690]
- [New design for vector-editing widget][11620].
- [Default values on widgets are displayed in italic][11666].
- [The `:` type operator can now be chained][11671].
- [Fixed bug causing Table Visualization to show wrong data][11684].

[11151]: https://github.com/enso-org/enso/pull/11151
Expand All @@ -64,7 +66,7 @@
[11612]: https://github.com/enso-org/enso/pull/11612
[11620]: https://github.com/enso-org/enso/pull/11620
[11666]: https://github.com/enso-org/enso/pull/11666
[11671]: https://github.com/enso-org/enso/pull/11671
[11690]: https://github.com/enso-org/enso/pull/11690
[11684]: https://github.com/enso-org/enso/pull/11684

#### Enso Standard Library
Expand All @@ -90,8 +92,10 @@
#### Enso Language & Runtime

- [Arguments in constructor definitions may now be on their own lines][11374]
- [The `:` type operator can now be chained][11671].

[11374]: https://github.com/enso-org/enso/pull/11374
[11671]: https://github.com/enso-org/enso/pull/11671

# Enso 2024.4

Expand Down
14 changes: 14 additions & 0 deletions app/common/src/utilities/data/iter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export function map<T, U>(it: Iterable<T>, f: (value: T) => U): IterableIterator
return mapIterator(it[Symbol.iterator](), f)
}

export function filter<T, S extends T>(
iter: Iterable<T>,
include: (value: T) => value is S,
): IterableIterator<S>
export function filter<T>(iter: Iterable<T>, include: (value: T) => boolean): IterableIterator<T>
/**
* Return an {@link Iterable} that `yield`s only the values from the given source iterable
* that pass the given predicate.
Expand Down Expand Up @@ -179,6 +184,15 @@ export function every<T>(iter: Iterable<T>, f: (value: T) => boolean): boolean {
return true
}

/**
* Returns whether the predicate returned `true` for any values yielded by the provided iterator. Short-circuiting.
* Returns `false` if the iterator doesn't yield any values.
*/
export function some<T>(iter: Iterable<T>, f: (value: T) => boolean): boolean {
for (const value of iter) if (f(value)) return true
return false
}

/** Return the first element returned by the iterable which meets the condition. */
export function find<T>(iter: Iterable<T>, f: (value: T) => boolean): T | undefined {
for (const value of iter) {
Expand Down
6 changes: 6 additions & 0 deletions app/common/src/utilities/data/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,9 @@ export function useObjectId() {
}
return { objectId }
}

/**
* Returns the union of `A` and `B`, with a type-level assertion that `A` and `B` don't have any keys in common; this
* can be used to splice together objects without the risk of collisions.
*/
export type DisjointKeysUnion<A, B> = keyof A & keyof B extends never ? A & B : never
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test, type Page } from '@playwright/test'
import * as actions from './actions'
import { expect } from './customExpect'
import { CONTROL_KEY } from './keyboard'
import * as locate from './locate'
import { edgesToNodeWithBinding, graphNodeByBinding, outputPortCoordinates } from './locate'

Expand Down Expand Up @@ -97,8 +98,7 @@ test('Conditional ports: Enabled', async ({ page }) => {
const node = graphNodeByBinding(page, 'filtered')
const conditionalPort = node.locator('.WidgetPort').filter({ hasText: /^filter$/ })

await page.keyboard.down('Meta')
await page.keyboard.down('Control')
await page.keyboard.down(CONTROL_KEY)

await expect(conditionalPort).toHaveClass(/enabled/)
const outputPort = await outputPortCoordinates(graphNodeByBinding(page, 'final'))
Expand All @@ -109,6 +109,5 @@ test('Conditional ports: Enabled', async ({ page }) => {
await conditionalPort.click({ force: true })
await expect(node.locator('.WidgetToken')).toHaveText(['final'])

await page.keyboard.up('Meta')
await page.keyboard.up('Control')
await page.keyboard.up(CONTROL_KEY)
})
56 changes: 48 additions & 8 deletions app/gui/integration-test/project-view/nodeClipboard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import test from 'playwright/test'
import test, { type Locator, type Page } from 'playwright/test'
import * as actions from './actions'
import { expect } from './customExpect'
import { CONTROL_KEY } from './keyboard'
Expand Down Expand Up @@ -28,7 +28,23 @@ test.beforeEach(async ({ page }) => {
})
})

test('Copy node with comment', async ({ page }) => {
test('Copy component with context menu', async ({ page }) => {
await actions.goToGraph(page)
const originalNodes = await locate.graphNode(page).count()
const nodeToCopy = locate.graphNodeByBinding(page, 'final')
await nodeToCopy.click({ button: 'right' })
await expect(nodeToCopy).toBeSelected()
await page
.locator('.ComponentContextMenu')
.getByRole('button', { name: 'Copy Component' })
.click()
await page.keyboard.press(`${CONTROL_KEY}+V`)
await expect(nodeToCopy).not.toBeSelected()
await expect(locate.selectedNodes(page)).toHaveCount(1)
await expect(locate.graphNode(page)).toHaveCount(originalNodes + 1)
})

test('Copy component with comment', async ({ page }) => {
await actions.goToGraph(page)

// Check state before operation.
Expand All @@ -51,7 +67,10 @@ test('Copy node with comment', async ({ page }) => {
await expect(locate.nodeComment(page)).toHaveCount(originalNodeComments + 1)
})

test('Copy multiple nodes', async ({ page }) => {
async function testCopyMultiple(
page: Page,
copyNodes: (node1: Locator, node2: Locator) => Promise<void>,
) {
await actions.goToGraph(page)

// Check state before operation.
Expand All @@ -61,13 +80,10 @@ test('Copy multiple nodes', async ({ page }) => {

// Select some nodes.
const node1 = locate.graphNodeByBinding(page, 'final')
await node1.click()
const node2 = locate.graphNodeByBinding(page, 'prod')
await node2.click({ modifiers: ['Shift'] })
await expect(node1).toBeSelected()
await expect(node2).toBeSelected()

// Copy and paste.
await page.keyboard.press(`${CONTROL_KEY}+C`)
await copyNodes(node1, node2)
await page.keyboard.press(`${CONTROL_KEY}+V`)
await expect(node1).not.toBeSelected()
await expect(node2).not.toBeSelected()
Expand All @@ -87,4 +103,28 @@ test('Copy multiple nodes', async ({ page }) => {
await expect(await edgesToNodeWithBinding(page, 'final')).toHaveCount(1 * EDGE_PARTS)
await expect(await edgesToNodeWithBinding(page, 'prod1')).toHaveCount(1 * EDGE_PARTS)
await expect(await edgesToNodeWithBinding(page, 'final1')).toHaveCount(1 * EDGE_PARTS)
}

test('Copy multiple components with keyboard shortcut', async ({ page }) => {
await testCopyMultiple(page, async (node1, node2) => {
await node1.click()
await node2.click({ modifiers: ['Shift'] })
await expect(node1).toBeSelected()
await expect(node2).toBeSelected()
await page.keyboard.press(`${CONTROL_KEY}+C`)
})
})

test('Copy multiple components with context menu', async ({ page }) => {
await testCopyMultiple(page, async (node1, node2) => {
await node1.click()
await node2.click({ modifiers: ['Shift'] })
await expect(node1).toBeSelected()
await expect(node2).toBeSelected()
await node1.click({ button: 'right' })
await page
.locator('.ComponentContextMenu')
.getByRole('button', { name: 'Copy Selected Components' })
.click()
})
})
25 changes: 25 additions & 0 deletions app/gui/integration-test/project-view/nodeComments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,31 @@ test('Start editing comment via menu', async ({ page }) => {
await expect(locate.nodeComment(node)).toBeFocused()
})

test('Start editing comment via context menu', async ({ page }) => {
await actions.goToGraph(page)
const node = locate.graphNodeByBinding(page, 'final')
await node.click({ button: 'right' })
await page.getByRole('button', { name: 'Add Comment' }).click()
await expect(locate.nodeComment(node)).toBeFocused()
})

test('Start editing comment via context menu when multiple components initially selected', async ({
page,
}) => {
await actions.goToGraph(page)
const otherNode = locate.graphNodeByBinding(page, 'sum')
await otherNode.click()
const node = locate.graphNodeByBinding(page, 'final')
await node.click({ modifiers: ['Shift'] })
const anotherNode = locate.graphNodeByBinding(page, 'list')
await anotherNode.click({ modifiers: ['Shift'] })
await node.click({ button: 'right' })
await expect(locate.selectedNodes(page)).toHaveCount(3)
await page.getByRole('button', { name: 'Add Comment' }).click()
await expect(locate.selectedNodes(page)).toHaveCount(1)
await expect(locate.nodeComment(node)).toBeFocused()
})

test('Add new comment via menu', async ({ page }) => {
await actions.goToGraph(page)
const INITIAL_NODE_COMMENTS = 1
Expand Down
25 changes: 25 additions & 0 deletions app/gui/integration-test/project-view/removingNodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,31 @@ test('Deleting selected node with delete key', async ({ page }) => {
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 1)
})

test('Deleting node with context menu', async ({ page }) => {
await actions.goToGraph(page)
const nodesCount = await locate.graphNode(page).count()
const deletedNode = locate.graphNodeByBinding(page, 'final')
await deletedNode.click({ button: 'right' })
await expect(locate.selectedNodes(page)).toHaveCount(1)
await page.getByRole('button', { name: 'Delete Component' }).click()
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 1)
})

test('Deleting multiple nodes with context menu', async ({ page }) => {
await actions.goToGraph(page)
const nodesCount = await locate.graphNode(page).count()
const deletedNode1 = locate.graphNodeByBinding(page, 'final')
await deletedNode1.click()
const deletedNode2 = locate.graphNodeByBinding(page, 'sum')
await deletedNode2.click({ modifiers: ['Shift'] })
await deletedNode2.click({ button: 'right' })
await page
.locator('.ComponentContextMenu')
.getByRole('button', { name: 'Delete Selected Components' })
.click()
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 2)
})

test('Graph can be empty', async ({ page }) => {
await actions.goToGraph(page)

Expand Down
2 changes: 1 addition & 1 deletion app/gui/src/project-view/components/ColorRing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const FIXED_RANGE_WIDTH = 1 / 16
const selectedColor = defineModel<string | undefined>()
const props = defineProps<{
matchableColors: Set<string>
matchableColors: ReadonlySet<string>
/** Angle, measured in degrees from the positive Y-axis, where the initially-selected color should be placed. */
initialColorAngle?: number
}>()
Expand Down
49 changes: 49 additions & 0 deletions app/gui/src/project-view/components/ComponentButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script setup lang="ts">
import MenuButton from '@/components/MenuButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import {
injectComponentAndSelectionButtons,
type ComponentAndSelectionButtons,
} from '@/providers/selectionButtons'
const { button: buttonName } = defineProps<{ button: keyof ComponentAndSelectionButtons }>()
const { buttons } = injectComponentAndSelectionButtons()
const button = buttons[buttonName]
</script>

<template>
<MenuButton
:data-testid="button.testid"
:disabled="button.disabled"
class="ComponentButton"
v-bind="button.state != null ? { modelValue: button.state } : {}"
@update:modelValue="button.state != null && (button.state = $event)"
@click="button.action"
>
<SvgIcon :name="button.icon" class="rowIcon" />
<span v-text="button.description" />
<span v-if="button.shortcut" class="shortcutHint" v-text="button.shortcut" />
</MenuButton>
</template>

<style scoped>
.ComponentButton {
display: flex;
align-items: center;
justify-content: left;
padding-left: 8px;
padding-right: 8px;
}
.rowIcon {
display: inline-block;
margin-right: 8px;
}
.shortcutHint {
margin-left: auto;
padding-left: 2em;
opacity: 0.8;
}
</style>
41 changes: 41 additions & 0 deletions app/gui/src/project-view/components/ComponentContextMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<script setup lang="ts">
import ComponentButton from '@/components/ComponentButton.vue'
import MenuPanel from '@/components/MenuPanel.vue'
import { type ComponentButtons } from '@/providers/componentButtons'
import { type SelectionButtons } from '@/providers/selectionButtons'
const emit = defineEmits<{ close: [] }>()
const componentButtons: (keyof ComponentButtons)[] = [
'toggleDocPanel',
'toggleVisualization',
'createNewNode',
'editingComment',
'recompute',
'pickColor',
'enterNode',
'startEditing',
]
const selectionButtons: (keyof SelectionButtons)[] = ['copy', 'deleteSelected']
const buttons = [...componentButtons, ...selectionButtons]
</script>

<template>
<MenuPanel class="ComponentContextMenu" @contextmenu.stop.prevent="emit('close')">
<ComponentButton
v-for="button in buttons"
:key="button"
:button="button"
@click.stop="emit('close')"
/>
</MenuPanel>
</template>

<style scoped>
.MenuPanel {
margin-top: 2px;
padding: 4px;
background: var(--dropdown-opened-background, var(--color-app-bg));
backdrop-filter: var(--dropdown-opened-backdrop-filter, var(--blur-app-bg));
}
</style>
Loading

0 comments on commit e82ce10

Please sign in to comment.