Self-argument access chain ports are conditional (#9439)

Ports for `PropertyAccess` AST nodes that (transitively) contain a `WidgetSelfArgument` are not targets for a disconnected edge, unless the mod key is held.

https://github.com/enso-org/enso/assets/1047859/68ac1953-c8b1-4e51-8c4c-211595e0c034

(Video shows with and without mod key)
This commit is contained in:
Kaz Wesley 2024-03-17 23:24:22 -04:00 committed by GitHub
parent 47d9e73ead
commit e1893b65af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 332 additions and 103 deletions

View File

@ -1,5 +1,4 @@
import { test, type Page } from '@playwright/test'
import assert from 'assert'
import os from 'os'
import * as actions from './actions'
import { expect } from './customExpect'
@ -47,23 +46,16 @@ test('Different ways of opening Component Browser', async ({ page }) => {
await locate.graphEditor(page).press('Enter')
await expectAndCancelBrowser(page, 'final.')
// Dragging out an edge
// `click` method of locator could be simpler, but `position` option doesn't work.
const outputPortArea = await locate
.graphNodeByBinding(page, 'final')
.locator('.outputPortHoverArea')
.boundingBox()
assert(outputPortArea)
const outputPortX = outputPortArea.x + outputPortArea.width / 2.0
const outputPortY = outputPortArea.y + outputPortArea.height - 2.0
await page.mouse.click(outputPortX, outputPortY)
const outputPort = await locate.outputPortCoordinates(locate.graphNodeByBinding(page, 'final'))
await page.mouse.click(outputPort.x, outputPort.y)
await page.mouse.click(100, 500)
await expectAndCancelBrowser(page, 'final.')
// Double-clicking port
// TODO[ao] Without timeout, even the first click would be treated as double due to previous
// event. Probably we need a better way to simulate double clicks.
await page.waitForTimeout(600)
await page.mouse.click(outputPortX, outputPortY)
await page.mouse.click(outputPortX, outputPortY)
await page.mouse.click(outputPort.x, outputPort.y)
await page.mouse.click(outputPort.x, outputPort.y)
await expectAndCancelBrowser(page, 'final.')
})
@ -108,14 +100,8 @@ test('Graph Editor pans to Component Browser', async ({ page }) => {
await page.mouse.move(100, 80)
await page.mouse.up({ button: 'middle' })
await expect(locate.graphNodeByBinding(page, 'five')).toBeInViewport()
const outputPortArea = await locate
.graphNodeByBinding(page, 'final')
.locator('.outputPortHoverArea')
.boundingBox()
assert(outputPortArea)
const outputPortX = outputPortArea.x + outputPortArea.width / 2.0
const outputPortY = outputPortArea.y + outputPortArea.height - 2.0
await page.mouse.click(outputPortX, outputPortY)
const outputPort = await locate.outputPortCoordinates(locate.graphNodeByBinding(page, 'final'))
await page.mouse.click(outputPort.x, outputPort.y)
await page.mouse.click(100, 1550)
await expect(locate.graphNodeByBinding(page, 'five')).not.toBeInViewport()
await expectAndCancelBrowser(page, 'final.')

View File

@ -1,6 +1,8 @@
import { expect, test, type Page } from '@playwright/test'
import { test, type Page } from '@playwright/test'
import * as actions from './actions'
import { edgesToNodeWithBinding, graphNodeByBinding } from './locate'
import { expect } from './customExpect'
import * as locate from './locate'
import { edgesToNodeWithBinding, graphNodeByBinding, outputPortCoordinates } from './locate'
/**
* Prepare the graph for the tests. We drag the `ten` node to the right of the `sum` node for better access
@ -53,3 +55,43 @@ test('Connect an node to a port via dragging the edge', async ({ page }) => {
await expect(graphNodeByBinding(page, 'prod')).toContainText('ten')
})
test('Conditional ports: Disabled', async ({ page }) => {
await actions.goToGraph(page)
const node = graphNodeByBinding(page, 'filtered')
const conditionalPort = node.locator('.WidgetPort').filter({ hasText: /^filter$/ })
// Check that the `enabled` CSS class is not set on disabled `WidgetPort`s.
await expect(node.locator('.WidgetSelfIcon')).toBeVisible()
await expect(conditionalPort).not.toHaveClass(/enabled/)
// When a port is disabled, it doesn't react to hovering with a disconnected edge,
// and any attempt to connect to it should open the CB.
const outputPort = await outputPortCoordinates(graphNodeByBinding(page, 'final'))
await page.mouse.click(outputPort.x, outputPort.y)
await conditionalPort.hover()
await expect(conditionalPort).not.toHaveClass(/isTarget/)
await conditionalPort.click()
await expect(locate.componentBrowser(page)).toExist()
await page.keyboard.press('Escape')
})
test('Conditional ports: Enabled', async ({ page }) => {
await actions.goToGraph(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 expect(conditionalPort).toHaveClass(/enabled/)
const outputPort = await outputPortCoordinates(graphNodeByBinding(page, 'final'))
await page.mouse.click(outputPort.x, outputPort.y)
await conditionalPort.hover()
await expect(conditionalPort).toHaveClass(/isTarget/)
await conditionalPort.click()
await expect(node.locator('.WidgetToken')).toHaveText(['final'])
await page.keyboard.up('Meta')
await page.keyboard.up('Control')
})

View File

@ -1,4 +1,5 @@
import { type Locator, type Page } from '@playwright/test'
import { expect, type Locator, type Page } from '@playwright/test'
import assert from 'assert'
import cssEscape from 'css.escape'
// ==============
@ -199,3 +200,17 @@ export async function edgesToNodeWithBinding(page: Page, binding: string) {
const nodeId = await node.getAttribute('data-node-id')
return page.locator(`[data-target-node-id="${nodeId}"]`)
}
// === Output ports ===
/** Returns a location that can be clicked to activate an output port.
* Using a `Locator` would be better, but `position` option of `click` doesn't work.
*/
export async function outputPortCoordinates(node: Locator) {
const outputPortArea = await node.locator('.outputPortHoverArea').boundingBox()
expect(outputPortArea).not.toBeNull()
assert(outputPortArea)
const centerX = outputPortArea.x + outputPortArea.width / 2
const bottom = outputPortArea.y + outputPortArea.height
return { x: centerX, y: bottom - 2.0 }
}

View File

@ -993,6 +993,18 @@ export interface MutablePropertyAccess extends PropertyAccess, MutableAst {
}
applyMixins(MutablePropertyAccess, [MutableAst])
/** Unroll the provided chain of `PropertyAccess` nodes, returning the first non-access as `subject` and the accesses
* from left-to-right. */
export function accessChain(ast: Ast): { subject: Ast; accessChain: PropertyAccess[] } {
const accessChain = new Array<PropertyAccess>()
while (ast instanceof PropertyAccess && ast.lhs) {
accessChain.push(ast)
ast = ast.lhs
}
accessChain.reverse()
return { subject: ast, accessChain }
}
interface GenericFields {
children: RawNodeChild[]
}

View File

@ -6,7 +6,6 @@ export const codeEditorBindings = defineKeybinds('code-editor', {
export const interactionBindings = defineKeybinds('current-interaction', {
cancel: ['Escape'],
click: ['PointerMain'],
})
export const componentBrowserBindings = defineKeybinds('component-browser', {

View File

@ -24,6 +24,7 @@ import { useStackNavigator } from '@/composables/stackNavigator'
import { provideGraphNavigator } from '@/providers/graphNavigator'
import { provideGraphSelection } from '@/providers/graphSelection'
import { provideInteractionHandler } from '@/providers/interactionHandler'
import { provideKeyboard } from '@/providers/keyboard'
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
import { useGraphStore, type NodeId } from '@/stores/graph'
import type { RequiredImport } from '@/stores/graph/imports'
@ -44,8 +45,9 @@ import { type Usage } from './ComponentBrowser/input'
const DEFAULT_NODE_SIZE = new Vec2(0, 24)
const gapBetweenNodes = 48.0
const keyboard = provideKeyboard()
const viewportNode = ref<HTMLElement>()
const graphNavigator = provideGraphNavigator(viewportNode)
const graphNavigator = provideGraphNavigator(viewportNode, keyboard)
const graphStore = useGraphStore()
const widgetRegistry = provideWidgetRegistry(graphStore.db)
widgetRegistry.loadBuiltins()
@ -83,15 +85,19 @@ projectStore.executionContext.on('executionFailed', (e) =>
// === nodes ===
const nodeSelection = provideGraphSelection(graphNavigator, graphStore.nodeRects, {
onSelected(id) {
graphStore.db.moveNodeToTop(id)
const nodeSelection = provideGraphSelection(
graphNavigator,
graphStore.nodeRects,
graphStore.isPortEnabled,
{
onSelected(id) {
graphStore.db.moveNodeToTop(id)
},
},
})
)
const interactionBindingsHandler = interactionBindings.handler({
cancel: () => interaction.handleCancel(),
click: (e) => (e instanceof PointerEvent ? interaction.handleClick(e, graphNavigator) : false),
})
// Return the environment for the placement of a new node. The passed nodes should be the nodes that are
@ -147,7 +153,9 @@ useEvent(window, 'keydown', (event) => {
(!keyboardBusy() && graphBindingsHandler(event)) ||
(!keyboardBusyExceptIn(codeEditorArea.value) && codeEditorHandler(event))
})
useEvent(window, 'pointerdown', interactionBindingsHandler, { capture: true })
useEvent(window, 'pointerdown', (e) => interaction.handleClick(e, graphNavigator), {
capture: true,
})
onMounted(() => viewportNode.value?.focus())

View File

@ -494,6 +494,7 @@ const documentation = computed<string | undefined>({
:icon="icon"
:connectedSelfArgumentId="connectedSelfArgumentId"
:potentialSelfArgumentId="potentialSelfArgumentId"
:conditionalPorts="props.node.conditionalPorts"
:extended="isOnlyOneSelected"
@openFullMenu="openFullMenu"
/>

View File

@ -15,6 +15,8 @@ const props = defineProps<{
icon: Icon
connectedSelfArgumentId: Ast.AstId | undefined
potentialSelfArgumentId: Ast.AstId | undefined
/** Ports that are not targetable by default; see {@link NodeDataFromAst}. */
conditionalPorts: Set<Ast.AstId>
extended: boolean
}>()
const emit = defineEmits<{
@ -92,6 +94,7 @@ provideWidgetTree(
toRef(props, 'icon'),
toRef(props, 'connectedSelfArgumentId'),
toRef(props, 'potentialSelfArgumentId'),
toRef(props, 'conditionalPorts'),
toRef(props, 'extended'),
layoutTransitions.active,
() => {

View File

@ -4,6 +4,7 @@ import { useRaf } from '@/composables/animation'
import { useResizeObserver } from '@/composables/events'
import { injectGraphNavigator } from '@/providers/graphNavigator'
import { injectGraphSelection } from '@/providers/graphSelection'
import { injectKeyboard } from '@/providers/keyboard'
import { injectPortInfo, providePortInfo, type PortId } from '@/providers/portInfo'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { injectWidgetTree } from '@/providers/widgetTree'
@ -88,10 +89,6 @@ const innerWidget = computed(() => {
providePortInfo(proxyRefs({ portId, connected: hasConnection }))
watch(nodeSize, updateRect)
onUpdated(() => nextTick(updateRect))
useRaf(toRef(tree, 'hasActiveAnimations'), updateRect)
const randSlice = randomUuid.slice(0, 4)
watchEffect(
@ -104,19 +101,37 @@ watchEffect(
{ flush: 'post' },
)
function updateRect() {
let domNode = rootNode.value
const keyboard = injectKeyboard()
const enabled = computed(() => {
const input = props.input.value
const isConditional = input instanceof Ast.Ast && tree.conditionalPorts.has(input.id)
return !isConditional || keyboard.mod
})
const computedRect = computed(() => {
const domNode = rootNode.value
const rootDomNode = domNode?.closest('.GraphNode')
if (domNode == null || rootDomNode == null) return
if (!enabled.value) return
let _nodeSizeEffect = nodeSize.value
const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect())
const nodeClientRect = Rect.FromDomRect(rootDomNode.getBoundingClientRect())
const exprSceneRect = navigator.clientToSceneRect(exprClientRect)
const exprNodeRect = navigator.clientToSceneRect(nodeClientRect)
const localRect = exprSceneRect.offsetBy(exprNodeRect.pos.inverse())
if (portRect.value != null && localRect.equals(portRect.value)) return
portRect.value = localRect
return exprSceneRect.offsetBy(exprNodeRect.pos.inverse())
})
function updateRect() {
const newRect = computedRect.value
if (!Rect.Equal(portRect.value, newRect)) {
portRect.value = newRect
}
}
watch(computedRect, updateRect)
onUpdated(() => nextTick(updateRect))
useRaf(toRef(tree, 'hasActiveAnimations'), updateRect)
</script>
<script lang="ts">
@ -159,6 +174,7 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
ref="rootNode"
class="WidgetPort"
:class="{
enabled,
connected,
isTarget,
isSelfArgument,

View File

@ -22,7 +22,7 @@ export const widgetDefinition = defineWidget(WidgetInput.isAst, {
<template>
<SvgIcon
class="icon nodeCategoryIcon"
class="WidgetSelfIcon icon nodeCategoryIcon"
:name="icon"
@click.right.stop.prevent="tree.emitOpenFullMenu()"
/>

View File

@ -14,7 +14,8 @@ function selectionWithMockData(sceneMousePos?: Ref<Vec2>) {
rects.set(3, Rect.FromBounds(1, 20, 10, 30))
rects.set(4, Rect.FromBounds(20, 20, 30, 30))
const navigator = proxyRefs({ sceneMousePos: sceneMousePos ?? ref(Vec2.Zero), scale: 1 })
const selection = useSelection(navigator, rects, 0)
const allPortsEnabled = () => true
const selection = useSelection(navigator, rects, allPortsEnabled, 0)
selection.setSelection(new Set([1, 2]))
return selection
}

View File

@ -134,7 +134,7 @@ const hasWindow = typeof window !== 'undefined'
const platform = hasWindow ? window.navigator?.platform ?? '' : ''
export const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(platform)
export function modKey(e: KeyboardEvent): boolean {
export function modKey(e: KeyboardEvent | MouseEvent): boolean {
return isMacLike ? e.metaKey : e.ctrlKey
}

View File

@ -0,0 +1,31 @@
import { isMacLike, useEvent } from '@/composables/events'
import { proxyRefs, ref } from 'vue'
export type KeyboardComposable = ReturnType<typeof useKeyboard>
export function useKeyboard() {
const state = {
alt: ref(false),
shift: ref(false),
meta: ref(false),
ctrl: ref(false),
}
const updateState = (e: MouseEvent | KeyboardEvent) => {
state.alt.value = e.altKey
state.shift.value = e.shiftKey
state.meta.value = e.metaKey
state.ctrl.value = e.ctrlKey
return false
}
useEvent(window, 'keydown', updateState, { capture: true })
useEvent(window, 'keyup', updateState, { capture: true })
useEvent(window, 'pointerenter', updateState, { capture: true })
return proxyRefs({
alt: state.alt,
shift: state.shift,
meta: state.meta,
ctrl: state.ctrl,
mod: isMacLike ? state.meta : state.ctrl,
})
}

View File

@ -2,6 +2,7 @@
import { useApproach } from '@/composables/animation'
import { PointerButtonMask, useEvent, usePointer, useResizeObserver } from '@/composables/events'
import type { KeyboardComposable } from '@/composables/keyboard'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { computed, proxyRefs, shallowRef, type Ref } from 'vue'
@ -19,7 +20,7 @@ function elemRect(target: Element | undefined): Rect {
}
export type NavigatorComposable = ReturnType<typeof useNavigator>
export function useNavigator(viewportNode: Ref<Element | undefined>) {
export function useNavigator(viewportNode: Ref<Element | undefined>, keyboard: KeyboardComposable) {
const size = useResizeObserver(viewportNode)
const targetCenter = shallowRef<Vec2>(Vec2.Zero)
const targetX = computed(() => targetCenter.value.x)
@ -211,26 +212,6 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
{ capture: true },
)
let ctrlPressed = false
useEvent(
window,
'keydown',
(event) => {
if (event.key === 'Control') ctrlPressed = true
return false
},
{ capture: true },
)
useEvent(
window,
'keyup',
(event) => {
if (event.key === 'Control') ctrlPressed = false
return false
},
{ capture: true },
)
/** Clamp the value to the given bounds, except if it is already outside the bounds allow the new value to be less
* outside the bounds. */
function directedClamp(oldValue: number, newValue: number, [min, max]: ScaleRange): number {
@ -257,11 +238,6 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
)
}
/** Update `ctrlPressed` from the event; this helps catch if we missed the `keyup` while focus was elsewhere. */
function updateCtrlState(e: KeyboardEvent | MouseEvent | PointerEvent) {
ctrlPressed = e.ctrlKey
}
return proxyRefs({
events: {
dragover(e: DragEvent) {
@ -270,14 +246,10 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
dragleave() {
eventMousePos.value = null
},
pointerenter(e: PointerEvent) {
updateCtrlState(e)
},
pointermove(e: PointerEvent) {
eventMousePos.value = eventScreenPos(e)
panPointer.events.pointermove(e)
zoomPointer.events.pointermove(e)
updateCtrlState(e)
},
pointerleave() {
eventMousePos.value = null
@ -297,7 +269,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
if (e.ctrlKey) {
// A pinch gesture is represented by setting `e.ctrlKey`. It can be distinguished from an actual Ctrl+wheel
// combination because the real Ctrl key emits keyup/keydown events.
const isGesture = !ctrlPressed
const isGesture = !keyboard.ctrl
if (isGesture) {
// OS X trackpad events provide usable rate-of-change information.
updateScale(
@ -309,7 +281,6 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
stepZoom(-Math.sign(e.deltaY), WHEEL_SCALE_RANGE)
}
} else {
updateCtrlState(e)
const delta = new Vec2(e.deltaX, e.deltaY)
center.value = center.value.addScaled(delta, 1 / scale.value)
}

View File

@ -12,6 +12,7 @@ export type SelectionComposable<T> = ReturnType<typeof useSelection<T>>
export function useSelection<T>(
navigator: { sceneMousePos: Vec2 | null; scale: number },
elementRects: Map<T, Rect>,
isPortEnabled: (port: PortId) => boolean,
margin: number,
callbacks: {
onSelected?: (element: T) => void
@ -22,22 +23,41 @@ export function useSelection<T>(
const initiallySelected = new Set<T>()
const selected = shallowReactive(new Set<T>())
const hoveredNode = ref<NodeId>()
const hoveredPort = ref<PortId>()
const hoveredElement = ref<Element>()
useEvent(document, 'pointerover', (event) => {
if (event.target instanceof Element) {
const widgetPort = event.target.closest('.WidgetPort')
hoveredPort.value =
(
widgetPort instanceof HTMLElement &&
'port' in widgetPort.dataset &&
typeof widgetPort.dataset.port === 'string'
) ?
(widgetPort.dataset.port as PortId)
: undefined
}
hoveredElement.value = event.target instanceof Element ? event.target : undefined
})
const hoveredPort = computed<PortId | undefined>(() => {
if (!hoveredElement.value) return undefined
for (const element of elementHierarchy(hoveredElement.value, '.WidgetPort')) {
const portId = elementPortId(element)
if (portId && isPortEnabled(portId)) return portId
}
return undefined
})
function* elementHierarchy(element: Element, selectors: string) {
for (;;) {
const match = element.closest(selectors)
if (!match) return
yield match
if (!match.parentElement) return
element = match.parentElement
}
}
function elementPortId(element: Element): PortId | undefined {
return (
element instanceof HTMLElement &&
'port' in element.dataset &&
typeof element.dataset.port === 'string'
) ?
(element.dataset.port as PortId)
: undefined
}
function readInitiallySelected() {
initiallySelected.clear()
for (const id of selected) initiallySelected.add(id)

View File

@ -13,11 +13,12 @@ const { provideFn, injectFn } = createContextStore(
(
navigator: NavigatorComposable,
nodeRects: Map<NodeId, Rect>,
isPortEnabled,
callbacks: {
onSelected?: (id: NodeId) => void
onDeselected?: (id: NodeId) => void
} = {},
) => {
return useSelection(navigator, nodeRects, SELECTION_BRUSH_MARGIN_PX, callbacks)
return useSelection(navigator, nodeRects, isPortEnabled, SELECTION_BRUSH_MARGIN_PX, callbacks)
},
)

View File

@ -0,0 +1,6 @@
import { useKeyboard } from '@/composables/keyboard'
import { createContextStore } from '@/providers'
export { injectFn as injectKeyboard, provideFn as provideKeyboard }
const { provideFn, injectFn } = createContextStore('Keyboard watcher', () => useKeyboard())

View File

@ -32,6 +32,7 @@ const { provideFn, injectFn } = createContextStore(
icon: Ref<Icon>,
connectedSelfArgumentId: Ref<Ast.AstId | undefined>,
potentialSelfArgumentId: Ref<Ast.AstId | undefined>,
conditionalPorts: Ref<Set<Ast.AstId>>,
extended: Ref<boolean>,
hasActiveAnimations: Ref<boolean>,
emitOpenFullMenu: () => void,
@ -46,6 +47,7 @@ const { provideFn, injectFn } = createContextStore(
icon,
connectedSelfArgumentId,
potentialSelfArgumentId,
conditionalPorts,
extended,
nodeSpanStart,
hasActiveAnimations,

View File

@ -12,6 +12,7 @@ import { MappedKeyMap, MappedSet } from '@/util/containers'
import { arrayEquals, tryGetIndex } from '@/util/data/array'
import { Vec2 } from '@/util/data/vec2'
import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb'
import { syncSet } from '@/util/reactivity'
import * as random from 'lib0/random'
import * as set from 'lib0/set'
import { methodPointerEquals, type MethodCall, type StackItem } from 'shared/languageServerTypes'
@ -368,6 +369,7 @@ export class GraphDb {
primarySubject,
prefixes,
documentation,
conditionalPorts,
} = newNode
const differentOrDirty = (a: Ast.Ast | undefined, b: Ast.Ast | undefined) =>
a?.id !== b?.id || (a && subtreeDirty(a.id))
@ -383,6 +385,7 @@ export class GraphDb {
)
)
node.prefixes = prefixes
syncSet(node.conditionalPorts, conditionalPorts)
// Ensure new fields can't be added to `NodeAstData` without this code being updated.
const _allFieldsHandled = {
outerExprId,
@ -392,6 +395,7 @@ export class GraphDb {
primarySubject,
prefixes,
documentation,
conditionalPorts,
} satisfies NodeDataFromAst
}
}
@ -496,6 +500,8 @@ export interface NodeDataFromAst {
/** A child AST in a syntactic position to be a self-argument input to the node. */
primarySubject: Ast.AstId | undefined
documentation: string | undefined
/** Ports that are not targetable by default; they can be targeted while holding the modifier key. */
conditionalPorts: Set<Ast.AstId>
}
export interface NodeDataFromMetadata {
@ -511,6 +517,7 @@ const baseMockNode = {
prefixes: { enableRecording: undefined },
primarySubject: undefined,
documentation: undefined,
conditionalPorts: new Set(),
} satisfies Partial<Node>
/** This should only be used for supplying as initial props when testing.

View File

@ -472,6 +472,10 @@ export const useGraphStore = defineStore('graph', () => {
return getPortPrimaryInstance(id)?.rect.value
}
function isPortEnabled(id: PortId): boolean {
return getPortRelativeRect(id) != null
}
function getPortNodeId(id: PortId): NodeId | undefined {
return db.getExpressionNodeId(id as string as Ast.AstId) ?? getPortPrimaryInstance(id)?.nodeId
}
@ -673,6 +677,7 @@ export const useGraphStore = defineStore('graph', () => {
removePortInstance,
getPortRelativeRect,
getPortNodeId,
isPortEnabled,
updatePortValue,
setEditedNode,
updateState,

View File

@ -1,5 +1,6 @@
import { LazySyncEffectSet } from '@/util/reactivity'
import { expect, test, vi } from 'vitest'
import { LazySyncEffectSet, syncSet } from '@/util/reactivity'
import { fc, test } from '@fast-check/vitest'
import { expect, vi } from 'vitest'
import { nextTick, reactive, ref } from 'vue'
test('LazySyncEffectSet', async () => {
@ -143,3 +144,13 @@ test('LazySyncEffectSet', async () => {
]),
)
})
test.prop({
oldValues: fc.array(fc.integer()),
newValues: fc.array(fc.integer()),
})('syncSet', ({ oldValues, newValues }) => {
const newState = new Set<number>(newValues)
const target = new Set<number>(oldValues)
syncSet(target, newState)
expect([...target].sort()).toEqual([...newState].sort())
})

View File

@ -949,3 +949,33 @@ test('Adding comments', () => {
expr.update((expr) => Ast.Documented.new('Calculate five', expr))
expect(expr.module.root()?.code()).toBe('## Calculate five\n2 + 2')
})
test.each([
{ code: 'operator1', expected: { subject: 'operator1', accesses: [] } },
{ code: 'operator1 foo bar', expected: { subject: 'operator1 foo bar', accesses: [] } },
{ code: 'operator1.parse_json', expected: { subject: 'operator1', accesses: ['parse_json'] } },
{
code: 'operator1.parse_json operator2.to_json',
expected: { subject: 'operator1.parse_json operator2.to_json', accesses: [] },
},
{
code: 'operator1.parse_json foo bar',
expected: { subject: 'operator1.parse_json foo bar', accesses: [] },
},
{
code: 'operator1.parse_json.length',
expected: { subject: 'operator1', accesses: ['parse_json', 'length'] },
},
{
code: 'operator1.parse_json.length foo bar',
expected: { subject: 'operator1.parse_json.length foo bar', accesses: [] },
},
{ code: 'operator1 + operator2', expected: { subject: 'operator1 + operator2', accesses: [] } },
])('Access chain in $code', ({ code, expected }) => {
const ast = Ast.parse(code)
const { subject, accessChain } = Ast.accessChain(ast)
expect({
subject: subject.code(),
accesses: accessChain.map((ast) => ast.rhs.code()),
}).toEqual(expected)
})

View File

@ -1,5 +1,6 @@
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import { initializePrefixes, nodeFromAst } from '@/util/ast/node'
import { initializePrefixes, nodeFromAst, primaryApplicationSubject } from '@/util/ast/node'
import { initializeFFI } from 'shared/ast/ffi'
import { expect, test } from 'vitest'
@ -27,3 +28,39 @@ test.each(['## Documentation only'])("'%s' should not be a node", (line) => {
const node = nodeFromAst(ast)
expect(node).toBeUndefined()
})
test.each([
{ code: 'operator1', expected: undefined },
{ code: 'operator1 foo bar', expected: undefined },
{ code: 'operator1.parse_json', expected: { subject: 'operator1', accesses: ['parse_json'] } },
{
code: 'operator1.parse_json operator2.to_json',
expected: { subject: 'operator1', accesses: ['parse_json'] },
},
{
code: 'operator1.parse_json foo bar',
expected: { subject: 'operator1', accesses: ['parse_json'] },
},
{
code: 'operator1.parse_json.length',
expected: { subject: 'operator1', accesses: ['parse_json', 'length'] },
},
{
code: 'operator1.parse_json.length foo bar',
expected: { subject: 'operator1', accesses: ['parse_json', 'length'] },
},
{ code: 'operator1 + operator2', expected: undefined },
])('Primary application subject of $code', ({ code, expected }) => {
const ast = Ast.Ast.parse(code)
const module = ast.module
const primaryApplication = primaryApplicationSubject(ast)
const analyzed = primaryApplication && {
subject: module.get(primaryApplication.subject).code(),
accesses: primaryApplication.accessChain.map((id) => {
const ast = module.get(id)
assert(ast instanceof Ast.PropertyAccess)
return ast.rhs.code()
}),
}
expect(analyzed).toEqual(expected)
})

View File

@ -25,27 +25,31 @@ export function nodeFromAst(ast: Ast.Ast): NodeDataFromAst | undefined {
const pattern = nodeCode instanceof Ast.Assignment ? nodeCode.pattern : undefined
const rootExpr = nodeCode instanceof Ast.Assignment ? nodeCode.expression : nodeCode
const { innerExpr, matches } = prefixes.extractMatches(rootExpr)
const primaryApplication = primaryApplicationSubject(innerExpr)
return {
outerExprId: ast.id,
pattern,
rootExpr,
innerExpr,
prefixes: matches,
primarySubject: primaryApplicationSubject(innerExpr),
primarySubject: primaryApplication?.subject,
documentation,
conditionalPorts: new Set(primaryApplication?.accessChain ?? []),
}
}
/** Given a node root, find a child AST that is the root of the access chain that is the subject of the primary
* application.
*/
export function primaryApplicationSubject(ast: Ast.Ast): Ast.AstId | undefined {
export function primaryApplicationSubject(
ast: Ast.Ast,
): { subject: Ast.AstId; accessChain: Ast.AstId[] } | undefined {
// Descend into LHS of any sequence of applications.
while (ast instanceof Ast.App) ast = ast.function
// Require a sequence of at least one property access; descend into LHS.
if (!(ast instanceof Ast.PropertyAccess)) return
while (ast instanceof Ast.PropertyAccess && ast.lhs) ast = ast.lhs
const { subject, accessChain } = Ast.accessChain(ast)
// Require at least one property access.
if (accessChain.length === 0) return
// The leftmost element must be an identifier.
if (!(ast instanceof Ast.Ident)) return
return ast.id
if (!(subject instanceof Ast.Ident)) return
return { subject: subject.id, accessChain: accessChain.map((ast) => ast.id) }
}

View File

@ -23,7 +23,9 @@ export class Rect {
return new Rect(center.addScaled(size, -0.5), size)
}
static FromDomRect(domRect: DOMRect): Rect {
static FromDomRect(
domRect: Readonly<{ x: number; y: number; width: number; height: number }>,
): Rect {
return new Rect(Vec2.FromXY(domRect), Vec2.FromSize(domRect))
}
@ -41,6 +43,15 @@ export class Rect {
return this.FromBounds(left, top, right, bottom)
}
static Equal(a: Rect, b: Rect): boolean
static Equal(a: Rect | null, b: Rect | null): boolean
static Equal(a: Rect | undefined, b: Rect | undefined): boolean
static Equal(a: Rect | null | undefined, b: Rect | null | undefined): boolean {
if (!a && !b) return true
if (!a || !b) return false
return a.equals(b)
}
offsetBy(offset: Vec2): Rect {
return new Rect(this.pos.add(offset), this.size)
}

View File

@ -150,3 +150,9 @@ export function debouncedGetter<T>(
})
return valueRef
}
/** Update `target` to have the same entries as `newState`. */
export function syncSet<T>(target: Set<T>, newState: Set<T>) {
for (const oldKey of target) if (!newState.has(oldKey)) target.delete(oldKey)
for (const newKey of newState) if (!target.has(newKey)) target.add(newKey)
}

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import GraphNode from '@/components/GraphEditor/GraphNode.vue'
import { useKeyboard } from '@/composables/keyboard'
import { useNavigator } from '@/composables/navigator'
import { provideGraphSelection } from '@/providers/graphSelection'
import type { Node } from '@/stores/graph'
@ -7,8 +8,8 @@ import { Ast } from '@/util/ast'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { logEvent } from 'histoire/client'
import { type SourceRange } from 'shared/yjsModel'
import { computed, reactive, ref, watchEffect } from 'vue'
import { type SourceRange } from '../shared/yjsModel'
import { createSetupComponent } from './histoire/utils'
const nodeBinding = ref('binding')
@ -43,6 +44,7 @@ const node = computed((): Node => {
primarySubject: undefined,
vis: undefined,
documentation: undefined,
conditionalPorts: new Set(),
}
})
@ -56,9 +58,11 @@ watchEffect((onCleanup) => {
})
})
const navigator = useNavigator(ref())
const keyboard = useKeyboard()
const navigator = useNavigator(ref(), keyboard)
const allPortsEnabled = () => true
const SetupStory = createSetupComponent((app) => {
const selection = provideGraphSelection._mock([navigator, mockRects], app)
const selection = provideGraphSelection._mock([navigator, mockRects, allPortsEnabled], app)
watchEffect(() => {
if (selected.value) {
selection.selectAll()