Graph editor breadcrumbs integration (#8519)

Implements  #7788

[Peek 2023-12-12 10-46.webm](https://github.com/enso-org/enso/assets/1428930/7d1f45f6-8323-4adc-9a8c-f6f4bcaeb143)
This commit is contained in:
Michael Mauderer 2023-12-19 05:47:15 +01:00 committed by GitHub
parent 21d164ec3e
commit 23e0bafc75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 183 additions and 60 deletions

View File

@ -35,6 +35,7 @@ app/ide-desktop/lib/dashboard/playwright-report/
app/ide-desktop/lib/dashboard/playwright/.cache/ app/ide-desktop/lib/dashboard/playwright/.cache/
app/gui/view/documentation/assets/stylesheet.css app/gui/view/documentation/assets/stylesheet.css
app/gui2/rust-ffi/pkg app/gui2/rust-ffi/pkg
app/gui2/src/assets/font-*.css
Cargo.lock Cargo.lock
build.json build.json
app/gui2/playwright-report/ app/gui2/playwright-report/

View File

@ -16,6 +16,7 @@ import PlusButton from '@/components/PlusButton.vue'
import TopBar from '@/components/TopBar.vue' import TopBar from '@/components/TopBar.vue'
import { useDoubleClick } from '@/composables/doubleClick' import { useDoubleClick } from '@/composables/doubleClick'
import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/composables/events' import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/composables/events'
import { useStackNavigator } from '@/composables/stackNavigator'
import { provideGraphNavigator } from '@/providers/graphNavigator' import { provideGraphNavigator } from '@/providers/graphNavigator'
import { provideGraphSelection } from '@/providers/graphSelection' import { provideGraphSelection } from '@/providers/graphSelection'
import { provideInteractionHandler, type Interaction } from '@/providers/interactionHandler' import { provideInteractionHandler, type Interaction } from '@/providers/interactionHandler'
@ -27,7 +28,6 @@ import { groupColorVar, useSuggestionDbStore } from '@/stores/suggestionDatabase
import { colorFromString } from '@/util/colors' import { colorFromString } from '@/util/colors'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import * as set from 'lib0/set' import * as set from 'lib0/set'
import type { ExprId, NodeMetadata } from 'shared/yjsModel' import type { ExprId, NodeMetadata } from 'shared/yjsModel'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
@ -206,12 +206,12 @@ const graphBindingsHandler = graphBindings.handler({
if (keyboardBusy()) return false if (keyboardBusy()) return false
const selectedNode = set.first(nodeSelection.selected) const selectedNode = set.first(nodeSelection.selected)
if (selectedNode) { if (selectedNode) {
enterNode(selectedNode) stackNavigator.enterNode(selectedNode)
} }
}, },
exitNode() { exitNode() {
if (keyboardBusy()) return false if (keyboardBusy()) return false
exitNode() stackNavigator.exitNode()
}, },
}) })
@ -220,7 +220,7 @@ const handleClick = useDoubleClick(
graphBindingsHandler(e) graphBindingsHandler(e)
}, },
() => { () => {
exitNode() stackNavigator.exitNode()
}, },
).handleClick ).handleClick
const codeEditorArea = ref<HTMLElement>() const codeEditorArea = ref<HTMLElement>()
@ -232,31 +232,6 @@ const codeEditorHandler = codeEditorBindings.handler({
}, },
}) })
function enterNode(id: ExprId) {
const expressionInfo = graphStore.db.getExpressionInfo(id)
if (expressionInfo == undefined || expressionInfo.methodCall == undefined) {
console.debug('Cannot enter node that has no method call.')
return
}
const definedOnType = tryQualifiedName(expressionInfo.methodCall.methodPointer.definedOnType)
if (!projectStore.modulePath?.ok) {
console.warn('Cannot enter node while no module is open.')
return
}
const openModuleName = qnLastSegment(projectStore.modulePath.value)
if (definedOnType.ok && qnLastSegment(definedOnType.value) != openModuleName) {
console.debug('Cannot enter node that is not defined on current module.')
return
}
projectStore.executionContext.push(id)
graphStore.updateState()
}
function exitNode() {
projectStore.executionContext.pop()
graphStore.updateState()
}
/** Track play button presses. */ /** Track play button presses. */
function onPlayButtonPress() { function onPlayButtonPress() {
projectStore.lsRpcConnection.then(async () => { projectStore.lsRpcConnection.then(async () => {
@ -410,17 +385,6 @@ async function handleFileDrop(event: DragEvent) {
}) })
} }
const breadcrumbs = computed(() =>
projectStore.executionContext.desiredStack.map((frame) => {
switch (frame.type) {
case 'ExplicitCall':
return frame.methodPointer.name
case 'LocalCall':
return frame.expressionId
}
}),
)
// === Clipboard === // === Clipboard ===
const ENSO_MIME_TYPE = 'web application/enso' const ENSO_MIME_TYPE = 'web application/enso'
@ -507,6 +471,8 @@ function handleNodeOutputPortDoubleClick(id: ExprId) {
interaction.setCurrent(creatingNodeFromPortDoubleClick) interaction.setCurrent(creatingNodeFromPortDoubleClick)
} }
const stackNavigator = useStackNavigator()
function handleEdgeDrop(source: ExprId, position: Vec2) { function handleEdgeDrop(source: ExprId, position: Vec2) {
componentBrowserUsage.value = { type: 'newNode', sourcePort: source } componentBrowserUsage.value = { type: 'newNode', sourcePort: source }
componentBrowserNodePosition.value = position componentBrowserNodePosition.value = position
@ -532,7 +498,7 @@ function handleEdgeDrop(source: ExprId, position: Vec2) {
<div :style="{ transform: graphNavigator.transform }" class="htmlLayer"> <div :style="{ transform: graphNavigator.transform }" class="htmlLayer">
<GraphNodes <GraphNodes
@nodeOutputPortDoubleClick="handleNodeOutputPortDoubleClick" @nodeOutputPortDoubleClick="handleNodeOutputPortDoubleClick"
@nodeDoubleClick="enterNode" @nodeDoubleClick="(id) => stackNavigator.enterNode(id)"
/> />
</div> </div>
<ComponentBrowser <ComponentBrowser
@ -549,10 +515,12 @@ function handleEdgeDrop(source: ExprId, position: Vec2) {
v-model:mode="projectStore.executionMode" v-model:mode="projectStore.executionMode"
:title="projectStore.name" :title="projectStore.name"
:modes="EXECUTION_MODES" :modes="EXECUTION_MODES"
:breadcrumbs="breadcrumbs" :breadcrumbs="stackNavigator.breadcrumbLabels.value"
@breadcrumbClick="console.log(`breadcrumb #${$event + 1} clicked.`)" :allowNavigationLeft="stackNavigator.allowNavigationLeft.value"
@back="exitNode" :allowNavigationRight="stackNavigator.allowNavigationRight.value"
@forward="console.log('breadcrumbs \'forward\' button clicked.')" @breadcrumbClick="stackNavigator.handleBreadcrumbClick"
@back="stackNavigator.exitNode"
@forward="stackNavigator.enterNextNodeFromHistory"
@execute="onPlayButtonPress()" @execute="onPlayButtonPress()"
/> />
<PlusButton @pointerdown="interaction.setCurrent(creatingNodeFromButton)" /> <PlusButton @pointerdown="interaction.setCurrent(creatingNodeFromButton)" />

View File

@ -49,7 +49,6 @@ const emit = defineEmits<{
draggingCommited: [] draggingCommited: []
delete: [] delete: []
replaceSelection: [] replaceSelection: []
nodeDoubleClick: []
outputPortClick: [portId: ExprId] outputPortClick: [portId: ExprId]
outputPortDoubleClick: [portId: ExprId] outputPortDoubleClick: [portId: ExprId]
doubleClick: [] doubleClick: []

View File

@ -1,8 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import NavBreadcrumbs from '@/components/NavBreadcrumbs.vue' import NavBreadcrumbs, { type BreadcrumbItem } from '@/components/NavBreadcrumbs.vue'
import SvgIcon from '@/components/SvgIcon.vue' import SvgIcon from '@/components/SvgIcon.vue'
const props = defineProps<{ breadcrumbs: string[] }>() const props = defineProps<{
breadcrumbs: BreadcrumbItem[]
allowNavigationLeft: boolean
allowNavigationRight: boolean
}>()
const emit = defineEmits<{ back: []; forward: []; breadcrumbClick: [index: number] }>() const emit = defineEmits<{ back: []; forward: []; breadcrumbClick: [index: number] }>()
</script> </script>
@ -13,20 +17,19 @@ const emit = defineEmits<{ back: []; forward: []; breadcrumbClick: [index: numbe
<SvgIcon <SvgIcon
name="arrow_left" name="arrow_left"
draggable="false" draggable="false"
class="icon button inactive" class="icon button"
:class="{ inactive: !props.allowNavigationLeft }"
@pointerdown="emit('back')" @pointerdown="emit('back')"
/> />
<SvgIcon <SvgIcon
name="arrow_right" name="arrow_right"
draggable="false" draggable="false"
class="icon button" class="icon button"
:class="{ inactive: !props.allowNavigationRight }"
@pointerdown="emit('forward')" @pointerdown="emit('forward')"
/> />
</div> </div>
<NavBreadcrumbs <NavBreadcrumbs :breadcrumbs="props.breadcrumbs" @selected="emit('breadcrumbClick', $event)" />
:breadcrumbs="props.breadcrumbs"
@pointerdown="emit('breadcrumbClick', $event)"
/>
</div> </div>
</template> </template>

View File

@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ text: string }>() const props = defineProps<{ text: string; active: boolean }>()
const emit = defineEmits<{ click: [] }>() const emit = defineEmits<{ click: [] }>()
</script> </script>
<template> <template>
<div class="NavBreadcrumb"><span @click="emit('click')" v-text="props.text"></span></div> <div :class="['NavBreadcrumb', { inactive: !props.active }]">
<span @click="emit('click')" v-text="props.text"></span>
</div>
</template> </template>
<style scoped> <style scoped>
@ -25,4 +27,8 @@ span {
backdrop-filter: var(--backdrop-blur); backdrop-filter: var(--backdrop-blur);
} }
} }
.inactive {
opacity: 0.4;
}
</style> </style>

View File

@ -3,15 +3,28 @@ import SvgIcon from '@/components/SvgIcon.vue'
import NavBreadcrumb from '@/components/NavBreadcrumb.vue' import NavBreadcrumb from '@/components/NavBreadcrumb.vue'
const props = defineProps<{ breadcrumbs: string[] }>() export interface BreadcrumbItem {
const emit = defineEmits<{ click: [index: number] }>() label: string
active: boolean
}
const props = defineProps<{ breadcrumbs: BreadcrumbItem[] }>()
const emit = defineEmits<{ selected: [index: number] }>()
</script> </script>
<template> <template>
<div class="NavBreadcrumbs"> <div class="NavBreadcrumbs">
<template v-for="(breadcrumb, index) in props.breadcrumbs" :key="index"> <template v-for="(breadcrumb, index) in props.breadcrumbs" :key="index">
<SvgIcon v-if="index > 0" name="arrow_right_head_only" class="arrow" /> <SvgIcon
<NavBreadcrumb :text="breadcrumb" @click="emit('click', index)" /> v-if="index > 0"
name="arrow_right_head_only"
:class="['arrow', { inactive: !breadcrumb.active }]"
/>
<NavBreadcrumb
:text="breadcrumb.label"
:active="breadcrumb.active"
@pointerdown="emit('selected', index)"
/>
</template> </template>
</div> </div>
</template> </template>
@ -26,4 +39,8 @@ const emit = defineEmits<{ click: [index: number] }>()
.arrow { .arrow {
color: #666666; color: #666666;
} }
.inactive {
opacity: 0.4;
}
</style> </style>

View File

@ -1,10 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import NavBar from '@/components/NavBar.vue' import NavBar from '@/components/NavBar.vue'
import type { BreadcrumbItem } from '@/components/NavBreadcrumbs.vue'
import ProjectTitle from '@/components/ProjectTitle.vue' import ProjectTitle from '@/components/ProjectTitle.vue'
import { injectGuiConfig } from '@/providers/guiConfig' import { injectGuiConfig } from '@/providers/guiConfig'
import { computed } from 'vue' import { computed } from 'vue'
const props = defineProps<{ title: string; breadcrumbs: string[]; modes: string[]; mode: string }>() const props = defineProps<{
title: string
breadcrumbs: BreadcrumbItem[]
modes: string[]
mode: string
allowNavigationLeft: boolean
allowNavigationRight: boolean
}>()
const emit = defineEmits<{ const emit = defineEmits<{
execute: [] execute: []
back: [] back: []
@ -36,6 +44,8 @@ const barStyle = computed(() => {
/> />
<NavBar <NavBar
:breadcrumbs="props.breadcrumbs" :breadcrumbs="props.breadcrumbs"
:allowNavigationLeft="props.allowNavigationLeft"
:allowNavigationRight="props.allowNavigationRight"
@back="emit('back')" @back="emit('back')"
@forward="emit('forward')" @forward="emit('forward')"
@breadcrumbClick="emit('breadcrumbClick', $event)" @breadcrumbClick="emit('breadcrumbClick', $event)"

View File

@ -0,0 +1,119 @@
import type { BreadcrumbItem } from '@/components/NavBreadcrumbs.vue'
import { useGraphStore } from '@/stores/graph'
import { useProjectStore } from '@/stores/project'
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import type { StackItem } from 'shared/languageServerTypes.ts'
import type { ExprId } from 'shared/yjsModel.ts'
import { computed, onMounted, ref } from 'vue'
export function useStackNavigator() {
const projectStore = useProjectStore()
const graphStore = useGraphStore()
const breadcrumbs = ref<StackItem[]>([])
const breadcrumbLabels = computed(() => {
const activeStackLength = projectStore.executionContext.desiredStack.length
return breadcrumbs.value.map((item, index) => {
const label = stackItemToLabel(item)
const isActive = index < activeStackLength
return { label, active: isActive } satisfies BreadcrumbItem
})
})
const allowNavigationLeft = computed(() => {
return projectStore.executionContext.desiredStack.length > 1
})
const allowNavigationRight = computed(() => {
return projectStore.executionContext.desiredStack.length < breadcrumbs.value.length
})
function stackItemToLabel(item: StackItem): string {
switch (item.type) {
case 'ExplicitCall': {
return item.methodPointer.name
}
case 'LocalCall': {
const exprId = item.expressionId
const info = graphStore.db.getExpressionInfo(exprId)
return info?.methodCall?.methodPointer.name ?? 'unknown'
}
}
}
function handleBreadcrumbClick(index: number) {
const activeStack = projectStore.executionContext.desiredStack
if (index < activeStack.length) {
const diff = activeStack.length - index - 1
for (let i = 0; i < diff; i++) {
projectStore.executionContext.pop()
}
} else if (index >= activeStack.length) {
const diff = index - activeStack.length + 1
for (let i = 0; i < diff; i++) {
const stackItem = breadcrumbs.value[index - i]
if (stackItem?.type === 'LocalCall') {
const exprId = stackItem.expressionId
projectStore.executionContext.push(exprId)
} else {
console.warn('Cannot enter non-local call.')
}
}
}
graphStore.updateState()
}
function enterNode(id: ExprId) {
const expressionInfo = graphStore.db.getExpressionInfo(id)
if (expressionInfo == null || expressionInfo.methodCall == null) {
console.debug('Cannot enter node that has no method call.')
return
}
const definedOnType = tryQualifiedName(expressionInfo.methodCall.methodPointer.definedOnType)
if (!projectStore.modulePath?.ok) {
console.warn('Cannot enter node while no module is open.')
return
}
const openModuleName = qnLastSegment(projectStore.modulePath.value)
if (definedOnType.ok && qnLastSegment(definedOnType.value) !== openModuleName) {
console.debug('Cannot enter node that is not defined on current module.')
return
}
projectStore.executionContext.push(id)
graphStore.updateState()
breadcrumbs.value = projectStore.executionContext.desiredStack.slice()
}
function exitNode() {
projectStore.executionContext.pop()
graphStore.updateState()
}
/// Enter the next node from the history stack. This is the node that is the first greyed out item in the breadcrumbs.
function enterNextNodeFromHistory() {
const nextNodeIndex = projectStore.executionContext.desiredStack.length
const nextNode = breadcrumbs.value[nextNodeIndex]
if (nextNode?.type !== 'LocalCall') {
console.warn('Cannot enter non-local call.')
return
}
projectStore.executionContext.push(nextNode.expressionId)
graphStore.updateState()
}
onMounted(() => {
breadcrumbs.value = projectStore.executionContext.desiredStack.slice()
})
return {
breadcrumbs,
breadcrumbLabels,
allowNavigationLeft,
allowNavigationRight,
handleBreadcrumbClick,
enterNode,
exitNode,
enterNextNodeFromHistory,
}
}