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/gui/view/documentation/assets/stylesheet.css
app/gui2/rust-ffi/pkg
app/gui2/src/assets/font-*.css
Cargo.lock
build.json
app/gui2/playwright-report/

View File

@ -16,6 +16,7 @@ import PlusButton from '@/components/PlusButton.vue'
import TopBar from '@/components/TopBar.vue'
import { useDoubleClick } from '@/composables/doubleClick'
import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/composables/events'
import { useStackNavigator } from '@/composables/stackNavigator'
import { provideGraphNavigator } from '@/providers/graphNavigator'
import { provideGraphSelection } from '@/providers/graphSelection'
import { provideInteractionHandler, type Interaction } from '@/providers/interactionHandler'
@ -27,7 +28,6 @@ import { groupColorVar, useSuggestionDbStore } from '@/stores/suggestionDatabase
import { colorFromString } from '@/util/colors'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import * as set from 'lib0/set'
import type { ExprId, NodeMetadata } from 'shared/yjsModel'
import { computed, onMounted, ref, watch } from 'vue'
@ -206,12 +206,12 @@ const graphBindingsHandler = graphBindings.handler({
if (keyboardBusy()) return false
const selectedNode = set.first(nodeSelection.selected)
if (selectedNode) {
enterNode(selectedNode)
stackNavigator.enterNode(selectedNode)
}
},
exitNode() {
if (keyboardBusy()) return false
exitNode()
stackNavigator.exitNode()
},
})
@ -220,7 +220,7 @@ const handleClick = useDoubleClick(
graphBindingsHandler(e)
},
() => {
exitNode()
stackNavigator.exitNode()
},
).handleClick
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. */
function onPlayButtonPress() {
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 ===
const ENSO_MIME_TYPE = 'web application/enso'
@ -507,6 +471,8 @@ function handleNodeOutputPortDoubleClick(id: ExprId) {
interaction.setCurrent(creatingNodeFromPortDoubleClick)
}
const stackNavigator = useStackNavigator()
function handleEdgeDrop(source: ExprId, position: Vec2) {
componentBrowserUsage.value = { type: 'newNode', sourcePort: source }
componentBrowserNodePosition.value = position
@ -532,7 +498,7 @@ function handleEdgeDrop(source: ExprId, position: Vec2) {
<div :style="{ transform: graphNavigator.transform }" class="htmlLayer">
<GraphNodes
@nodeOutputPortDoubleClick="handleNodeOutputPortDoubleClick"
@nodeDoubleClick="enterNode"
@nodeDoubleClick="(id) => stackNavigator.enterNode(id)"
/>
</div>
<ComponentBrowser
@ -549,10 +515,12 @@ function handleEdgeDrop(source: ExprId, position: Vec2) {
v-model:mode="projectStore.executionMode"
:title="projectStore.name"
:modes="EXECUTION_MODES"
:breadcrumbs="breadcrumbs"
@breadcrumbClick="console.log(`breadcrumb #${$event + 1} clicked.`)"
@back="exitNode"
@forward="console.log('breadcrumbs \'forward\' button clicked.')"
:breadcrumbs="stackNavigator.breadcrumbLabels.value"
:allowNavigationLeft="stackNavigator.allowNavigationLeft.value"
:allowNavigationRight="stackNavigator.allowNavigationRight.value"
@breadcrumbClick="stackNavigator.handleBreadcrumbClick"
@back="stackNavigator.exitNode"
@forward="stackNavigator.enterNextNodeFromHistory"
@execute="onPlayButtonPress()"
/>
<PlusButton @pointerdown="interaction.setCurrent(creatingNodeFromButton)" />

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,18 @@
<script setup lang="ts">
import NavBar from '@/components/NavBar.vue'
import type { BreadcrumbItem } from '@/components/NavBreadcrumbs.vue'
import ProjectTitle from '@/components/ProjectTitle.vue'
import { injectGuiConfig } from '@/providers/guiConfig'
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<{
execute: []
back: []
@ -36,6 +44,8 @@ const barStyle = computed(() => {
/>
<NavBar
:breadcrumbs="props.breadcrumbs"
:allowNavigationLeft="props.allowNavigationLeft"
:allowNavigationRight="props.allowNavigationRight"
@back="emit('back')"
@forward="emit('forward')"
@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,
}
}