Skip to content

Commit 79a6a6a

Browse files
authored
Pan to CB (#9273)
When the CB is opened, pan to show it. Large screen: <video src="https://github.com/enso-org/enso/assets/1047859/1a07c8cc-5818-420a-9fb3-1d1cb308cb87"> Small screen: <video src="https://github.com/enso-org/enso/assets/1047859/a9f18df5-c0ca-426c-959a-bda5cd077541"> # Important Notes A prioritized-coordinates approach is used to adjust panning goals based on screen space: - Fitting the input area is highest-priority. - If possible, the whole component panel area will be fit. - If possible, the visualization preview will be fit. - If there's extra room, margins will be included; the top and left are prioritized because those margins prevent overlap with fixed UI elements.
1 parent e930738 commit 79a6a6a

File tree

6 files changed

+179
-17
lines changed

6 files changed

+179
-17
lines changed

app/gui2/e2e/componentBrowser.spec.ts

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,38 +13,39 @@ async function deselectAllNodes(page: Page) {
1313
await expect(page.locator('.GraphNode.selected')).toHaveCount(0)
1414
}
1515

16-
test('Different ways of opening Component Browser', async ({ page }) => {
17-
await actions.goToGraph(page)
16+
async function expectAndCancelBrowser(page: Page, expectedInput: string) {
1817
const nodeCount = await locate.graphNode(page).count()
18+
await expect(locate.componentBrowser(page)).toExist()
19+
await expect(locate.componentBrowserEntry(page)).toExist()
20+
await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue(expectedInput)
21+
await expect(locate.componentBrowserInput(page).locator('input')).toBeInViewport()
22+
await page.keyboard.press('Escape')
23+
await expect(locate.componentBrowser(page)).not.toBeVisible()
24+
await expect(locate.graphNode(page)).toHaveCount(nodeCount)
25+
}
1926

20-
async function expectAndCancelBrowser(expectedInput: string) {
21-
await expect(locate.componentBrowser(page)).toExist()
22-
await expect(locate.componentBrowserEntry(page)).toExist()
23-
await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue(expectedInput)
24-
await page.keyboard.press('Escape')
25-
await expect(locate.componentBrowser(page)).not.toBeVisible()
26-
await expect(locate.graphNode(page)).toHaveCount(nodeCount)
27-
}
27+
test('Different ways of opening Component Browser', async ({ page }) => {
28+
await actions.goToGraph(page)
2829

2930
// Without source node
3031

3132
// (+) button
3233
await locate.addNewNodeButton(page).click()
33-
await expectAndCancelBrowser('')
34+
await expectAndCancelBrowser(page, '')
3435
// Enter key
3536
await locate.graphEditor(page).press('Enter')
36-
await expectAndCancelBrowser('')
37+
await expectAndCancelBrowser(page, '')
3738

3839
// With source node
3940

4041
// (+) button
4142
await locate.graphNodeByBinding(page, 'final').click()
4243
await locate.addNewNodeButton(page).click()
43-
await expectAndCancelBrowser('final.')
44+
await expectAndCancelBrowser(page, 'final.')
4445
// Enter key
4546
await locate.graphNodeByBinding(page, 'final').click()
4647
await locate.graphEditor(page).press('Enter')
47-
await expectAndCancelBrowser('final.')
48+
await expectAndCancelBrowser(page, 'final.')
4849
// Dragging out an edge
4950
// `click` method of locator could be simpler, but `position` option doesn't work.
5051
const outputPortArea = await locate
@@ -55,15 +56,48 @@ test('Different ways of opening Component Browser', async ({ page }) => {
5556
const outputPortX = outputPortArea.x + outputPortArea.width / 2.0
5657
const outputPortY = outputPortArea.y + outputPortArea.height - 2.0
5758
await page.mouse.click(outputPortX, outputPortY)
58-
await page.mouse.click(40, 300)
59-
await expectAndCancelBrowser('final.')
59+
await page.mouse.click(100, 500)
60+
await expectAndCancelBrowser(page, 'final.')
6061
// Double-clicking port
6162
// TODO[ao] Without timeout, even the first click would be treated as double due to previous
6263
// event. Probably we need a better way to simulate double clicks.
6364
await page.waitForTimeout(600)
6465
await page.mouse.click(outputPortX, outputPortY)
6566
await page.mouse.click(outputPortX, outputPortY)
66-
await expectAndCancelBrowser('final.')
67+
await expectAndCancelBrowser(page, 'final.')
68+
})
69+
70+
test('Graph Editor pans to Component Browser', async ({ page }) => {
71+
await actions.goToGraph(page)
72+
73+
// Select node, pan out of view of it, press Enter; should pan to show node and CB
74+
await locate.graphNodeByBinding(page, 'final').click()
75+
await page.mouse.move(100, 80)
76+
await page.mouse.down({ button: 'middle' })
77+
await page.mouse.move(100, 700)
78+
await page.mouse.up({ button: 'middle' })
79+
await expect(locate.graphNodeByBinding(page, 'final')).not.toBeInViewport()
80+
await locate.graphEditor(page).press('Enter')
81+
await expect(locate.graphNodeByBinding(page, 'final')).toBeInViewport()
82+
await expectAndCancelBrowser(page, 'final.')
83+
84+
// Dragging out an edge to the bottom of the viewport; when the CB pans into view, some nodes are out of view.
85+
await page.mouse.move(100, 1100)
86+
await page.mouse.down({ button: 'middle' })
87+
await page.mouse.move(100, 80)
88+
await page.mouse.up({ button: 'middle' })
89+
await expect(locate.graphNodeByBinding(page, 'five')).toBeInViewport()
90+
const outputPortArea = await locate
91+
.graphNodeByBinding(page, 'final')
92+
.locator('.outputPortHoverArea')
93+
.boundingBox()
94+
assert(outputPortArea)
95+
const outputPortX = outputPortArea.x + outputPortArea.width / 2.0
96+
const outputPortY = outputPortArea.y + outputPortArea.height - 2.0
97+
await page.mouse.click(outputPortX, outputPortY)
98+
await page.mouse.click(100, 1550)
99+
await expect(locate.graphNodeByBinding(page, 'five')).not.toBeInViewport()
100+
await expectAndCancelBrowser(page, 'final.')
67101
})
68102

69103
test('Accepting suggestion', async ({ page }) => {

app/gui2/mock/providers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ export const graphNavigator: GraphNavigator = {
88
clientToScenePos: () => Vec2.Zero,
99
clientToSceneRect: () => Rect.Zero,
1010
panAndZoomTo: () => {},
11+
panTo: () => {},
1112
transform: '',
1213
prescaledTransform: '',
1314
translate: Vec2.Zero,
15+
targetScale: 1,
1416
scale: 1,
1517
sceneMousePos: Vec2.Zero,
1618
viewBox: '',

app/gui2/src/components/ComponentBrowser.vue

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { targetIsOutside } from '@/util/autoBlur'
2222
import { tryGetIndex } from '@/util/data/array'
2323
import type { Opt } from '@/util/data/opt'
2424
import { allRanges } from '@/util/data/range'
25+
import { Rect } from '@/util/data/rect'
2526
import { Vec2 } from '@/util/data/vec2'
2627
import { debouncedGetter } from '@/util/reactivity'
2728
import type { SuggestionId } from 'shared/languageServerTypes/suggestions'
@@ -33,6 +34,17 @@ const TOP_BAR_HEIGHT = 32
3334
// Difference in position between the component browser and a node for the input of the component browser to
3435
// be placed at the same position as the node.
3536
const COMPONENT_BROWSER_TO_NODE_OFFSET = new Vec2(-4, -4)
37+
const WIDTH = 600
38+
const INPUT_AREA_HEIGHT = 40
39+
const PANELS_HEIGHT = 384
40+
// Height of the visualization area, starting from the bottom of the input area.
41+
const VISUALIZATION_HEIGHT = 190
42+
const PAN_MARGINS = {
43+
top: 48,
44+
bottom: 40,
45+
left: 80,
46+
right: 40,
47+
}
3648
3749
const projectStore = useProjectStore()
3850
const suggestionDbStore = useSuggestionDbStore()
@@ -66,6 +78,39 @@ const cbOpen: Interaction = {
6678
},
6779
}
6880
81+
function scaleValues<T extends Record<any, number>>(
82+
values: T,
83+
scale: number,
84+
): { [Key in keyof T]: number } {
85+
return Object.fromEntries(
86+
Object.entries(values).map(([key, value]) => [key, value * scale]),
87+
) as any
88+
}
89+
90+
function panIntoView() {
91+
// Factor that converts client-coordinate dimensions to scene-coordinate dimensions.
92+
const scale = 1 / props.navigator.targetScale
93+
const origin = props.nodePosition.add(COMPONENT_BROWSER_TO_NODE_OFFSET.scale(scale))
94+
const inputArea = new Rect(origin, new Vec2(WIDTH, INPUT_AREA_HEIGHT).scale(scale))
95+
const panelsAreaDimensions = new Vec2(WIDTH, PANELS_HEIGHT).scale(scale)
96+
const panelsArea = new Rect(origin.sub(new Vec2(0, panelsAreaDimensions.y)), panelsAreaDimensions)
97+
const vizHeight = VISUALIZATION_HEIGHT * scale
98+
const margins = scaleValues(PAN_MARGINS, scale)
99+
props.navigator.panTo([
100+
// Always include the bottom-left of the input area.
101+
{ x: inputArea.left, y: inputArea.bottom },
102+
// Try to reach the top-right corner of the panels.
103+
{ x: inputArea.right, y: panelsArea.top },
104+
// Extend down to include the visualization.
105+
{ y: inputArea.bottom + vizHeight },
106+
// Top (and left) margins are more important than bottom (and right) margins because the screen has controls across
107+
// the top and on the left.
108+
{ x: inputArea.left - margins.left, y: panelsArea.top - margins.top },
109+
// If the screen is very spacious, even the bottom right gets some breathing room.
110+
{ x: inputArea.right + margins.right, y: inputArea.bottom + vizHeight + margins.bottom },
111+
])
112+
}
113+
69114
onMounted(() => {
70115
interaction.setCurrent(cbOpen)
71116
input.reset(props.usage)
@@ -76,6 +121,7 @@ onMounted(() => {
76121
'Component Browser input element was not mounted. This is not expected and may break the Component Browser',
77122
)
78123
}
124+
panIntoView()
79125
})
80126
81127
// === Position ===

app/gui2/src/composables/navigator.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
9393
targetCenter.value = new Vec2(centerX, centerY)
9494
}
9595

96+
/** Pan to include the given prioritized list of coordinates.
97+
*
98+
* The view will be offset to include each coordinate, unless the coordinate cannot be fit in the viewport without
99+
* losing a previous (higher-priority) coordinate; in that case, shift the viewport as close as possible to the
100+
* coordinate while still satisfying the more important constraints.
101+
*
102+
* If all provided constraints can be met, the viewport will be moved the shortest distance that fits all the
103+
* coordinates in view.
104+
*/
105+
function panTo(points: Partial<Vec2>[]) {
106+
let target = viewport.value
107+
for (const point of points.reverse()) target = target.offsetToInclude(point) ?? target
108+
targetCenter.value = target.center()
109+
}
110+
96111
let zoomPivot = Vec2.Zero
97112
const zoomPointer = usePointer((pos, _event, ty) => {
98113
if (ty === 'start') {
@@ -225,6 +240,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
225240
},
226241
},
227242
translate,
243+
targetScale,
228244
scale,
229245
viewBox,
230246
transform,
@@ -234,6 +250,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
234250
clientToScenePos,
235251
clientToSceneRect,
236252
panAndZoomTo,
253+
panTo,
237254
viewport,
238255
})
239256
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Rect } from '@/util/data/rect'
2+
import { Vec2 } from '@/util/data/vec2'
3+
import { fc, test } from '@fast-check/vitest'
4+
import { expect } from 'vitest'
5+
6+
test.prop({
7+
rectX: fc.nat(),
8+
rectY: fc.nat(),
9+
width: fc.nat(),
10+
height: fc.nat(),
11+
x: fc.nat(),
12+
y: fc.nat(),
13+
})('offsetToInclude', ({ rectX, rectY, width, height, x, y }) => {
14+
const rect = new Rect(new Vec2(rectX, rectY), new Vec2(width, height))
15+
const point = new Vec2(x, y)
16+
const offsetRect = rect.offsetToInclude(point)
17+
expect(
18+
offsetRect === undefined,
19+
'`offsetToInclude` returns `undefined` iffi the original `Rect` contains the point.',
20+
).toBe(rect.contains(point))
21+
if (offsetRect === undefined) return
22+
expect(
23+
offsetRect.size === rect.size,
24+
'The result of `offsetToInclude` is the same size as the input `Rect`.',
25+
)
26+
expect(offsetRect.contains(point), 'The result of `offsetToInclude` contains the point.')
27+
const dx = Math.max(0, rect.left - point.x, point.x - rect.right)
28+
const dy = Math.max(0, rect.top - point.y, point.y - rect.bottom)
29+
expect(
30+
Math.abs(offsetRect.left - rect.left),
31+
'`offsetToInclude` has shifted the `Rect` by the minimum distance that reaches the point on the x-axis.',
32+
).toBe(dx)
33+
expect(
34+
Math.abs(offsetRect.top - rect.top),
35+
'`offsetToInclude` has shifted the `Rect` by the minimum distance that reaches the point on the y-axis.',
36+
).toBe(dy)
37+
})

app/gui2/src/util/data/rect.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ export class Rect {
6868
)
6969
}
7070

71+
contains(coord: Partial<Vec2>): boolean {
72+
return (
73+
(coord.x == null || (this.left <= coord.x && this.right >= coord.x)) &&
74+
(coord.y == null || (this.top <= coord.y && this.bottom >= coord.y))
75+
)
76+
}
77+
7178
center(): Vec2 {
7279
return this.pos.addScaled(this.size, 0.5)
7380
}
@@ -87,6 +94,25 @@ export class Rect {
8794
intersects(other: Rect): boolean {
8895
return this.intersectsX(other) && this.intersectsY(other)
8996
}
97+
98+
/** If this `Rect` already includes `coord`, return `undefined`; otherwise, return a new `Rect` that has been shifted
99+
* by the minimum distance that causes it to include the coordinate. The coordinate may be a point or may specify
100+
* only an `x` or `y` bound to leave the other dimension unchanged.
101+
*/
102+
offsetToInclude(coord: Partial<Vec2>): Rect | undefined {
103+
const newX =
104+
coord.x == null ? undefined
105+
: coord.x < this.left ? coord.x
106+
: coord.x > this.right ? coord.x - this.width
107+
: undefined
108+
const newY =
109+
coord.y == null ? undefined
110+
: coord.y < this.top ? coord.y
111+
: coord.y > this.bottom ? coord.y - this.height
112+
: undefined
113+
if (newX == null && newY == null) return
114+
return new Rect(new Vec2(newX ?? this.pos.x, newY ?? this.pos.y), this.size)
115+
}
90116
}
91117

92118
Rect.Zero = new Rect(Vec2.Zero, Vec2.Zero)

0 commit comments

Comments
 (0)