Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display component evaluating status #12126

Merged
merged 2 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/gui/integration-test/project-view/graphRenderNodes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test } from '@playwright/test'
import * as actions from './actions'
import { expect } from './customExpect'
import { mockExpressionUpdate } from './expressionUpdates'
import * as locate from './locate'

test('graph can open and render nodes', async ({ page }) => {
Expand All @@ -16,3 +17,12 @@ test('graph can open and render nodes', async ({ page }) => {
const finalNode = locate.graphNodeByBinding(page, 'final')
await expect(finalNode.locator('.WidgetToken')).toHaveText(['Main', '.', 'func1', 'prod'])
})

test('Component icon indicates evaluation in progress', async ({ page }) => {
await actions.goToGraph(page)

const node = locate.graphNodeByBinding(page, 'final')
await expect(node.locator('.WidgetIcon .LoadingSpinner')).not.toBeVisible()
await mockExpressionUpdate(page, 'final', { payload: { type: 'Pending', progress: 0.1 } })
await expect(node.locator('.WidgetIcon .LoadingSpinner')).toBeVisible()
})
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<script setup lang="ts">
import { DisplayIcon } from '@/components/GraphEditor/widgets/WidgetIcon.vue'
import WidgetTreeRoot from '@/components/GraphEditor/WidgetTreeRoot.vue'
import { injectGraphSelection } from '@/providers/graphSelection'
import { WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
import { WidgetEditHandlerParent } from '@/providers/widgetRegistry/editHandler'
import { useGraphStore, type NodeId } from '@/stores/graph'
import type { NodeType } from '@/stores/graph/graphDatabase'
import { type NodeType } from '@/stores/graph/graphDatabase'
import { Ast } from '@/util/ast'
import { iconOfNode } from '@/util/getIconName'
import { computed } from 'vue'
import { DisplayIcon } from './widgets/WidgetIcon.vue'
import WidgetTreeRoot from './WidgetTreeRoot.vue'
import { iconOfNode, useDisplayedIcon } from '@/util/getIconName'
import { computed, toRef } from 'vue'

const props = defineProps<{
ast: Ast.Expression
Expand All @@ -20,7 +20,13 @@ const props = defineProps<{
conditionalPorts: Set<Ast.AstId>
extended: boolean
}>()

const graph = useGraphStore()
const selection = injectGraphSelection()

const baseIcon = computed(() => iconOfNode(props.nodeId, graph.db))
const { displayedIcon } = useDisplayedIcon(graph.db, toRef(props, 'nodeId'), baseIcon)

const rootPort = computed(() => {
const input = WidgetInput.FromAst(props.ast)
if (
Expand All @@ -30,15 +36,14 @@ const rootPort = computed(() => {
input.forcePort = true
}

if (!props.potentialSelfArgumentId && topLevelIcon.value) {
if (!props.potentialSelfArgumentId) {
input[DisplayIcon] = {
icon: topLevelIcon.value,
icon: displayedIcon.value,
showContents: props.nodeType != 'output',
}
}
return input
})
const selection = injectGraphSelection()

function selectNode() {
selection.setSelection(new Set([props.nodeId]))
Expand Down Expand Up @@ -77,8 +82,6 @@ function handleWidgetUpdates(update: WidgetUpdate) {
return true
}

const topLevelIcon = computed(() => iconOfNode(props.nodeId, graph.db))

function onCurrentEditChange(currentEdit: WidgetEditHandlerParent | undefined) {
if (currentEdit) selectNode()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import LoadingSpinner from '@/components/shared/LoadingSpinner.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import type { URLString } from '@/util/data/urlString'
import type { Icon } from '@/util/iconMetadata/iconName'
import NodeWidget from '../NodeWidget.vue'
import { type URLString } from '@/util/data/urlString'
import { type Icon } from '@/util/iconMetadata/iconName'
import { computed } from 'vue'

const props = defineProps(widgetProps(widgetDefinition))

const icon = computed(() => props.input[DisplayIcon].icon)
</script>

<script lang="ts">
export const DisplayIcon: unique symbol = Symbol.for('WidgetInput:DisplayIcon')
declare module '@/providers/widgetRegistry' {
export interface WidgetInput {
[DisplayIcon]?: {
icon: Icon | URLString
icon: Icon | URLString | '$evaluating'
allowChoice?: boolean
showContents?: boolean
}
Expand All @@ -32,7 +36,16 @@ export const widgetDefinition = defineWidget(

<template>
<div class="WidgetIcon">
<SvgIcon class="nodeCategoryIcon grab-handle" :name="props.input[DisplayIcon].icon" />
<div class="iconContainer">
<Transition>
<LoadingSpinner
v-if="icon === '$evaluating'"
class="nodeCategoryIcon grab-handle"
:size="16"
/>
<SvgIcon v-else class="nodeCategoryIcon grab-handle" :name="icon" />
</Transition>
</div>
<NodeWidget v-if="props.input[DisplayIcon].showContents === true" :input="props.input" />
</div>
</template>
Expand All @@ -43,10 +56,33 @@ export const widgetDefinition = defineWidget(
flex-direction: row;
align-items: center;
gap: var(--widget-token-pad-unit);

> .SvgIcon {
margin: 0 calc((var(--node-port-height) - 16px) / 2);
display: flex;
}
.iconContainer {
position: relative;
height: 16px;
width: 16px;
margin: 0 calc((var(--node-port-height) - 16px) / 2);
}
.nodeCategoryIcon {
position: absolute;
}
.LoadingSpinner {
border: 4px solid;
border-radius: 100%;
border-color: rgba(255, 255, 255, 90%) #0000;
animation: s1 0.8s infinite;
}
@keyframes s1 {
to {
transform: rotate(0.5turn);
}
}
.v-enter-active,
.v-leave-active {
transition: opacity 0.1s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { DisplayIcon } from '@/components/GraphEditor/widgets/WidgetIcon.vue'
import { injectFunctionInfo } from '@/providers/functionInfo'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { injectWidgetTree } from '@/providers/widgetTree'
import { useGraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast'
import { displayedIconOf } from '@/util/getIconName'
import { computed } from 'vue'
import { DisplayIcon } from './WidgetIcon.vue'
import { displayedIconOf, useDisplayedIcon } from '@/util/getIconName'
import { computed, toRef } from 'vue'

const props = defineProps(widgetProps(widgetDefinition))
const functionInfo = injectFunctionInfo(true)
const graph = useGraphStore()
const tree = injectWidgetTree()

const displayedIcon = computed(() => {
const baseIcon = computed(() => {
const callInfo = functionInfo?.callInfo
return displayedIconOf(
callInfo?.suggestion,
callInfo?.methodCall.methodPointer,
functionInfo?.outputType ?? 'Unknown',
)
})
const { displayedIcon } = useDisplayedIcon(graph.db, toRef(tree, 'externalId'), baseIcon)

const iconInput = computed(() => {
const lhs = props.input.value.lhs
if (!lhs) return
const input = WidgetInput.WithPort(WidgetInput.FromAst(lhs))
const icon = displayedIcon.value
if (icon) input[DisplayIcon] = { icon, showContents: showFullAccessChain.value }
input[DisplayIcon] = { icon: displayedIcon.value, showContents: showFullAccessChain.value }
return input
})

Expand Down
28 changes: 25 additions & 3 deletions app/gui/src/project-view/util/getIconName.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import type { NodeId } from '@/stores/graph'
import { GraphDb } from '@/stores/graph/graphDatabase'
import { type NodeId } from '@/stores/graph'
import { type GraphDb } from '@/stores/graph/graphDatabase'
import {
SuggestionKind,
type SuggestionEntry,
type Typename,
} from '@/stores/suggestionDatabase/entry'
import type { Icon } from '@/util/iconMetadata/iconName'
import { type URLString } from '@/util/data/urlString'
import { type Icon } from '@/util/iconMetadata/iconName'
import { type MethodPointer } from '@/util/methodPointer'
import { type ToValue } from '@/util/reactivity'
import { computed, toValue } from 'vue'
import { type ExternalId } from 'ydoc-shared/yjsModel'

const typeNameToIconLookup: Record<string, Icon> = {
'Standard.Base.Data.Text.Text': 'text_input',
Expand Down Expand Up @@ -68,3 +72,21 @@ export function iconOfNode(node: NodeId, graphDb: GraphDb) {
return 'data_input'
}
}

/**
* Returns the icon to show on a component, using either the provided base icon or an icon representing its current
* status.
*/
export function useDisplayedIcon(
graphDb: GraphDb,
externalId: ToValue<ExternalId>,
baseIcon: ToValue<Icon | URLString>,
) {
const evaluating = computed(() => {
const payload = graphDb.getExpressionInfo(toValue(externalId))?.payload
return payload?.type === 'Pending' && payload.progress
})
return {
displayedIcon: computed(() => (evaluating.value ? '$evaluating' : toValue(baseIcon))),
}
}
Loading