Vue dependency update, better selection performance, visible quotes in text inputs (#9204)

- Improved performance by batching simulatenous node edits, including metadata updates when dragging many selected nodes together.
- Updated Vue to new version, allowing us to use `defineModel`.
- Fixed #9161
- Unified all handling of auto-blur by making `useAutoBlur` cheap to register - all logic goes through a single window event handler.
- Combined all `ResizeObserver`s into one.
- Fixed the behaviour of repeated toast messages. Now only the latest compilation status is visible at any given time, and the errors disappear once compilation passes.
- Actually fixed broken interaction of node and visualization widths. There no longer is a style feedback loop and the visible node backdrop width no longer jumps or randomly fails to update.
This commit is contained in:
Paweł Grabarz 2024-03-06 16:34:07 +01:00 committed by GitHub
parent d4b2390fc1
commit b7a8909818
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
122 changed files with 7144 additions and 5525 deletions

View File

@ -1 +1 @@
18.14.1 20.11.1

View File

@ -233,8 +233,8 @@ class TableVisualization extends Visualization {
parsedData.data && parsedData.data.length > 0 parsedData.data && parsedData.data.length > 0
? parsedData.data[0].length ? parsedData.data[0].length
: parsedData.indices && parsedData.indices.length > 0 : parsedData.indices && parsedData.indices.length > 0
? parsedData.indices[0].length ? parsedData.indices[0].length
: 0 : 0
rowData = Array.apply(null, Array(rows)).map((_, i) => { rowData = Array.apply(null, Array(rows)).map((_, i) => {
const row = {} const row = {}
const shift = parsedData.indices ? parsedData.indices.length : 0 const shift = parsedData.indices ? parsedData.indices.length : 0

View File

@ -6,5 +6,6 @@
"singleQuote": true, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"trailingComma": "all", "trailingComma": "all",
"organizeImportsSkipDestructiveCodeActions": true "organizeImportsSkipDestructiveCodeActions": true,
"experimentalTernaries": true
} }

View File

@ -28,8 +28,8 @@ onMounted(() => {
</MockProjectStoreWrapper> </MockProjectStoreWrapper>
</template> </template>
<style scoped> <style>
:is(.viewport) { :deep(.viewport) {
color: var(--color-text); color: var(--color-text);
font-family: var(--font-code); font-family: var(--font-code);
font-size: 11.5px; font-size: 11.5px;

View File

@ -1,5 +1,5 @@
import { expect, type Page } from '@playwright/test' import { type Page } from '@playwright/test'
import * as customExpect from './customExpect' import { expect } from './customExpect'
import * as locate from './locate' import * as locate from './locate'
import { graphNodeByBinding } from './locate' import { graphNodeByBinding } from './locate'
@ -12,14 +12,25 @@ export async function goToGraph(page: Page) {
await page.goto('/') await page.goto('/')
await expect(page.locator('.App')).toBeVisible() await expect(page.locator('.App')).toBeVisible()
// Wait until nodes are loaded. // Wait until nodes are loaded.
await customExpect.toExist(locate.graphNode(page)) await expect(locate.graphNode(page)).toExist()
// Wait for position initialization // Wait for position initialization
await expectNodePositionsInitialized(page, 64)
}
export async function expectNodePositionsInitialized(page: Page, yPos: number) {
// TODO: The yPos should not need to be a variable. Instead, first automatically positioned nodes
// should always have constant known position. This is a bug caused by incorrect layout after
// entering a function. To be fixed with #9255
await expect(locate.graphNode(page).first()).toHaveCSS( await expect(locate.graphNode(page).first()).toHaveCSS(
'transform', 'transform',
'matrix(1, 0, 0, 1, -16, -16)', `matrix(1, 0, 0, 1, -16, ${yPos})`,
) )
} }
export async function exitFunction(page: Page, x = 300, y = 300) {
await page.mouse.dblclick(x, y, { delay: 10 })
}
// ================= // =================
// === Drag Node === // === Drag Node ===
// ================= // =================

View File

@ -1,7 +1,7 @@
import { expect, test, type Page } from '@playwright/test' import { test, type Page } from '@playwright/test'
import os from 'os' import os from 'os'
import * as actions from './actions' import * as actions from './actions'
import * as customExpect from './customExpect' import { expect } from './customExpect'
import { mockCollapsedFunctionInfo } from './expressionUpdates' import { mockCollapsedFunctionInfo } from './expressionUpdates'
import * as locate from './locate' import * as locate from './locate'
@ -27,19 +27,19 @@ test('Leaving entered nodes', async ({ page }) => {
await actions.goToGraph(page) await actions.goToGraph(page)
await enterToFunc2(page) await enterToFunc2(page)
await page.mouse.dblclick(100, 100) await actions.exitFunction(page)
await expectInsideFunc1(page) await expectInsideFunc1(page)
await page.mouse.dblclick(100, 100) await actions.exitFunction(page)
await expectInsideMain(page) await expectInsideMain(page)
}) })
test('Using breadcrumbs to navigate', async ({ page }) => { test('Using breadcrumbs to navigate', async ({ page }) => {
await actions.goToGraph(page) await actions.goToGraph(page)
await enterToFunc2(page) await enterToFunc2(page)
await page.mouse.dblclick(100, 100) await actions.exitFunction(page)
await expectInsideFunc1(page) await expectInsideFunc1(page)
await page.mouse.dblclick(100, 100) await actions.exitFunction(page)
await expectInsideMain(page) await expectInsideMain(page)
// Breadcrumbs still have all the crumbs, but the last two are dimmed. // Breadcrumbs still have all the crumbs, but the last two are dimmed.
await expect(locate.navBreadcrumb(page)).toHaveText(['Mock Project', 'func1', 'func2']) await expect(locate.navBreadcrumb(page)).toHaveText(['Mock Project', 'func1', 'func2'])
@ -85,9 +85,9 @@ test('Collapsing nodes', async ({ page }) => {
await collapsedNode.dblclick() await collapsedNode.dblclick()
await expect(locate.graphNode(page)).toHaveCount(4) await expect(locate.graphNode(page)).toHaveCount(4)
await customExpect.toExist(locate.graphNodeByBinding(page, 'ten')) await expect(locate.graphNodeByBinding(page, 'ten')).toExist()
await customExpect.toExist(locate.graphNodeByBinding(page, 'sum')) await expect(locate.graphNodeByBinding(page, 'sum')).toExist()
await customExpect.toExist(locate.graphNodeByBinding(page, 'prod')) await expect(locate.graphNodeByBinding(page, 'prod')).toExist()
await locate await locate
.graphNodeByBinding(page, 'ten') .graphNodeByBinding(page, 'ten')
@ -103,31 +103,34 @@ test('Collapsing nodes', async ({ page }) => {
await mockCollapsedFunctionInfo(page, 'ten', 'collapsed1') await mockCollapsedFunctionInfo(page, 'ten', 'collapsed1')
await secondCollapsedNode.dblclick() await secondCollapsedNode.dblclick()
await expect(locate.graphNode(page)).toHaveCount(2) await expect(locate.graphNode(page)).toHaveCount(2)
await customExpect.toExist(locate.graphNodeByBinding(page, 'ten')) await expect(locate.graphNodeByBinding(page, 'ten')).toExist()
}) })
async function expectInsideMain(page: Page) { async function expectInsideMain(page: Page) {
await actions.expectNodePositionsInitialized(page, 64)
await expect(locate.graphNode(page)).toHaveCount(10) await expect(locate.graphNode(page)).toHaveCount(10)
await customExpect.toExist(locate.graphNodeByBinding(page, 'five')) await expect(locate.graphNodeByBinding(page, 'five')).toExist()
await customExpect.toExist(locate.graphNodeByBinding(page, 'ten')) await expect(locate.graphNodeByBinding(page, 'ten')).toExist()
await customExpect.toExist(locate.graphNodeByBinding(page, 'sum')) await expect(locate.graphNodeByBinding(page, 'sum')).toExist()
await customExpect.toExist(locate.graphNodeByBinding(page, 'prod')) await expect(locate.graphNodeByBinding(page, 'prod')).toExist()
await customExpect.toExist(locate.graphNodeByBinding(page, 'final')) await expect(locate.graphNodeByBinding(page, 'final')).toExist()
await customExpect.toExist(locate.graphNodeByBinding(page, 'list')) await expect(locate.graphNodeByBinding(page, 'list')).toExist()
await customExpect.toExist(locate.graphNodeByBinding(page, 'data')) await expect(locate.graphNodeByBinding(page, 'data')).toExist()
await customExpect.toExist(locate.graphNodeByBinding(page, 'aggregated')) await expect(locate.graphNodeByBinding(page, 'aggregated')).toExist()
await customExpect.toExist(locate.graphNodeByBinding(page, 'filtered')) await expect(locate.graphNodeByBinding(page, 'filtered')).toExist()
} }
async function expectInsideFunc1(page: Page) { async function expectInsideFunc1(page: Page) {
await actions.expectNodePositionsInitialized(page, 192)
await expect(locate.graphNode(page)).toHaveCount(3) await expect(locate.graphNode(page)).toHaveCount(3)
await customExpect.toExist(locate.graphNodeByBinding(page, 'f2')) await expect(locate.graphNodeByBinding(page, 'f2')).toExist()
await customExpect.toExist(locate.graphNodeByBinding(page, 'result')) await expect(locate.graphNodeByBinding(page, 'result')).toExist()
} }
async function expectInsideFunc2(page: Page) { async function expectInsideFunc2(page: Page) {
await actions.expectNodePositionsInitialized(page, 128)
await expect(locate.graphNode(page)).toHaveCount(2) await expect(locate.graphNode(page)).toHaveCount(2)
await customExpect.toExist(locate.graphNodeByBinding(page, 'r')) await expect(locate.graphNodeByBinding(page, 'r')).toExist()
} }
async function enterToFunc2(page: Page) { async function enterToFunc2(page: Page) {

View File

@ -1,8 +1,8 @@
import { expect, test, type Page } from '@playwright/test' import { test, type Page } from '@playwright/test'
import assert from 'assert' import assert from 'assert'
import os from 'os' import os from 'os'
import * as actions from './actions' import * as actions from './actions'
import * as customExpect from './customExpect' import { expect } from './customExpect'
import * as locate from './locate' import * as locate from './locate'
const CONTROL_KEY = os.platform() === 'darwin' ? 'Meta' : 'Control' const CONTROL_KEY = os.platform() === 'darwin' ? 'Meta' : 'Control'
@ -18,8 +18,8 @@ test('Different ways of opening Component Browser', async ({ page }) => {
const nodeCount = await locate.graphNode(page).count() const nodeCount = await locate.graphNode(page).count()
async function expectAndCancelBrowser(expectedInput: string) { async function expectAndCancelBrowser(expectedInput: string) {
await customExpect.toExist(locate.componentBrowser(page)) await expect(locate.componentBrowser(page)).toExist()
await customExpect.toExist(locate.componentBrowserEntry(page)) await expect(locate.componentBrowserEntry(page)).toExist()
await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue(expectedInput) await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue(expectedInput)
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await expect(locate.componentBrowser(page)).not.toBeVisible() await expect(locate.componentBrowser(page)).not.toBeVisible()
@ -79,7 +79,7 @@ test('Accepting suggestion', async ({ page }) => {
'.', '.',
'read_text', 'read_text',
]) ])
await customExpect.toBeSelected(locate.graphNode(page).last()) await expect(locate.graphNode(page).last()).toBeSelected()
// Clicking at highlighted entry // Clicking at highlighted entry
nodeCount = await locate.graphNode(page).count() nodeCount = await locate.graphNode(page).count()
@ -93,7 +93,7 @@ test('Accepting suggestion', async ({ page }) => {
'.', '.',
'read', 'read',
]) ])
await customExpect.toBeSelected(locate.graphNode(page).last()) await expect(locate.graphNode(page).last()).toBeSelected()
// Accepting with Enter // Accepting with Enter
nodeCount = await locate.graphNode(page).count() nodeCount = await locate.graphNode(page).count()
@ -107,7 +107,7 @@ test('Accepting suggestion', async ({ page }) => {
'.', '.',
'read', 'read',
]) ])
await customExpect.toBeSelected(locate.graphNode(page).last()) await expect(locate.graphNode(page).last()).toBeSelected()
}) })
test('Accepting any written input', async ({ page }) => { test('Accepting any written input', async ({ page }) => {
@ -127,14 +127,14 @@ test('Filling input with suggestions', async ({ page }) => {
// Entering module // Entering module
await locate.componentBrowserEntryByLabel(page, 'Standard.Base.Data').click() await locate.componentBrowserEntryByLabel(page, 'Standard.Base.Data').click()
await customExpect.toExist(locate.componentBrowser(page)) await expect(locate.componentBrowser(page)).toExist()
await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue( await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue(
'Standard.Base.Data.', 'Standard.Base.Data.',
) )
// Applying suggestion // Applying suggestion
await page.keyboard.press('Tab') await page.keyboard.press('Tab')
await customExpect.toExist(locate.componentBrowser(page)) await expect(locate.componentBrowser(page)).toExist()
await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue( await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue(
'Standard.Base.Data.read ', 'Standard.Base.Data.read ',
) )
@ -167,8 +167,8 @@ test('Editing existing nodes', async ({ page }) => {
await expect(input).toHaveValue(`Data.read ${ADDED_PATH}`) await expect(input).toHaveValue(`Data.read ${ADDED_PATH}`)
await page.keyboard.press('Enter') await page.keyboard.press('Enter')
await expect(locate.componentBrowser(page)).not.toBeVisible() await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(node.locator('.WidgetToken')).toHaveText(['Data', '.', 'read']) await expect(node.locator('.WidgetToken')).toHaveText(['Data', '.', 'read', '"', '"'])
await expect(node.locator('.WidgetText input')).toHaveValue(ADDED_PATH) await expect(node.locator('.WidgetText input')).toHaveValue(ADDED_PATH.replaceAll('"', ''))
// Edit again, using "edit" button // Edit again, using "edit" button
await locate.graphNodeIcon(node).click() await locate.graphNodeIcon(node).click()
@ -187,12 +187,12 @@ test('Visualization preview: type-based visualization selection', async ({ page
await actions.goToGraph(page) await actions.goToGraph(page)
const nodeCount = await locate.graphNode(page).count() const nodeCount = await locate.graphNode(page).count()
await locate.addNewNodeButton(page).click() await locate.addNewNodeButton(page).click()
await customExpect.toExist(locate.componentBrowser(page)) await expect(locate.componentBrowser(page)).toExist()
await customExpect.toExist(locate.componentBrowserEntry(page)) await expect(locate.componentBrowserEntry(page)).toExist()
const input = locate.componentBrowserInput(page).locator('input') const input = locate.componentBrowserInput(page).locator('input')
await input.fill('4') await input.fill('4')
await expect(input).toHaveValue('4') await expect(input).toHaveValue('4')
await customExpect.toExist(locate.jsonVisualization(page)) await expect(locate.jsonVisualization(page)).toExist()
await input.fill('Table.ne') await input.fill('Table.ne')
await expect(input).toHaveValue('Table.ne') await expect(input).toHaveValue('Table.ne')
// The table visualization is not currently working with `executeExpression` (#9194), but we can test that the JSON // The table visualization is not currently working with `executeExpression` (#9194), but we can test that the JSON
@ -207,12 +207,12 @@ test('Visualization preview: user visualization selection', async ({ page }) =>
await actions.goToGraph(page) await actions.goToGraph(page)
const nodeCount = await locate.graphNode(page).count() const nodeCount = await locate.graphNode(page).count()
await locate.addNewNodeButton(page).click() await locate.addNewNodeButton(page).click()
await customExpect.toExist(locate.componentBrowser(page)) await expect(locate.componentBrowser(page)).toExist()
await customExpect.toExist(locate.componentBrowserEntry(page)) await expect(locate.componentBrowserEntry(page)).toExist()
const input = locate.componentBrowserInput(page).locator('input') const input = locate.componentBrowserInput(page).locator('input')
await input.fill('4') await input.fill('4')
await expect(input).toHaveValue('4') await expect(input).toHaveValue('4')
await customExpect.toExist(locate.jsonVisualization(page)) await expect(locate.jsonVisualization(page)).toExist()
await locate.showVisualizationSelectorButton(page).click() await locate.showVisualizationSelectorButton(page).click()
await page.getByRole('button', { name: 'Table' }).click() await page.getByRole('button', { name: 'Table' }).click()
// The table visualization is not currently working with `executeExpression` (#9194), but we can test that the JSON // The table visualization is not currently working with `executeExpression` (#9194), but we can test that the JSON

View File

@ -1,19 +1,52 @@
import { expect, type Locator } from 'playwright/test' import { expect as baseExpect, type Locator } from 'playwright/test'
/** Ensures that at least one of the elements that the Locator points to, export const expect = baseExpect.extend({
* is an attached and visible DOM node. */ /** Ensures that at least one of the elements that the Locator points to,
export function toExist(locator: Locator) { * is an attached and visible DOM node. */
// Counter-intuitive, but correct: async toExist(locator: Locator) {
// https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-visible // Counter-intuitive, but correct:
return expect(locator.first()).toBeVisible() // https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-visible
} const assertionName = 'toExist'
let pass: boolean
try {
await expect(locator.first()).toBeVisible()
pass = true
} catch (e: any) {
console.log(e)
pass = false
}
export function toBeSelected(locator: Locator) { const message = () =>
return expect(locator).toHaveClass(/(?<=^| )selected(?=$| )/) this.utils.matcherHint(assertionName, locator, '', {
} isNot: this.isNot,
})
export module not { return {
export function toBeSelected(locator: Locator) { message,
return expect(locator).not.toHaveClass(/(?<=^| )selected(?=$| )/) pass,
} name: assertionName,
} }
},
async toBeSelected(locator: Locator) {
const assertionName = 'toBeSelected'
let pass: boolean
try {
await baseExpect(locator).toHaveClass(/(?<=^| )selected(?=$| )/, { timeout: 50 })
pass = true
} catch (e: any) {
pass = false
}
const message = () =>
this.utils.matcherHint(assertionName, locator, '', {
isNot: this.isNot,
})
return {
message,
pass,
name: assertionName,
}
},
})

View File

@ -1,6 +1,6 @@
import { expect, test, type Page } from '@playwright/test' import { test } from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
import * as customExpect from './customExpect' import { expect } from './customExpect'
import * as locate from './locate' import * as locate from './locate'
import { graphNodeByBinding } from './locate' import { graphNodeByBinding } from './locate'
@ -17,11 +17,11 @@ test('Load Fullscreen Visualisation', async ({ page }) => {
const fullscreenButton = locate.enterFullscreenButton(aggregatedNode) const fullscreenButton = locate.enterFullscreenButton(aggregatedNode)
await fullscreenButton.click() await fullscreenButton.click()
const vis = locate.jsonVisualization(page) const vis = locate.jsonVisualization(page)
await customExpect.toExist(vis) await expect(vis).toExist()
await customExpect.toExist(locate.exitFullscreenButton(page)) await expect(locate.exitFullscreenButton(page)).toExist()
const visBoundingBox = await vis.boundingBox() const visBoundingBox = await vis.boundingBox()
expect(visBoundingBox!.height).toBe(808) expect(visBoundingBox?.height).toBeGreaterThan(600)
expect(visBoundingBox!.width).toBe(1920) expect(visBoundingBox?.width).toBe(1920)
const jsonContent = await vis.textContent().then((text) => JSON.parse(text!)) const jsonContent = await vis.textContent().then((text) => JSON.parse(text!))
expect(jsonContent).toEqual({ expect(jsonContent).toEqual({
axis: { axis: {

View File

@ -1,18 +1,18 @@
import { expect, test } from '@playwright/test' import { test } from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
import * as customExpect from './customExpect' import { expect } from './customExpect'
import * as locate from './locate' import * as locate from './locate'
test('node can open and load visualization', async ({ page }) => { test('node can open and load visualization', async ({ page }) => {
await actions.goToGraph(page) await actions.goToGraph(page)
const node = locate.graphNode(page).last() const node = locate.graphNode(page).last()
await node.click({ position: { x: 8, y: 8 } }) await node.click({ position: { x: 8, y: 8 } })
await customExpect.toExist(locate.circularMenu(page)) await expect(locate.circularMenu(page)).toExist()
await locate.toggleVisualizationButton(page).click() await locate.toggleVisualizationButton(page).click()
await customExpect.toExist(locate.anyVisualization(page)) await expect(locate.anyVisualization(page)).toExist()
await locate.showVisualizationSelectorButton(page).click() await locate.showVisualizationSelectorButton(page).click()
await page.getByText('JSON').click() await page.getByText('JSON').click()
await customExpect.toExist(locate.jsonVisualization(page)) await expect(locate.jsonVisualization(page)).toExist()
// The default JSON viz data contains an object. // The default JSON viz data contains an object.
await expect(locate.jsonVisualization(page)).toContainText('{') await expect(locate.jsonVisualization(page)).toContainText('{')
}) })

View File

@ -1,12 +1,12 @@
import { expect, test } from '@playwright/test' import { test } from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
import * as customExpect from './customExpect' import { expect } from './customExpect'
import * as locate from './locate' import * as locate from './locate'
test('graph can open and render nodes', async ({ page }) => { test('graph can open and render nodes', async ({ page }) => {
await actions.goToGraph(page) await actions.goToGraph(page)
await customExpect.toExist(locate.graphEditor(page)) await expect(locate.graphEditor(page)).toExist()
await customExpect.toExist(locate.graphNode(page)) await expect(locate.graphNode(page)).toExist()
// check simple node's content (without input widgets) // check simple node's content (without input widgets)
const sumNode = locate.graphNodeByBinding(page, 'sum') const sumNode = locate.graphNodeByBinding(page, 'sum')

View File

@ -132,10 +132,9 @@ export function graphNodeIcon(node: Node) {
// === Data locators === // === Data locators ===
type SanitizeClassName<T extends string> = T extends `${infer A}.${infer B}` type SanitizeClassName<T extends string> =
? SanitizeClassName<`${A}${B}`> T extends `${infer A}.${infer B}` ? SanitizeClassName<`${A}${B}`>
: T extends `${infer A} ${infer B}` : T extends `${infer A} ${infer B}` ? SanitizeClassName<`${A}${B}`>
? SanitizeClassName<`${A}${B}`>
: T : T
function componentLocator<T extends string>(className: SanitizeClassName<T>) { function componentLocator<T extends string>(className: SanitizeClassName<T>) {

View File

@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
import { initializeFFI } from 'shared/ast/ffi' import { initializeFFI } from 'shared/ast/ffi'
import { createApp, ref } from 'vue' import { createApp, ref } from 'vue'
import { mockDataHandler, mockLSHandler } from '../mock/engine' import { mockDataHandler, mockLSHandler } from '../mock/engine'
import '../src/assets/base.css' import '../src/assets/main.css'
import { provideGuiConfig } from '../src/providers/guiConfig' import { provideGuiConfig } from '../src/providers/guiConfig'
import { provideVisualizationConfig } from '../src/providers/visualizationConfig' import { provideVisualizationConfig } from '../src/providers/visualizationConfig'
import { Vec2 } from '../src/util/data/vec2' import { Vec2 } from '../src/util/data/vec2'

View File

@ -1,44 +1,44 @@
import { expect, test } from '@playwright/test' import { test } from '@playwright/test'
import assert from 'assert' import assert from 'assert'
import * as actions from './actions' import * as actions from './actions'
import * as customExpect from './customExpect' import { expect } from './customExpect'
import * as locate from './locate' import * as locate from './locate'
test('Selecting nodes by click', async ({ page }) => { test('Selecting nodes by click', async ({ page }) => {
await actions.goToGraph(page) await actions.goToGraph(page)
const node1 = locate.graphNodeByBinding(page, 'five') const node1 = locate.graphNodeByBinding(page, 'five')
const node2 = locate.graphNodeByBinding(page, 'ten') const node2 = locate.graphNodeByBinding(page, 'ten')
await customExpect.not.toBeSelected(node1) await expect(node1).not.toBeSelected()
await customExpect.not.toBeSelected(node2) await expect(node2).not.toBeSelected()
await locate.graphNodeIcon(node1).click() await locate.graphNodeIcon(node1).click()
await customExpect.toBeSelected(node1) await expect(node1).toBeSelected()
await customExpect.not.toBeSelected(node2) await expect(node2).not.toBeSelected()
await locate.graphNodeIcon(node2).click() await locate.graphNodeIcon(node2).click()
await customExpect.not.toBeSelected(node1) await expect(node1).not.toBeSelected()
await customExpect.toBeSelected(node2) await expect(node2).toBeSelected()
await page.waitForTimeout(600) // Avoid double clicks await page.waitForTimeout(300) // Avoid double clicks
await locate.graphNodeIcon(node1).click({ modifiers: ['Shift'] }) await locate.graphNodeIcon(node1).click({ modifiers: ['Shift'] })
await customExpect.toBeSelected(node1) await expect(node1).toBeSelected()
await customExpect.toBeSelected(node2) await expect(node2).toBeSelected()
await locate.graphNodeIcon(node2).click() await locate.graphNodeIcon(node2).click()
await customExpect.not.toBeSelected(node1) await expect(node1).not.toBeSelected()
await customExpect.toBeSelected(node2) await expect(node2).toBeSelected()
await page.mouse.click(200, 200) await page.mouse.click(600, 200)
await customExpect.not.toBeSelected(node1) await expect(node1).not.toBeSelected()
await customExpect.not.toBeSelected(node2) await expect(node2).not.toBeSelected()
}) })
test('Selecting nodes by area drag', async ({ page }) => { test('Selecting nodes by area drag', async ({ page }) => {
await actions.goToGraph(page) await actions.goToGraph(page)
const node1 = locate.graphNodeByBinding(page, 'five') const node1 = locate.graphNodeByBinding(page, 'five')
const node2 = locate.graphNodeByBinding(page, 'ten') const node2 = locate.graphNodeByBinding(page, 'ten')
await customExpect.not.toBeSelected(node1) await expect(node1).not.toBeSelected()
await customExpect.not.toBeSelected(node2) await expect(node2).not.toBeSelected()
const node1BBox = await node1.locator('.selection').boundingBox() const node1BBox = await node1.locator('.selection').boundingBox()
const node2BBox = await node2.boundingBox() const node2BBox = await node2.boundingBox()
@ -49,9 +49,9 @@ test('Selecting nodes by area drag', async ({ page }) => {
await page.mouse.move(node1BBox.x - 49, node1BBox.y - 49) await page.mouse.move(node1BBox.x - 49, node1BBox.y - 49)
await expect(page.locator('.SelectionBrush')).toBeVisible() await expect(page.locator('.SelectionBrush')).toBeVisible()
await page.mouse.move(node2BBox.x + node2BBox.width, node2BBox.y + node2BBox.height) await page.mouse.move(node2BBox.x + node2BBox.width, node2BBox.y + node2BBox.height)
await customExpect.toBeSelected(node1) await expect(node1).toBeSelected()
await customExpect.toBeSelected(node2) await expect(node2).toBeSelected()
await page.mouse.up() await page.mouse.up()
await customExpect.toBeSelected(node1) await expect(node1).toBeSelected()
await customExpect.toBeSelected(node2) await expect(node2).toBeSelected()
}) })

View File

@ -1,6 +1,6 @@
import { expect, test, type Page } from '@playwright/test' import { test, type Page } from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
import * as customExpect from './customExpect' import { expect } from './customExpect'
import { mockExpressionUpdate } from './expressionUpdates' import { mockExpressionUpdate } from './expressionUpdates'
import * as locate from './locate' import * as locate from './locate'
import { graphNodeByBinding } from './locate' import { graphNodeByBinding } from './locate'
@ -26,7 +26,7 @@ test('Load Table Visualisation', async ({ page }) => {
await page.keyboard.press('Space') await page.keyboard.press('Space')
await page.waitForTimeout(1000) await page.waitForTimeout(1000)
const tableVisualization = locate.tableVisualization(page) const tableVisualization = locate.tableVisualization(page)
await customExpect.toExist(tableVisualization) await expect(tableVisualization).toExist()
await expect(tableVisualization).toContainText('10 rows.') await expect(tableVisualization).toContainText('10 rows.')
await expect(tableVisualization).toContainText('0,0') await expect(tableVisualization).toContainText('0,0')
await expect(tableVisualization).toContainText('1,0') await expect(tableVisualization).toContainText('1,0')

View File

@ -1,5 +1,6 @@
import test, { expect, type Locator, type Page } from 'playwright/test' import test, { type Locator, type Page } from 'playwright/test'
import * as actions from './actions' import * as actions from './actions'
import { expect } from './customExpect'
import { mockMethodCallInfo } from './expressionUpdates' import { mockMethodCallInfo } from './expressionUpdates'
import * as locate from './locate' import * as locate from './locate'
@ -32,7 +33,7 @@ test('Widget in plain AST', async ({ page }) => {
const numberNode = locate.graphNodeByBinding(page, 'five') const numberNode = locate.graphNodeByBinding(page, 'five')
const numberWidget = numberNode.locator('.WidgetNumber') const numberWidget = numberNode.locator('.WidgetNumber')
await expect(numberWidget).toBeVisible() await expect(numberWidget).toBeVisible()
await expect(numberWidget.locator('.value')).toHaveValue('5') await expect(numberWidget.locator('input')).toHaveValue('5')
const listNode = locate.graphNodeByBinding(page, 'list') const listNode = locate.graphNodeByBinding(page, 'list')
const listWidget = listNode.locator('.WidgetVector') const listWidget = listNode.locator('.WidgetVector')
@ -41,7 +42,7 @@ test('Widget in plain AST', async ({ page }) => {
const textNode = locate.graphNodeByBinding(page, 'text') const textNode = locate.graphNodeByBinding(page, 'text')
const textWidget = textNode.locator('.WidgetText') const textWidget = textNode.locator('.WidgetText')
await expect(textWidget).toBeVisible() await expect(textWidget).toBeVisible()
await expect(textWidget.locator('.value')).toHaveValue("'test'") await expect(textWidget.locator('input')).toHaveValue('test')
}) })
test('Selection widgets in Data.read node', async ({ page }) => { test('Selection widgets in Data.read node', async ({ page }) => {
@ -71,7 +72,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
await onProblemsArg.click() await onProblemsArg.click()
await dropDown.expectVisibleWithOptions(page, ['Ignore', 'Report_Warning', 'Report_Error']) await dropDown.expectVisibleWithOptions(page, ['Ignore', 'Report_Warning', 'Report_Error'])
await dropDown.clickOption(page, 'Report_Error') await dropDown.clickOption(page, 'Report_Error')
await expect(onProblemsArg.locator('.WidgetToken')).toHaveText([ await expect(onProblemsArg.locator('.WidgetToken')).toContainText([
'Problem_Behavior', 'Problem_Behavior',
'.', '.',
'Report_Error', 'Report_Error',
@ -89,7 +90,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
await page.getByText('Report_Error').click() await page.getByText('Report_Error').click()
await dropDown.expectVisibleWithOptions(page, ['Ignore', 'Report_Warning', 'Report_Error']) await dropDown.expectVisibleWithOptions(page, ['Ignore', 'Report_Warning', 'Report_Error'])
await dropDown.clickOption(page, 'Report_Warning') await dropDown.clickOption(page, 'Report_Warning')
await expect(onProblemsArg.locator('.WidgetToken')).toHaveText([ await expect(onProblemsArg.locator('.WidgetToken')).toContainText([
'Problem_Behavior', 'Problem_Behavior',
'.', '.',
'Report_Warning', 'Report_Warning',
@ -101,7 +102,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
await expect(page.locator('.dropdownContainer')).toBeVisible() await expect(page.locator('.dropdownContainer')).toBeVisible()
await dropDown.expectVisibleWithOptions(page, ['"File 1"', '"File 2"']) await dropDown.expectVisibleWithOptions(page, ['"File 1"', '"File 2"'])
await dropDown.clickOption(page, '"File 2"') await dropDown.clickOption(page, '"File 2"')
await expect(pathArg.locator('.EnsoTextInputWidget > input')).toHaveValue('"File 2"') await expect(pathArg.locator('.WidgetText > input')).toHaveValue('File 2')
// Change value on `path` (dynamic config) // Change value on `path` (dynamic config)
await mockMethodCallInfo(page, 'data', { await mockMethodCallInfo(page, 'data', {
@ -115,7 +116,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
await page.getByText('path').click() await page.getByText('path').click()
await dropDown.expectVisibleWithOptions(page, ['"File 1"', '"File 2"']) await dropDown.expectVisibleWithOptions(page, ['"File 1"', '"File 2"'])
await dropDown.clickOption(page, '"File 1"') await dropDown.clickOption(page, '"File 1"')
await expect(pathArg.locator('.EnsoTextInputWidget > input')).toHaveValue('"File 1"') await expect(pathArg.locator('.WidgetText > input')).toHaveValue('File 1')
}) })
test('Managing aggregates in `aggregate` node', async ({ page }) => { test('Managing aggregates in `aggregate` node', async ({ page }) => {
@ -142,7 +143,11 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
// Add first aggregate // Add first aggregate
const columnsArg = argumentNames.filter({ has: page.getByText('columns') }) const columnsArg = argumentNames.filter({ has: page.getByText('columns') })
await columnsArg.locator('.add-item').click() await columnsArg.locator('.add-item').click()
await expect(columnsArg.locator('.WidgetToken')).toHaveText(['Aggregate_Column', '.', 'Group_By']) await expect(columnsArg.locator('.WidgetToken')).toContainText([
'Aggregate_Column',
'.',
'Group_By',
])
await mockMethodCallInfo( await mockMethodCallInfo(
page, page,
{ {
@ -164,7 +169,7 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
await firstItem.click() await firstItem.click()
await dropDown.expectVisibleWithOptions(page, ['Group_By', 'Count', 'Count_Distinct']) await dropDown.expectVisibleWithOptions(page, ['Group_By', 'Count', 'Count_Distinct'])
await dropDown.clickOption(page, 'Count_Distinct') await dropDown.clickOption(page, 'Count_Distinct')
await expect(columnsArg.locator('.WidgetToken')).toHaveText([ await expect(columnsArg.locator('.WidgetToken')).toContainText([
'Aggregate_Column', 'Aggregate_Column',
'.', '.',
'Count_Distinct', 'Count_Distinct',
@ -190,16 +195,16 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
await columnArg.click() await columnArg.click()
await dropDown.expectVisibleWithOptions(page, ['"column 1"', '"column 2"']) await dropDown.expectVisibleWithOptions(page, ['"column 1"', '"column 2"'])
await dropDown.clickOption(page, '"column 1"') await dropDown.clickOption(page, '"column 1"')
await expect(columnsArg.locator('.WidgetToken')).toHaveText([ await expect(columnsArg.locator('.WidgetToken')).toContainText([
'Aggregate_Column', 'Aggregate_Column',
'.', '.',
'Count_Distinct', 'Count_Distinct',
]) ])
await expect(columnsArg.locator('.EnsoTextInputWidget > input').first()).toHaveValue('"column 1"') await expect(columnsArg.locator('.WidgetText > input').first()).toHaveValue('column 1')
// Add another aggregate // Add another aggregate
await columnsArg.locator('.add-item').click() await columnsArg.locator('.add-item').click()
await expect(columnsArg.locator('.WidgetToken')).toHaveText([ await expect(columnsArg.locator('.WidgetToken')).toContainText([
'Aggregate_Column', 'Aggregate_Column',
'.', '.',
'Count_Distinct', 'Count_Distinct',
@ -229,8 +234,12 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
await secondColumnArg.click() await secondColumnArg.click()
await dropDown.expectVisibleWithOptions(page, ['"column 1"', '"column 2"']) await dropDown.expectVisibleWithOptions(page, ['"column 1"', '"column 2"'])
await dropDown.clickOption(page, '"column 2"') await dropDown.clickOption(page, '"column 2"')
await expect(secondItem.locator('.WidgetToken')).toHaveText(['Aggregate_Column', '.', 'Group_By']) await expect(secondItem.locator('.WidgetToken')).toContainText([
await expect(secondItem.locator('.EnsoTextInputWidget > input').first()).toHaveValue('"column 2"') 'Aggregate_Column',
'.',
'Group_By',
])
await expect(secondItem.locator('.WidgetText > input').first()).toHaveValue('column 2')
// Switch aggregates // Switch aggregates
//TODO[ao] I have no idea how to emulate drag. Simple dragTo does not work (some element seem to capture event). //TODO[ao] I have no idea how to emulate drag. Simple dragTo does not work (some element seem to capture event).
@ -244,7 +253,7 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
// await columnsArg.locator('.item > .handle').nth(0).hover({ force: true }) // await columnsArg.locator('.item > .handle').nth(0).hover({ force: true })
// await columnsArg.locator('.item > .handle').nth(0).hover() // await columnsArg.locator('.item > .handle').nth(0).hover()
// await page.mouse.up() // await page.mouse.up()
// await expect(columnsArg.locator('.WidgetToken')).toHaveText([ // await expect(columnsArg.locator('.WidgetToken')).toContainText([
// 'Aggregate_Column', // 'Aggregate_Column',
// '.', // '.',
// 'Group_By', // 'Group_By',

View File

@ -15,6 +15,7 @@ const conf = [
'dist', 'dist',
'shared/ast/generated', 'shared/ast/generated',
'templates', 'templates',
'.histoire',
'playwright-report', 'playwright-report',
], ],
}, },

View File

@ -43,13 +43,14 @@ export default defineConfig({
order(a, b) { order(a, b) {
const aIndex = order.indexOf(a) const aIndex = order.indexOf(a)
const bIndex = order.indexOf(b) const bIndex = order.indexOf(b)
return aIndex != null return (
? bIndex != null aIndex != null ?
? aIndex - bIndex bIndex != null ?
aIndex - bIndex
: -1 : -1
: bIndex != null : bIndex != null ? 1
? 1
: a.localeCompare(b) : a.localeCompare(b)
)
}, },
}, },
vite: { vite: {

View File

@ -323,15 +323,15 @@ function createId(id: Uuid) {
function sendVizData(id: Uuid, config: VisualizationConfiguration) { function sendVizData(id: Uuid, config: VisualizationConfiguration) {
const vizDataHandler = const vizDataHandler =
mockVizData[ mockVizData[
typeof config.expression === 'string' typeof config.expression === 'string' ?
? `${config.visualizationModule}.${config.expression}` `${config.visualizationModule}.${config.expression}`
: `${config.expression.definedOnType}.${config.expression.name}` : `${config.expression.definedOnType}.${config.expression.name}`
] ]
if (!vizDataHandler || !sendData) return if (!vizDataHandler || !sendData) return
const vizData = const vizData =
vizDataHandler instanceof Uint8Array vizDataHandler instanceof Uint8Array ? vizDataHandler : (
? vizDataHandler vizDataHandler(config.positionalArgumentsExpressions ?? [])
: vizDataHandler(config.positionalArgumentsExpressions ?? []) )
const builder = new Builder() const builder = new Builder()
const exprId = visualizationExprIds.get(id) const exprId = visualizationExprIds.get(id)
const visualizationContextOffset = VisualizationContext.createVisualizationContext( const visualizationContextOffset = VisualizationContext.createVisualizationContext(

View File

@ -62,13 +62,13 @@
"lib0": "^0.2.85", "lib0": "^0.2.85",
"magic-string": "^0.30.3", "magic-string": "^0.30.3",
"murmurhash": "^2.0.1", "murmurhash": "^2.0.1",
"pinia": "^2.1.6", "pinia": "^2.1.7",
"postcss-inline-svg": "^6.0.0", "postcss-inline-svg": "^6.0.0",
"postcss-nesting": "^12.0.1", "postcss-nesting": "^12.0.1",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"semver": "^7.5.4", "semver": "^7.5.4",
"sucrase": "^3.34.0", "sucrase": "^3.34.0",
"vue": "^3.3.4", "vue": "^3.4.19",
"ws": "^8.13.0", "ws": "^8.13.0",
"y-codemirror.next": "^0.3.2", "y-codemirror.next": "^0.3.2",
"y-protocols": "^1.0.5", "y-protocols": "^1.0.5",
@ -79,9 +79,9 @@
}, },
"devDependencies": { "devDependencies": {
"@danmarshall/deckgl-typings": "^4.9.28", "@danmarshall/deckgl-typings": "^4.9.28",
"@eslint/eslintrc": "^2.1.2", "@eslint/eslintrc": "^3.0.2",
"@eslint/js": "^8.49.0", "@eslint/js": "^8.57.0",
"@histoire/plugin-vue": "^0.17.1", "@histoire/plugin-vue": "^0.17.12",
"@open-rpc/server-js": "^1.9.4", "@open-rpc/server-js": "^1.9.4",
"@playwright/test": "^1.40.0", "@playwright/test": "^1.40.0",
"@rushstack/eslint-patch": "^1.3.2", "@rushstack/eslint-patch": "^1.3.2",
@ -92,45 +92,45 @@
"@types/hash-sum": "^1.0.0", "@types/hash-sum": "^1.0.0",
"@types/jsdom": "^21.1.1", "@types/jsdom": "^21.1.1",
"@types/mapbox-gl": "^2.7.13", "@types/mapbox-gl": "^2.7.13",
"@types/node": "^18.17.5", "@types/node": "^20.11.21",
"@types/shuffle-seed": "^1.1.0", "@types/shuffle-seed": "^1.1.0",
"@types/unbzip2-stream": "^1.4.3", "@types/unbzip2-stream": "^1.4.3",
"@types/wicg-file-system-access": "^2023.10.2", "@types/wicg-file-system-access": "^2023.10.2",
"@types/ws": "^8.5.5", "@types/ws": "^8.5.5",
"@vitejs/plugin-react": "^4.0.4", "@vitejs/plugin-react": "^4.0.4",
"@vitejs/plugin-vue": "^4.3.1", "@vitejs/plugin-vue": "^5.0.4",
"@vitest/coverage-v8": "^0.34.6", "@vitest/coverage-v8": "^1.3.1",
"@volar/vue-typescript": "^1.6.5", "@volar/vue-typescript": "^1.6.5",
"@vue/eslint-config-prettier": "^8.0.0", "@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0", "@vue/eslint-config-typescript": "^12.0.0",
"@vue/test-utils": "^2.4.1", "@vue/test-utils": "^2.4.4",
"@vue/tsconfig": "^0.4.0", "@vue/tsconfig": "^0.5.1",
"change-case": "^4.1.2", "change-case": "^4.1.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"css.escape": "^1.5.1", "css.escape": "^1.5.1",
"d3": "^7.4.0", "d3": "^7.4.0",
"esbuild": "^0.19.3", "esbuild": "^0.19.3",
"eslint": "^8.49.0", "eslint": "^8.49.0",
"eslint-plugin-vue": "^9.16.1", "eslint-plugin-vue": "^9.22.0",
"floating-vue": "^2.0.0-beta.24", "floating-vue": "^2.0.0-beta.24",
"hash-wasm": "^4.10.0", "hash-wasm": "^4.10.0",
"histoire": "^0.17.2", "histoire": "^0.17.2",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"playwright": "^1.39.0", "playwright": "^1.39.0",
"postcss-nesting": "^12.0.1", "postcss-nesting": "^12.0.1",
"prettier": "^3.0.0", "prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.3", "prettier-plugin-organize-imports": "^3.2.4",
"shuffle-seed": "^1.1.6", "shuffle-seed": "^1.1.6",
"sql-formatter": "^13.0.0", "sql-formatter": "^13.0.0",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"tar": "^6.2.0", "tar": "^6.2.0",
"tsx": "^3.12.6", "tsx": "^4.7.1",
"typescript": "~5.2.2", "typescript": "~5.2.2",
"unbzip2-stream": "^1.4.3", "unbzip2-stream": "^1.4.3",
"vite": "^4.4.9", "vite": "^4.4.9",
"vite-plugin-inspect": "^0.7.38", "vite-plugin-inspect": "^0.7.38",
"vitest": "^0.34.2", "vitest": "^1.3.1",
"vue-react-wrapper": "^0.3.1", "vue-react-wrapper": "^0.3.1",
"vue-tsc": "^1.8.8" "vue-tsc": "^1.8.27"
} }
} }

View File

@ -49,31 +49,31 @@ export default defineConfig({
headless: !DEBUG, headless: !DEBUG,
trace: 'retain-on-failure', trace: 'retain-on-failure',
viewport: { width: 1920, height: 1600 }, viewport: { width: 1920, height: 1600 },
...(DEBUG ...(DEBUG ?
? {} {}
: { : {
launchOptions: { launchOptions: {
ignoreDefaultArgs: ['--headless'], ignoreDefaultArgs: ['--headless'],
args: [ args: [
// Much closer to headful Chromium than classic headless. // Much closer to headful Chromium than classic headless.
'--headless=new', '--headless=new',
// Required for `backdrop-filter: blur` to work. // Required for `backdrop-filter: blur` to work.
'--use-angle=swiftshader', '--use-angle=swiftshader',
// FIXME: `--disable-gpu` disables `backdrop-filter: blur`, which is not handled by // FIXME: `--disable-gpu` disables `backdrop-filter: blur`, which is not handled by
// the software (CPU) compositor. This SHOULD be fixed eventually, but this flag // the software (CPU) compositor. This SHOULD be fixed eventually, but this flag
// MUST stay as CI does not have a GPU. // MUST stay as CI does not have a GPU.
'--disable-gpu', '--disable-gpu',
// Fully disable GPU process. // Fully disable GPU process.
'--disable-software-rasterizer', '--disable-software-rasterizer',
// Disable text subpixel antialiasing. // Disable text subpixel antialiasing.
'--font-render-hinting=none', '--font-render-hinting=none',
'--disable-skia-runtime-opts', '--disable-skia-runtime-opts',
'--disable-system-font-check', '--disable-system-font-check',
'--disable-font-subpixel-positioning', '--disable-font-subpixel-positioning',
'--disable-lcd-text', '--disable-lcd-text',
], ],
}, },
}), }),
}, },
// projects: [ // projects: [
// { // {
@ -112,9 +112,9 @@ export default defineConfig({
E2E: 'true', E2E: 'true',
}, },
command: command:
process.env.CI || process.env.PROD process.env.CI || process.env.PROD ?
? `npx vite build && npx vite preview --port ${PORT} --strictPort` `npx vite build && npx vite preview --port ${PORT} --strictPort`
: `npx vite dev --port ${PORT}`, : `npx vite dev --port ${PORT}`,
// Build from scratch apparently can take a while on CI machines. // Build from scratch apparently can take a while on CI machines.
timeout: 120 * 1000, timeout: 120 * 1000,
port: PORT, port: PORT,

View File

@ -40,9 +40,9 @@ function get(options, callback) {
const location = response.headers.location const location = response.headers.location
if (location) { if (location) {
get( get(
typeof options === 'string' || options instanceof URL typeof options === 'string' || options instanceof URL ?
? location location
: { ...options, ...new URL(location) }, : { ...options, ...new URL(location) },
callback, callback,
) )
} else { } else {
@ -53,11 +53,13 @@ function get(options, callback) {
/** @param {unknown} error */ /** @param {unknown} error */
function errorCode(error) { function errorCode(error) {
return typeof error === 'object' && return (
error != null && typeof error === 'object' &&
'code' in error && error != null &&
typeof error.code === 'string' 'code' in error &&
? error.code typeof error.code === 'string'
) ?
error.code
: undefined : undefined
} }

View File

@ -7,6 +7,7 @@ import { App, Ast, Group, MutableAst, OprApp, Wildcard } from './tree'
export * from './mutableModule' export * from './mutableModule'
export * from './parse' export * from './parse'
export * from './text'
export * from './token' export * from './token'
export * from './tree' export * from './tree'

View File

@ -204,8 +204,9 @@ class Abstractor {
} }
case RawAst.Tree.Type.OprApp: { case RawAst.Tree.Type.OprApp: {
const lhs = tree.lhs ? this.abstractTree(tree.lhs) : undefined const lhs = tree.lhs ? this.abstractTree(tree.lhs) : undefined
const opr = tree.opr.ok const opr =
? [this.abstractToken(tree.opr.value)] tree.opr.ok ?
[this.abstractToken(tree.opr.value)]
: Array.from(tree.opr.error.payload.operators, this.abstractToken.bind(this)) : Array.from(tree.opr.error.payload.operators, this.abstractToken.bind(this))
const rhs = tree.rhs ? this.abstractTree(tree.rhs) : undefined const rhs = tree.rhs ? this.abstractTree(tree.rhs) : undefined
const soleOpr = tryGetSoleValue(opr) const soleOpr = tryGetSoleValue(opr)
@ -831,9 +832,9 @@ function calculateCorrespondence(
for (const partAfter of partsAfter) { for (const partAfter of partsAfter) {
const astBefore = partAfterToAstBefore.get(sourceRangeKey(partAfter))! const astBefore = partAfterToAstBefore.get(sourceRangeKey(partAfter))!
if (astBefore.typeName() === astAfter.typeName()) { if (astBefore.typeName() === astAfter.typeName()) {
;(rangeLength(newSpans.get(astAfter.id)!) === rangeLength(partAfter) ;(rangeLength(newSpans.get(astAfter.id)!) === rangeLength(partAfter) ?
? toSync toSync
: candidates : candidates
).set(astBefore.id, astAfter) ).set(astBefore.id, astAfter)
break break
} }
@ -932,9 +933,9 @@ function syncTree(
const editAst = edit.getVersion(ast) const editAst = edit.getVersion(ast)
if (syncFieldsFrom) { if (syncFieldsFrom) {
const originalAssignmentExpression = const originalAssignmentExpression =
ast instanceof Assignment ast instanceof Assignment ?
? metadataSource.get(ast.fields.get('expression').node) metadataSource.get(ast.fields.get('expression').node)
: undefined : undefined
syncFields(edit.getVersion(ast), syncFieldsFrom, childReplacerFor(ast.id)) syncFields(edit.getVersion(ast), syncFieldsFrom, childReplacerFor(ast.id))
if (editAst instanceof MutableAssignment && originalAssignmentExpression) { if (editAst instanceof MutableAssignment && originalAssignmentExpression) {
if (editAst.expression.externalId !== originalAssignmentExpression.externalId) if (editAst.expression.externalId !== originalAssignmentExpression.externalId)

View File

@ -56,8 +56,9 @@ export class SourceDocument {
} }
} }
if (printed.code !== this.text_) { if (printed.code !== this.text_) {
const textEdits = update.updateRoots.has(root.id) const textEdits =
? [{ range: [0, this.text_.length] satisfies SourceRange, insert: printed.code }] update.updateRoots.has(root.id) ?
[{ range: [0, this.text_.length] satisfies SourceRange, insert: printed.code }]
: subtreeTextEdits : subtreeTextEdits
this.text_ = printed.code this.text_ = printed.code
this.notifyObservers(textEdits, update.origin) this.notifyObservers(textEdits, update.origin)

View File

@ -0,0 +1,79 @@
/***
* String escaping and interpolation handling. Code in this module must be kept aligned with lexer's
* understanding of string literals. The relevant lexer code can be found in
* `lib/rust/parser/src/lexer.rs`, search for `fn text_escape`.
*/
import { assertUnreachable } from '../util/assert'
const escapeSequences = [
['0', '\0'],
['a', '\x07'],
['b', '\x08'],
['f', '\x0C'],
['n', '\x0A'],
['r', '\x0D'],
['t', '\x09'],
['v', '\x0B'],
['e', '\x1B'],
['\\', '\\'],
['"', '"'],
["'", "'"],
['`', '`'],
] as const
function escapeAsCharCodes(str: string): string {
let out = ''
for (let i = 0; i < str.length; i += 1) out += `\\u{${str?.charCodeAt(i).toString(16)}}`
return out
}
const escapeRegex = new RegExp(
`${escapeSequences.map(([_, raw]) => escapeAsCharCodes(raw)).join('|')}`,
'gu',
)
const unescapeRegex = new RegExp(
'\\\\(?:' +
`${escapeSequences.map(([escape]) => escapeAsCharCodes(escape)).join('|')}` +
'|x[0-9a-fA-F]{0,2}' +
'|u\\{[0-9a-fA-F]{0,4}\\}?' + // Lexer allows trailing } to be missing.
'|u[0-9a-fA-F]{0,4}' +
'|U[0-9a-fA-F]{0,8}' +
')',
'gu',
)
const escapeMapping = Object.fromEntries(
escapeSequences.map(([escape, raw]) => [raw, `\\${escape}`]),
)
const unescapeMapping = Object.fromEntries(
escapeSequences.map(([escape, raw]) => [`\\${escape}`, raw]),
)
/**
* Escape a string so it can be safely spliced into an interpolated (`''`) Enso string.
* Note: Escape sequences are NOT interpreted in raw (`""`) string literals.
* */
export function escapeTextLiteral(rawString: string) {
return rawString.replace(escapeRegex, (match) => escapeMapping[match] ?? assertUnreachable())
}
/**
* Interpret all escaped characters from an interpolated (`''`) Enso string.
* Note: Escape sequences are NOT interpreted in raw (`""`) string literals.
*/
export function unescapeTextLiteral(escapedString: string) {
return escapedString.replace(unescapeRegex, (match) => {
let cut = 2
switch (match[1]) {
case 'u':
if (match[2] === '{') cut = 3 // fallthrough
case 'U':
case 'x':
return String.fromCharCode(parseInt(match.substring(cut), 16))
default:
return unescapeMapping[match] ?? assertUnreachable()
}
})
}

View File

@ -1,5 +1,5 @@
import type { DeepReadonly } from 'vue' import type { DeepReadonly } from 'vue'
import type { AstId, Owned } from '.' import type { AstId, NodeChild, Owned } from '.'
import { Ast, newExternalId } from '.' import { Ast, newExternalId } from '.'
import { assert } from '../util/assert' import { assert } from '../util/assert'
import type { ExternalId } from '../yjsModel' import type { ExternalId } from '../yjsModel'
@ -11,6 +11,10 @@ export function isToken(t: unknown): t is Token {
return t instanceof Token return t instanceof Token
} }
export function isTokenChild(child: NodeChild<unknown>): child is NodeChild<Token> {
return isToken(child.node)
}
declare const brandTokenId: unique symbol declare const brandTokenId: unique symbol
export type TokenId = ExternalId & { [brandTokenId]: never } export type TokenId = ExternalId & { [brandTokenId]: never }

View File

@ -16,8 +16,10 @@ import {
ROOT_ID, ROOT_ID,
Token, Token,
asOwned, asOwned,
escapeTextLiteral,
isIdentifier, isIdentifier,
isToken, isToken,
isTokenChild,
isTokenId, isTokenId,
newExternalId, newExternalId,
parentId, parentId,
@ -1194,24 +1196,6 @@ export interface MutableImport extends Import, MutableAst {
} }
applyMixins(MutableImport, [MutableAst]) applyMixins(MutableImport, [MutableAst])
const mapping: Record<string, string> = {
'\b': '\\b',
'\f': '\\f',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
'\v': '\\v',
'"': '\\"',
"'": "\\'",
'`': '``',
}
/** Escape a string so it can be safely spliced into an interpolated (`''`) Enso string.
* NOT USABLE to insert into raw strings. Does not include quotes. */
function escape(string: string) {
return string.replace(/[\0\b\f\n\r\t\v"'`]/g, (match) => mapping[match]!)
}
interface TreeRefs { interface TreeRefs {
token: any token: any
ast: any ast: any
@ -1247,6 +1231,16 @@ function rawToConcrete(module: Module): RefMap<RawRefs, ConcreteRefs> {
else return { ...child, node: module.get(child.node) } else return { ...child, node: module.get(child.node) }
} }
} }
function concreteToOwned(module: MutableModule): RefMap<ConcreteRefs, OwnedRefs> {
return (child: FieldData<ConcreteRefs>) => {
if (typeof child !== 'object') return
if (!('node' in child)) return
if (isTokenChild(child)) return child
else return { ...child, node: module.copy(child.node) }
}
}
export interface TextToken<T extends TreeRefs = RawRefs> { export interface TextToken<T extends TreeRefs = RawRefs> {
type: 'token' type: 'token'
readonly token: T['token'] readonly token: T['token']
@ -1335,19 +1329,22 @@ export class TextLiteral extends Ast {
return asOwned(new MutableTextLiteral(module, fields)) return asOwned(new MutableTextLiteral(module, fields))
} }
static new(rawText: string, module: MutableModule): Owned<MutableTextLiteral> { static new(rawText: string, module?: MutableModule): Owned<MutableTextLiteral> {
const escaped = escape(rawText) const escaped = escapeTextLiteral(rawText)
const parsed = parse(`'${escaped}'`, module) const parsed = parse(`'${escaped}'`, module)
if (!(parsed instanceof MutableTextLiteral)) { if (!(parsed instanceof MutableTextLiteral)) {
console.error(`Failed to escape string for interpolated text`, rawText, escaped, parsed) console.error(`Failed to escape string for interpolated text`, rawText, escaped, parsed)
const safeText = rawText.replaceAll(/[^-+A-Za-z0-9_. ]/, '') const safeText = rawText.replaceAll(/[^-+A-Za-z0-9_. ]/g, '')
return this.new(safeText, module) return this.new(safeText, module)
} }
return parsed return parsed
} }
/** Return the value of the string, interpreted except for any interpolated expressions. */ /**
contentUninterpolated(): string { * Return the literal value of the string with all escape sequences applied, but without
* evaluating any interpolated expressions.
*/
get rawTextContent(): string {
return uninterpolatedText(this.fields.get('elements'), this.module) return uninterpolatedText(this.fields.get('elements'), this.module)
} }
@ -1358,10 +1355,63 @@ export class TextLiteral extends Ast {
for (const e of elements) yield* fieldConcreteChildren(e) for (const e of elements) yield* fieldConcreteChildren(e)
if (close) yield close if (close) yield close
} }
boundaryTokenCode(): string | undefined {
return (this.open || this.close)?.code()
}
isInterpolated(): boolean {
const token = this.boundaryTokenCode()
return token === "'" || token === "'''"
}
get open(): Token | undefined {
return this.module.getToken(this.fields.get('open')?.node)
}
get close(): Token | undefined {
return this.module.getToken(this.fields.get('close')?.node)
}
get elements(): TextElement<ConcreteRefs>[] {
return this.fields.get('elements').map((e) => mapRefs(e, rawToConcrete(this.module)))
}
} }
export class MutableTextLiteral extends TextLiteral implements MutableAst { export class MutableTextLiteral extends TextLiteral implements MutableAst {
declare readonly module: MutableModule declare readonly module: MutableModule
declare readonly fields: FixedMap<AstFields & TextLiteralFields> declare readonly fields: FixedMap<AstFields & TextLiteralFields>
setBoundaries(code: string) {
this.fields.set('open', unspaced(Token.new(code)))
this.fields.set('close', unspaced(Token.new(code)))
}
setElements(elements: TextElement<OwnedRefs>[]) {
this.fields.set(
'elements',
elements.map((e) => mapRefs(e, ownedToRaw(this.module, this.id))),
)
}
/**
* Set literal value of the string. The code representation of assigned text will be automatically
* transformed to use escape sequences when necessary.
*/
setRawTextContent(rawText: string) {
let boundary = this.boundaryTokenCode()
const isInterpolated = this.isInterpolated()
const mustBecomeInterpolated = !isInterpolated && (!boundary || rawText.includes(boundary))
if (mustBecomeInterpolated) {
boundary = "'"
this.setBoundaries(boundary)
}
const literalContents =
isInterpolated || mustBecomeInterpolated ? escapeTextLiteral(rawText) : rawText
const parsed = parse(`${boundary}${literalContents}${boundary}`)
assert(parsed instanceof TextLiteral)
const elements = parsed.elements.map((e) => mapRefs(e, concreteToOwned(this.module)))
this.setElements(elements)
}
} }
export interface MutableTextLiteral extends TextLiteral, MutableAst {} export interface MutableTextLiteral extends TextLiteral, MutableAst {}
applyMixins(MutableTextLiteral, [MutableAst]) applyMixins(MutableTextLiteral, [MutableAst])
@ -1953,8 +2003,9 @@ function lineFromRaw(raw: RawBlockLine, module: Module): BlockLine {
const expression = raw.expression ? module.get(raw.expression.node) : undefined const expression = raw.expression ? module.get(raw.expression.node) : undefined
return { return {
newline: { ...raw.newline, node: module.getToken(raw.newline.node) }, newline: { ...raw.newline, node: module.getToken(raw.newline.node) },
expression: expression expression:
? { expression ?
{
whitespace: raw.expression?.whitespace, whitespace: raw.expression?.whitespace,
node: expression, node: expression,
} }
@ -1966,8 +2017,9 @@ function ownedLineFromRaw(raw: RawBlockLine, module: MutableModule): OwnedBlockL
const expression = raw.expression ? module.get(raw.expression.node).takeIfParented() : undefined const expression = raw.expression ? module.get(raw.expression.node).takeIfParented() : undefined
return { return {
newline: { ...raw.newline, node: module.getToken(raw.newline.node) }, newline: { ...raw.newline, node: module.getToken(raw.newline.node) },
expression: expression expression:
? { expression ?
{
whitespace: raw.expression?.whitespace, whitespace: raw.expression?.whitespace,
node: expression, node: expression,
} }
@ -1978,8 +2030,9 @@ function ownedLineFromRaw(raw: RawBlockLine, module: MutableModule): OwnedBlockL
function lineToRaw(line: OwnedBlockLine, module: MutableModule, block: AstId): RawBlockLine { function lineToRaw(line: OwnedBlockLine, module: MutableModule, block: AstId): RawBlockLine {
return { return {
newline: line.newline ?? unspaced(Token.new('\n', RawAst.Token.Type.Newline)), newline: line.newline ?? unspaced(Token.new('\n', RawAst.Token.Type.Newline)),
expression: line.expression expression:
? { line.expression ?
{
whitespace: line.expression?.whitespace, whitespace: line.expression?.whitespace,
node: claimChild(module, line.expression.node, block), node: claimChild(module, line.expression.node, block),
} }
@ -2084,40 +2137,24 @@ export class MutableWildcard extends Wildcard implements MutableAst {
export interface MutableWildcard extends Wildcard, MutableAst {} export interface MutableWildcard extends Wildcard, MutableAst {}
applyMixins(MutableWildcard, [MutableAst]) applyMixins(MutableWildcard, [MutableAst])
export type Mutable<T extends Ast = Ast> = T extends App export type Mutable<T extends Ast = Ast> =
? MutableApp T extends App ? MutableApp
: T extends Assignment : T extends Assignment ? MutableAssignment
? MutableAssignment : T extends BodyBlock ? MutableBodyBlock
: T extends BodyBlock : T extends Documented ? MutableDocumented
? MutableBodyBlock : T extends Function ? MutableFunction
: T extends Documented : T extends Generic ? MutableGeneric
? MutableDocumented : T extends Group ? MutableGroup
: T extends Function : T extends Ident ? MutableIdent
? MutableFunction : T extends Import ? MutableImport
: T extends Generic : T extends Invalid ? MutableInvalid
? MutableGeneric : T extends NegationApp ? MutableNegationApp
: T extends Group : T extends NumericLiteral ? MutableNumericLiteral
? MutableGroup : T extends OprApp ? MutableOprApp
: T extends Ident : T extends PropertyAccess ? MutablePropertyAccess
? MutableIdent : T extends TextLiteral ? MutableTextLiteral
: T extends Import : T extends UnaryOprApp ? MutableUnaryOprApp
? MutableImport : T extends Wildcard ? MutableWildcard
: T extends Invalid
? MutableInvalid
: T extends NegationApp
? MutableNegationApp
: T extends NumericLiteral
? MutableNumericLiteral
: T extends OprApp
? MutableOprApp
: T extends PropertyAccess
? MutablePropertyAccess
: T extends TextLiteral
? MutableTextLiteral
: T extends UnaryOprApp
? MutableUnaryOprApp
: T extends Wildcard
? MutableWildcard
: MutableAst : MutableAst
export function materializeMutable(module: MutableModule, fields: FixedMap<AstFields>): MutableAst { export function materializeMutable(module: MutableModule, fields: FixedMap<AstFields>): MutableAst {
@ -2296,15 +2333,27 @@ function concreteChild(
} }
type StrictIdentLike = Identifier | IdentifierToken type StrictIdentLike = Identifier | IdentifierToken
function toIdentStrict(ident: StrictIdentLike): IdentifierToken { function toIdentStrict(ident: StrictIdentLike): IdentifierToken
return isToken(ident) ? ident : (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierToken) function toIdentStrict(ident: StrictIdentLike | undefined): IdentifierToken | undefined
function toIdentStrict(ident: StrictIdentLike | undefined): IdentifierToken | undefined {
return (
ident ?
isToken(ident) ? ident
: (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierToken)
: undefined
)
} }
type IdentLike = IdentifierOrOperatorIdentifier | IdentifierOrOperatorIdentifierToken type IdentLike = IdentifierOrOperatorIdentifier | IdentifierOrOperatorIdentifierToken
function toIdent(ident: IdentLike): IdentifierOrOperatorIdentifierToken { function toIdent(ident: IdentLike): IdentifierOrOperatorIdentifierToken
return isToken(ident) function toIdent(ident: IdentLike | undefined): IdentifierOrOperatorIdentifierToken | undefined
? ident function toIdent(ident: IdentLike | undefined): IdentifierOrOperatorIdentifierToken | undefined {
: (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierOrOperatorIdentifierToken) return (
ident ?
isToken(ident) ? ident
: (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierOrOperatorIdentifierToken)
: undefined
)
} }
function makeEquals(): Token { function makeEquals(): Token {

View File

@ -668,10 +668,9 @@ export class ByteBuffer {
offset<T extends OffsetConstraint>(bbPos: number, vtableOffset: number): Offset<T> { offset<T extends OffsetConstraint>(bbPos: number, vtableOffset: number): Offset<T> {
const vtable = bbPos - this.view.getInt32(bbPos, true) const vtable = bbPos - this.view.getInt32(bbPos, true)
return ( return (
vtableOffset < this.view.getInt16(vtable, true) vtableOffset < this.view.getInt16(vtable, true) ?
? this.view.getInt16(vtable + vtableOffset, true) this.view.getInt16(vtable + vtableOffset, true)
: 0 : 0) as Offset<T>
) as Offset<T>
} }
union(t: Table, offset: number): Table { union(t: Table, offset: number): Table {
@ -1311,8 +1310,8 @@ export class VisualizationUpdate implements Table {
visualizationContext(obj?: VisualizationContext): VisualizationContext | null { visualizationContext(obj?: VisualizationContext): VisualizationContext | null {
const offset = this.bb.offset(this.bbPos, 4) const offset = this.bb.offset(this.bbPos, 4)
return offset return offset ?
? (obj ?? new VisualizationContext()).init(this.bb.indirect(this.bbPos + offset), this.bb!) (obj ?? new VisualizationContext()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null : null
} }
@ -1328,8 +1327,8 @@ export class VisualizationUpdate implements Table {
dataArray(): Uint8Array | null { dataArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 6) const offset = this.bb.offset(this.bbPos, 6)
return offset return offset ?
? new Uint8Array( new Uint8Array(
this.bb.view.buffer, this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset), this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
this.bb.vectorLength(this.bbPos + offset), this.bb.vectorLength(this.bbPos + offset),
@ -1414,8 +1413,8 @@ export class Path implements Table {
rawSegments(index: number): ArrayBuffer { rawSegments(index: number): ArrayBuffer {
const offset = this.bb.offset(this.bbPos, 6) const offset = this.bb.offset(this.bbPos, 6)
return offset return offset ?
? this.bb.rawMessage(this.bb.vector(this.bbPos + offset) + index * 4) this.bb.rawMessage(this.bb.vector(this.bbPos + offset) + index * 4)
: new Uint8Array() : new Uint8Array()
} }
@ -1517,8 +1516,8 @@ export class WriteFileCommand implements Table {
contentsArray(): Uint8Array | null { contentsArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 6) const offset = this.bb.offset(this.bbPos, 6)
return offset return offset ?
? new Uint8Array( new Uint8Array(
this.bb.view.buffer, this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset), this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
this.bb.vectorLength(this.bbPos + offset), this.bb.vectorLength(this.bbPos + offset),
@ -1662,8 +1661,8 @@ export class FileContentsReply implements Table {
contentsArray(): Uint8Array | null { contentsArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 4) const offset = this.bb.offset(this.bbPos, 4)
return offset return offset ?
? new Uint8Array( new Uint8Array(
this.bb.view.buffer, this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset), this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
this.bb.vectorLength(this.bbPos + offset), this.bb.vectorLength(this.bbPos + offset),
@ -1761,8 +1760,8 @@ export class WriteBytesCommand implements Table {
bytesArray(): Uint8Array | null { bytesArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 10) const offset = this.bb.offset(this.bbPos, 10)
return offset return offset ?
? new Uint8Array( new Uint8Array(
this.bb.view.buffer, this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset), this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
this.bb.vectorLength(this.bbPos + offset), this.bb.vectorLength(this.bbPos + offset),
@ -1855,8 +1854,8 @@ export class WriteBytesReply implements Table {
checksum(obj?: EnsoDigest): EnsoDigest | null { checksum(obj?: EnsoDigest): EnsoDigest | null {
const offset = this.bb.offset(this.bbPos, 4) const offset = this.bb.offset(this.bbPos, 4)
return offset return offset ?
? (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!) (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null : null
} }
@ -1913,8 +1912,8 @@ export class ReadBytesCommand implements Table {
segment(obj?: FileSegment): FileSegment | null { segment(obj?: FileSegment): FileSegment | null {
const offset = this.bb.offset(this.bbPos, 4) const offset = this.bb.offset(this.bbPos, 4)
return offset return offset ?
? (obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!) (obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null : null
} }
@ -1962,8 +1961,8 @@ export class ReadBytesReply implements Table {
checksum(obj?: EnsoDigest): EnsoDigest | null { checksum(obj?: EnsoDigest): EnsoDigest | null {
const offset = this.bb.offset(this.bbPos, 4) const offset = this.bb.offset(this.bbPos, 4)
return offset return offset ?
? (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!) (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null : null
} }
@ -1979,8 +1978,8 @@ export class ReadBytesReply implements Table {
bytesArray(): Uint8Array | null { bytesArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 6) const offset = this.bb.offset(this.bbPos, 6)
return offset return offset ?
? new Uint8Array( new Uint8Array(
this.bb.view.buffer, this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset), this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
this.bb.vectorLength(this.bbPos + offset), this.bb.vectorLength(this.bbPos + offset),
@ -2064,8 +2063,8 @@ export class ChecksumBytesCommand implements Table {
segment(obj?: FileSegment): FileSegment | null { segment(obj?: FileSegment): FileSegment | null {
const offset = this.bb.offset(this.bbPos, 4) const offset = this.bb.offset(this.bbPos, 4)
return offset return offset ?
? (obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!) (obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null : null
} }
@ -2122,8 +2121,8 @@ export class ChecksumBytesReply implements Table {
checksum(obj?: EnsoDigest): EnsoDigest | null { checksum(obj?: EnsoDigest): EnsoDigest | null {
const offset = this.bb.offset(this.bbPos, 4) const offset = this.bb.offset(this.bbPos, 4)
return offset return offset ?
? (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!) (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null : null
} }
@ -2181,8 +2180,8 @@ export class EnsoDigest implements Table {
bytesArray(): Uint8Array | null { bytesArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 4) const offset = this.bb.offset(this.bbPos, 4)
return offset return offset ?
? new Uint8Array( new Uint8Array(
this.bb.view.buffer, this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset), this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
this.bb.vectorLength(this.bbPos + offset), this.bb.vectorLength(this.bbPos + offset),

View File

@ -24,9 +24,9 @@ export function assertLength<T>(iterable: Iterable<T>, length: number, message?:
const convertedArray = Array.from(iterable) const convertedArray = Array.from(iterable)
const messagePrefix = message ? message + ' ' : '' const messagePrefix = message ? message + ' ' : ''
const elementRepresentation = const elementRepresentation =
convertedArray.length > 5 convertedArray.length > 5 ?
? `${convertedArray.slice(0, 5).join(', ')},...` `${convertedArray.slice(0, 5).join(', ')},...`
: convertedArray.join(', ') : convertedArray.join(', ')
assert( assert(
convertedArray.length === length, convertedArray.length === length,
`${messagePrefix}Expected iterable of length ${length}, got length ${convertedArray.length}. Elements: [${elementRepresentation}]`, `${messagePrefix}Expected iterable of length ${length}, got length ${convertedArray.length}. Elements: [${elementRepresentation}]`,

View File

@ -145,8 +145,9 @@ export class WebsocketClient extends ObservableV2<WebsocketEvents> {
this.lastMessageReceived = 0 this.lastMessageReceived = 0
/** Whether to connect to other peers or not */ /** Whether to connect to other peers or not */
this.shouldConnect = false this.shouldConnect = false
this._checkInterval = this.sendPings this._checkInterval =
? setInterval(() => { this.sendPings ?
setInterval(() => {
if ( if (
this.connected && this.connected &&
messageReconnectTimeout < time.getUnixTime() - this.lastMessageReceived messageReconnectTimeout < time.getUnixTime() - this.lastMessageReceived

View File

@ -8,6 +8,7 @@ import ProjectView from '@/views/ProjectView.vue'
import { isDevMode } from 'shared/util/detect' import { isDevMode } from 'shared/util/detect'
import { computed, onMounted, onUnmounted, toRaw } from 'vue' import { computed, onMounted, onUnmounted, toRaw } from 'vue'
import { useProjectStore } from './stores/project' import { useProjectStore } from './stores/project'
import { registerAutoBlurHandler } from './util/autoBlur'
const props = defineProps<{ const props = defineProps<{
config: ApplicationConfig config: ApplicationConfig
@ -20,6 +21,8 @@ const classSet = provideAppClassSet()
provideGuiConfig(computed((): ApplicationConfigValue => configValue(props.config))) provideGuiConfig(computed((): ApplicationConfigValue => configValue(props.config)))
registerAutoBlurHandler()
// Initialize suggestion db immediately, so it will be ready when user needs it. // Initialize suggestion db immediately, so it will be ready when user needs it.
onMounted(() => { onMounted(() => {
const suggestionDb = useSuggestionDbStore() const suggestionDb = useSuggestionDbStore()

View File

@ -15,6 +15,7 @@
--color-frame-bg: rgb(255 255 255 / 0.3); --color-frame-bg: rgb(255 255 255 / 0.3);
--color-frame-selected-bg: rgb(255 255 255 / 0.7); --color-frame-selected-bg: rgb(255 255 255 / 0.7);
--color-widget: rgb(255 255 255 / 0.12); --color-widget: rgb(255 255 255 / 0.12);
--color-widget-focus: rgb(255 255 255 / 0.25);
--color-widget-selected: rgb(255 255 255 / 0.58); --color-widget-selected: rgb(255 255 255 / 0.58);
--color-port-connected: rgb(255 255 255 / 0.15); --color-port-connected: rgb(255 255 255 / 0.15);

View File

@ -60,9 +60,9 @@ const emit = defineEmits<{
class="icon-container button slot7" class="icon-container button slot7"
:class="{ 'output-context-overridden': props.isOutputContextOverridden }" :class="{ 'output-context-overridden': props.isOutputContextOverridden }"
:alt="`${ :alt="`${
props.isOutputContextEnabledGlobally != props.isOutputContextOverridden props.isOutputContextEnabledGlobally != props.isOutputContextOverridden ?
? 'Disable' 'Disable'
: 'Enable' : 'Enable'
} output context`" } output context`"
:modelValue="props.isOutputContextOverridden" :modelValue="props.isOutputContextOverridden"
@update:modelValue="emit('update:isOutputContextOverridden', $event)" @update:modelValue="emit('update:isOutputContextOverridden', $event)"
@ -78,6 +78,11 @@ const emit = defineEmits<{
top: -36px; top: -36px;
width: 114px; width: 114px;
height: 114px; height: 114px;
pointer-events: none;
> * {
pointer-events: all;
}
&:before { &:before {
content: ''; content: '';
@ -86,6 +91,7 @@ const emit = defineEmits<{
background: var(--color-app-bg); background: var(--color-app-bg);
width: 100%; width: 100%;
height: 100%; height: 100%;
pointer-events: all;
} }
&.partial { &.partial {

View File

@ -227,9 +227,8 @@ function updateListener() {
commitPendingChanges() commitPendingChanges()
currentModule = newModule currentModule = newModule
} else if (transaction.docChanged && currentModule) { } else if (transaction.docChanged && currentModule) {
pendingChanges = pendingChanges pendingChanges =
? pendingChanges.compose(transaction.changes) pendingChanges ? pendingChanges.compose(transaction.changes) : transaction.changes
: transaction.changes
// Defer the update until after pending events have been processed, so that if changes are arriving faster than // Defer the update until after pending events have been processed, so that if changes are arriving faster than
// we would be able to apply them individually we coalesce them to keep up. // we would be able to apply them individually we coalesce them to keep up.
debouncer(commitPendingChanges) debouncer(commitPendingChanges)
@ -282,8 +281,9 @@ function observeSourceChange(textEdits: SourceRangeEdit[], origin: Origin | unde
// too quickly can result in incorrect ranges, but at idle it should correct itself when we receive new diagnostics. // too quickly can result in incorrect ranges, but at idle it should correct itself when we receive new diagnostics.
watch([viewInitialized, () => projectStore.diagnostics], ([ready, diagnostics]) => { watch([viewInitialized, () => projectStore.diagnostics], ([ready, diagnostics]) => {
if (!ready) return if (!ready) return
executionContextDiagnostics.value = graphStore.moduleSource.text executionContextDiagnostics.value =
? lsDiagnosticsToCMDiagnostics(graphStore.moduleSource.text, diagnostics) graphStore.moduleSource.text ?
lsDiagnosticsToCMDiagnostics(graphStore.moduleSource.text, diagnostics)
: [] : []
}) })
@ -403,7 +403,7 @@ const editorStyle = computed(() => {
} }
} }
.CodeEditor :is(.cm-editor) { .CodeEditor :deep(.cm-editor) {
position: relative; position: relative;
color: white; color: white;
width: 100%; width: 100%;
@ -420,11 +420,11 @@ const editorStyle = computed(() => {
transition: outline 0.1s ease-in-out; transition: outline 0.1s ease-in-out;
} }
.CodeEditor :is(.cm-focused) { .CodeEditor :deep(.cm-focused) {
outline: 1px solid rgba(0, 0, 0, 0.5); outline: 1px solid rgba(0, 0, 0, 0.5);
} }
.CodeEditor :is(.cm-tooltip-hover) { .CodeEditor :deep(.cm-tooltip-hover) {
padding: 4px; padding: 4px;
border-radius: 4px; border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.4); border: 1px solid rgba(0, 0, 0, 0.4);
@ -438,7 +438,7 @@ const editorStyle = computed(() => {
} }
} }
.CodeEditor :is(.cm-gutters) { .CodeEditor :deep(.cm-gutters) {
border-radius: 3px 0 0 3px; border-radius: 3px 0 0 3px;
} }
</style> </style>

View File

@ -69,7 +69,9 @@ export function lsDiagnosticsToCMDiagnostics(
continue continue
} }
const severity = const severity =
diagnostic.kind === 'Error' ? 'error' : diagnostic.kind === 'Warning' ? 'warning' : 'info' diagnostic.kind === 'Error' ? 'error'
: diagnostic.kind === 'Warning' ? 'warning'
: 'info'
results.push({ from, to, message: diagnostic.message, severity }) results.push({ from, to, message: diagnostic.message, severity })
} }
return results return results

View File

@ -54,14 +54,9 @@ export function labelOfEntry(
matchedAlias: match.matchedAlias, matchedAlias: match.matchedAlias,
matchedRanges: [ matchedRanges: [
...(match.memberOfRanges ?? match.definedInRanges ?? []).flatMap((range) => ...(match.memberOfRanges ?? match.definedInRanges ?? []).flatMap((range) =>
range.end <= lastSegmentStart range.end <= lastSegmentStart ?
? [] []
: [ : [new Range(Math.max(0, range.start - lastSegmentStart), range.end - lastSegmentStart)],
new Range(
Math.max(0, range.start - lastSegmentStart),
range.end - lastSegmentStart,
),
],
), ),
...(match.nameRanges ?? []).map( ...(match.nameRanges ?? []).map(
(range) => new Range(range.start + nameOffset, range.end + nameOffset), (range) => new Range(range.start + nameOffset, range.end + nameOffset),
@ -69,16 +64,15 @@ export function labelOfEntry(
], ],
} }
} else } else
return match.nameRanges return match.nameRanges ?
? { label: entry.name, matchedAlias: match.matchedAlias, matchedRanges: match.nameRanges } { label: entry.name, matchedAlias: match.matchedAlias, matchedRanges: match.nameRanges }
: { label: entry.name, matchedAlias: match.matchedAlias } : { label: entry.name, matchedAlias: match.matchedAlias }
} }
function formatLabel(labelInfo: ComponentLabelInfo): ComponentLabel { function formatLabel(labelInfo: ComponentLabelInfo): ComponentLabel {
return { return {
label: labelInfo.matchedAlias label:
? `${labelInfo.matchedAlias} (${labelInfo.label})` labelInfo.matchedAlias ? `${labelInfo.matchedAlias} (${labelInfo.label})` : labelInfo.label,
: labelInfo.label,
matchedRanges: labelInfo.matchedRanges, matchedRanges: labelInfo.matchedRanges,
} }
} }

View File

@ -73,7 +73,7 @@ class FilteringWithPattern {
// - The unmatched part up to the next matched letter // - The unmatched part up to the next matched letter
const regex = pattern const regex = pattern
.split('') .split('')
.map((c) => `(${c})`) .map((c) => `(${escapeStringRegexp(c)})`)
.join('([^_]*?[_ ])') .join('([^_]*?[_ ])')
this.initialsMatchRegex = new RegExp('(^|.*?_)' + regex + '(.*)', 'i') this.initialsMatchRegex = new RegExp('(^|.*?_)' + regex + '(.*)', 'i')
} }
@ -307,9 +307,9 @@ export class Filtering {
for (const [, text, separator] of this.fullPattern.matchAll(/(.+?)([._]|$)/g)) { for (const [, text, separator] of this.fullPattern.matchAll(/(.+?)([._]|$)/g)) {
const escaped = escapeStringRegexp(text ?? '') const escaped = escapeStringRegexp(text ?? '')
const segment = const segment =
separator === '_' separator === '_' ?
? `()(${escaped})([^_.]*)(_)` `()(${escaped})([^_.]*)(_)`
: `([^.]*_)?(${escaped})([^.]*)(${separator === '.' ? '\\.' : ''})` : `([^.]*_)?(${escaped})([^.]*)(${separator === '.' ? '\\.' : ''})`
prefix = '(?:' + prefix prefix = '(?:' + prefix
suffix += segment + ')?' suffix += segment + ')?'
} }

View File

@ -366,9 +366,9 @@ export function useComponentBrowserInput(
const ctx = context.value const ctx = context.value
const opr = ctx.type !== 'changeLiteral' && ctx.oprApp != null ? ctx.oprApp.lastOpr() : null const opr = ctx.type !== 'changeLiteral' && ctx.oprApp != null ? ctx.oprApp.lastOpr() : null
const oprAppSpacing = const oprAppSpacing =
ctx.type === 'insert' && opr != null && opr.inner.whitespaceLengthInCodeBuffer > 0 ctx.type === 'insert' && opr != null && opr.inner.whitespaceLengthInCodeBuffer > 0 ?
? ' '.repeat(opr.inner.whitespaceLengthInCodeBuffer) ' '.repeat(opr.inner.whitespaceLengthInCodeBuffer)
: '' : ''
const extendingAccessOprChain = opr != null && opr.repr() === '.' const extendingAccessOprChain = opr != null && opr.repr() === '.'
// Modules are special case, as we want to encourage user to continue writing path. // Modules are special case, as we want to encourage user to continue writing path.
if (entry.kind === SuggestionKind.Module) { if (entry.kind === SuggestionKind.Module) {

View File

@ -55,46 +55,64 @@ const suggestionDb = useSuggestionDbStore()
const interaction = provideInteractionHandler() const interaction = provideInteractionHandler()
/// === UI Messages and Errors === /// === UI Messages and Errors ===
function toastOnce(id: string, ...[content, options]: Parameters<typeof toast>) {
if (toast.isActive(id)) toast.update(id, { ...options, render: content })
else toast(content, { ...options, toastId: id })
}
enum ToastId {
startup = 'startup',
connectionLost = 'connectionLost',
connectionError = 'connectionError',
lspError = 'lspError',
executionFailed = 'executionFailed',
}
function initStartupToast() { function initStartupToast() {
let startupToast = toast.info('Initializing the project. This can take up to one minute.', { toastOnce(ToastId.startup, 'Initializing the project. This can take up to one minute.', {
type: 'info',
autoClose: false, autoClose: false,
}) })
const removeToast = () => toast.dismiss(startupToast) const removeToast = () => toast.dismiss(ToastId.startup)
projectStore.firstExecution.then(removeToast) projectStore.firstExecution.then(removeToast)
onScopeDispose(removeToast) onScopeDispose(removeToast)
} }
function initConnectionLostToast() { function initConnectionLostToast() {
let connectionLostToast = 'connectionLostToast'
document.addEventListener( document.addEventListener(
ProjectManagerEvents.loadingFailed, ProjectManagerEvents.loadingFailed,
() => { () => {
toast.error('Lost connection to Language Server.', { toastOnce(ToastId.connectionLost, 'Lost connection to Language Server.', {
type: 'error',
autoClose: false, autoClose: false,
toastId: connectionLostToast,
}) })
}, },
{ once: true }, { once: true },
) )
onUnmounted(() => { onUnmounted(() => {
toast.dismiss(connectionLostToast) toast.dismiss(ToastId.connectionLost)
}) })
} }
projectStore.lsRpcConnection.then( projectStore.lsRpcConnection.then(
(ls) => { (ls) => {
ls.client.onError((err) => { ls.client.onError((err) => {
toast.error(`Language server error: ${err}`) toastOnce(ToastId.lspError, `Language server error: ${err}`, { type: 'error' })
}) })
}, },
(err) => { (err) => {
toast.error(`Connection to language server failed: ${JSON.stringify(err)}`) toastOnce(
ToastId.connectionError,
`Connection to language server failed: ${JSON.stringify(err)}`,
{ type: 'error' },
)
}, },
) )
projectStore.executionContext.on('executionComplete', () => toast.dismiss(ToastId.executionFailed))
projectStore.executionContext.on('executionFailed', (err) => { projectStore.executionContext.on('executionFailed', (err) => {
toast.error(`Execution Failed: ${JSON.stringify(err)}`, {}) toastOnce(ToastId.executionFailed, `Execution Failed: ${JSON.stringify(err)}`, { type: 'error' })
}) })
onMounted(() => { onMounted(() => {

View File

@ -55,9 +55,8 @@ const targetPos = computed<Vec2 | undefined>(() => {
if (expr != null && targetNode.value != null && targetNodeRect.value != null) { if (expr != null && targetNode.value != null && targetNodeRect.value != null) {
const targetRectRelative = graph.getPortRelativeRect(expr) const targetRectRelative = graph.getPortRelativeRect(expr)
if (targetRectRelative == null) return if (targetRectRelative == null) return
const yAdjustment = targetIsSelfArgument.value const yAdjustment =
? -(selfArgumentArrowHeight + selfArgumentArrowYOffset) targetIsSelfArgument.value ? -(selfArgumentArrowHeight + selfArgumentArrowYOffset) : 0
: 0
return targetNodeRect.value.pos.add(new Vec2(targetRectRelative.center().x, yAdjustment)) return targetNodeRect.value.pos.add(new Vec2(targetRectRelative.center().x, yAdjustment))
} else if (navigator?.sceneMousePos != null) { } else if (navigator?.sceneMousePos != null) {
return navigator.sceneMousePos return navigator.sceneMousePos
@ -389,9 +388,9 @@ const activeStyle = computed(() => {
const distances = mouseLocationOnEdge.value const distances = mouseLocationOnEdge.value
if (distances == null) return {} if (distances == null) return {}
const offset = const offset =
distances.sourceToMouse < distances.mouseToTarget distances.sourceToMouse < distances.mouseToTarget ?
? distances.mouseToTarget distances.mouseToTarget
: -distances.sourceToMouse : -distances.sourceToMouse
return { return {
...baseStyle.value, ...baseStyle.value,
strokeDasharray: distances.sourceToTarget, strokeDasharray: distances.sourceToTarget,

View File

@ -71,14 +71,13 @@ const outputPortsSet = computed(() => {
return bindings return bindings
}) })
const widthOverridePx = ref<number>()
const nodeId = computed(() => asNodeId(props.node.rootSpan.id)) const nodeId = computed(() => asNodeId(props.node.rootSpan.id))
const externalId = computed(() => props.node.rootSpan.externalId) const externalId = computed(() => props.node.rootSpan.externalId)
const potentialSelfArgumentId = computed(() => props.node.primarySubject) const potentialSelfArgumentId = computed(() => props.node.primarySubject)
const connectedSelfArgumentId = computed(() => const connectedSelfArgumentId = computed(() =>
props.node.primarySubject && graph.isConnectedTarget(props.node.primarySubject) props.node.primarySubject && graph.isConnectedTarget(props.node.primarySubject) ?
? props.node.primarySubject props.node.primarySubject
: undefined, : undefined,
) )
onUnmounted(() => graph.unregisterNodeRect(nodeId.value)) onUnmounted(() => graph.unregisterNodeRect(nodeId.value))
@ -86,7 +85,6 @@ onUnmounted(() => graph.unregisterNodeRect(nodeId.value))
const rootNode = ref<HTMLElement>() const rootNode = ref<HTMLElement>()
const contentNode = ref<HTMLElement>() const contentNode = ref<HTMLElement>()
const nodeSize = useResizeObserver(rootNode) const nodeSize = useResizeObserver(rootNode)
const baseNodeSize = computed(() => new Vec2(contentNode.value?.scrollWidth ?? 0, nodeSize.value.y))
const error = computed(() => { const error = computed(() => {
const externalId = graph.db.idToExternal(nodeId.value) const externalId = graph.db.idToExternal(nodeId.value)
@ -215,11 +213,11 @@ const isOutputContextOverridden = computed({
const module = projectStore.module const module = projectStore.module
if (!module) return if (!module) return
const edit = props.node.rootSpan.module.edit() const edit = props.node.rootSpan.module.edit()
const replacementText = shouldOverride const replacementText =
? [Ast.TextLiteral.new(projectStore.executionMode, edit)] shouldOverride ? [Ast.TextLiteral.new(projectStore.executionMode, edit)] : undefined
: undefined const replacements =
const replacements = projectStore.isOutputContextEnabled projectStore.isOutputContextEnabled ?
? { {
enableOutputContext: undefined, enableOutputContext: undefined,
disableOutputContext: replacementText, disableOutputContext: replacementText,
} }
@ -388,10 +386,7 @@ const documentation = computed<string | undefined>({
class="GraphNode" class="GraphNode"
:style="{ :style="{
transform, transform,
width: minWidth: isVisualizationVisible ? `${visualizationWidth}px` : undefined,
widthOverridePx != null && isVisualizationVisible
? `${Math.max(widthOverridePx, contentNode?.scrollWidth ?? 0)}px`
: undefined,
'--node-group-color': color, '--node-group-color': color,
}" }"
:class="{ :class="{
@ -424,7 +419,7 @@ const documentation = computed<string | undefined>({
/> />
<GraphVisualization <GraphVisualization
v-if="isVisualizationVisible" v-if="isVisualizationVisible"
:nodeSize="baseNodeSize" :nodeSize="nodeSize"
:scale="navigator?.scale ?? 1" :scale="navigator?.scale ?? 1"
:nodePosition="props.node.position" :nodePosition="props.node.position"
:isCircularMenuVisible="menuVisible" :isCircularMenuVisible="menuVisible"
@ -434,10 +429,7 @@ const documentation = computed<string | undefined>({
:typename="expressionInfo?.typename" :typename="expressionInfo?.typename"
:width="visualizationWidth" :width="visualizationWidth"
:isFocused="isOnlyOneSelected" :isFocused="isOnlyOneSelected"
@update:rect=" @update:rect="emit('update:visualizationRect', $event)"
emit('update:visualizationRect', $event),
(widthOverridePx = $event && $event.size.x > baseNodeSize.x ? $event.size.x : undefined)
"
@update:id="emit('update:visualizationId', $event)" @update:id="emit('update:visualizationId', $event)"
@update:visible="emit('update:visualizationVisible', $event)" @update:visible="emit('update:visualizationVisible', $event)"
@update:fullscreen="emit('update:visualizationFullscreen', $event)" @update:fullscreen="emit('update:visualizationFullscreen', $event)"
@ -453,7 +445,7 @@ const documentation = computed<string | undefined>({
</Suspense> </Suspense>
<div <div
ref="contentNode" ref="contentNode"
class="node" class="content"
v-on="dragPointer.events" v-on="dragPointer.events"
@click.stop @click.stop
@pointerdown.stop @pointerdown.stop
@ -600,6 +592,7 @@ const documentation = computed<string | undefined>({
position: absolute; position: absolute;
border-radius: var(--node-border-radius); border-radius: var(--node-border-radius);
transition: box-shadow 0.2s ease-in-out; transition: box-shadow 0.2s ease-in-out;
box-sizing: border-box;
::selection { ::selection {
background-color: rgba(255, 255, 255, 20%); background-color: rgba(255, 255, 255, 20%);
} }
@ -609,7 +602,7 @@ const documentation = computed<string | undefined>({
display: none; display: none;
} }
.node { .content {
font-family: var(--font-code); font-family: var(--font-code);
position: relative; position: relative;
top: 0; top: 0;
@ -617,7 +610,7 @@ const documentation = computed<string | undefined>({
caret-shape: bar; caret-shape: bar;
height: var(--node-height); height: var(--node-height);
border-radius: var(--node-border-radius); border-radius: var(--node-border-radius);
display: inline-flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
white-space: nowrap; white-space: nowrap;

View File

@ -29,7 +29,7 @@ const iconForType: Record<GraphNodeMessageType, Icon | undefined> = {
<template> <template>
<div class="GraphNodeMessage" :class="styleClassForType[props.type]"> <div class="GraphNodeMessage" :class="styleClassForType[props.type]">
<SvgIcon class="icon" :name="icon" /> <SvgIcon v-if="icon" class="icon" :name="icon" />
<div v-text="props.message"></div> <div v-text="props.message"></div>
</div> </div>
</template> </template>

View File

@ -20,7 +20,6 @@ import type { Result } from '@/util/data/result'
import type { URLString } from '@/util/data/urlString' import type { URLString } from '@/util/data/urlString'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import type { Icon } from '@/util/iconName' import type { Icon } from '@/util/iconName'
import { debouncedGetter } from '@/util/reactivity'
import { computedAsync } from '@vueuse/core' import { computedAsync } from '@vueuse/core'
import { isIdentifier } from 'shared/ast' import { isIdentifier } from 'shared/ast'
import { visIdentifierEquals, type VisualizationIdentifier } from 'shared/yjsModel' import { visIdentifierEquals, type VisualizationIdentifier } from 'shared/yjsModel'
@ -91,9 +90,9 @@ const defaultVisualizationForCurrentNodeSource = computed<VisualizationIdentifie
return { return {
name: raw.value.name, name: raw.value.name,
module: module:
raw.value.library == null raw.value.library == null ?
? { kind: 'Builtin' } { kind: 'Builtin' }
: { kind: 'Library', name: raw.value.library.name }, : { kind: 'Library', name: raw.value.library.name },
} }
}, },
) )
@ -225,26 +224,23 @@ watchEffect(async () => {
}) })
const isBelowToolbar = ref(false) const isBelowToolbar = ref(false)
let width = ref<Opt<number>>(props.width) let userSetHeight = ref(150)
let height = ref(150)
// We want to debounce width changes, because they are saved to the metadata.
const debouncedWidth = debouncedGetter(() => width.value, 300)
watch(debouncedWidth, (value) => value != null && emit('update:width', value))
watchEffect(() => const rect = computed(
emit( () =>
'update:rect',
new Rect( new Rect(
props.nodePosition, props.nodePosition,
new Vec2( new Vec2(
width.value ?? props.nodeSize.x, Math.max(props.width ?? 0, props.nodeSize.x),
height.value + (isBelowToolbar.value ? TOP_WITH_TOOLBAR_PX : TOP_WITHOUT_TOOLBAR_PX), userSetHeight.value + (isBelowToolbar.value ? TOP_WITH_TOOLBAR_PX : TOP_WITHOUT_TOOLBAR_PX),
), ),
), ),
),
) )
onUnmounted(() => emit('update:rect', undefined)) watchEffect(() => emit('update:rect', rect.value))
onUnmounted(() => {
emit('update:rect', undefined)
})
const allTypes = computed(() => Array.from(visualizationStore.types(props.typename))) const allTypes = computed(() => Array.from(visualizationStore.types(props.typename)))
@ -262,16 +258,16 @@ provideVisualizationConfig({
return props.scale return props.scale
}, },
get width() { get width() {
return width.value ?? null return rect.value.width
}, },
set width(value) { set width(value) {
width.value = value emit('update:width', value)
}, },
get height() { get height() {
return height.value return userSetHeight.value
}, },
set height(value) { set height(value) {
height.value = value userSetHeight.value = value
}, },
get isBelowToolbar() { get isBelowToolbar() {
return isBelowToolbar.value return isBelowToolbar.value

View File

@ -1,9 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { import type { WidgetModule } from '@/providers/widgetRegistry'
injectWidgetRegistry, import { injectWidgetRegistry, WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
type WidgetInput,
type WidgetUpdate,
} from '@/providers/widgetRegistry'
import { injectWidgetTree } from '@/providers/widgetTree' import { injectWidgetTree } from '@/providers/widgetTree'
import { import {
injectWidgetUsageInfo, injectWidgetUsageInfo,
@ -12,7 +9,7 @@ import {
} from '@/providers/widgetUsageInfo' } from '@/providers/widgetUsageInfo'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import { computed, proxyRefs } from 'vue' import { computed, getCurrentInstance, proxyRefs, shallowRef, watchEffect, withCtx } from 'vue'
const props = defineProps<{ const props = defineProps<{
input: WidgetInput input: WidgetInput
@ -43,16 +40,17 @@ const sameInputParentWidgets = computed(() =>
) )
const nesting = computed(() => (parentUsageInfo?.nesting ?? 0) + (props.nest === true ? 1 : 0)) const nesting = computed(() => (parentUsageInfo?.nesting ?? 0) + (props.nest === true ? 1 : 0))
const selectedWidget = computed(() => { const selectedWidget = shallowRef<WidgetModule<WidgetInput> | undefined>()
return registry.select( const updateSelection = withCtx(() => {
selectedWidget.value = registry.select(
{ {
input: props.input, input: props.input,
nesting: nesting.value, nesting: nesting.value,
}, },
sameInputParentWidgets.value, sameInputParentWidgets.value,
) )
}) }, getCurrentInstance())
watchEffect(() => updateSelection())
const updateHandler = computed(() => { const updateHandler = computed(() => {
const nextHandler = const nextHandler =
parentUsageInfo?.updateHandler ?? (() => console.log('Missing update handler')) parentUsageInfo?.updateHandler ?? (() => console.log('Missing update handler'))

View File

@ -48,7 +48,9 @@ function handleWidgetUpdates(update: WidgetUpdate) {
const { value, origin } = update.portUpdate const { value, origin } = update.portUpdate
if (Ast.isAstId(origin)) { if (Ast.isAstId(origin)) {
const ast = const ast =
value instanceof Ast.Ast ? value : value == null ? Ast.Wildcard.new(edit) : undefined value instanceof Ast.Ast ? value
: value == null ? Ast.Wildcard.new(edit)
: undefined
if (ast) { if (ast) {
edit.replaceValue(origin as Ast.AstId, ast) edit.replaceValue(origin as Ast.AstId, ast)
} else if (typeof value === 'string') { } else if (typeof value === 'string') {
@ -105,7 +107,7 @@ provideWidgetTree(
} }
&:has(.WidgetPort.newToConnect > .r-24:only-child) { &:has(.WidgetPort.newToConnect > .r-24:only-child) {
margin-left: 4px; margin-left: 0px;
} }
} }

View File

@ -171,17 +171,19 @@ export function useDragging() {
} }
updateNodesPosition() { updateNodesPosition() {
for (const [id, dragged] of this.draggedNodes) { graphStore.batchEdits(() => {
const node = graphStore.db.nodeIdToNode.get(id) for (const [id, dragged] of this.draggedNodes) {
if (node == null) continue const node = graphStore.db.nodeIdToNode.get(id)
// If node was moved in other way than current dragging, we want to stop dragging it. if (node == null) continue
if (node.position.distanceSquared(dragged.currentPos) > 1.0) { // If node was moved in other way than current dragging, we want to stop dragging it.
this.draggedNodes.delete(id) if (node.position.distanceSquared(dragged.currentPos) > 1.0) {
} else { this.draggedNodes.delete(id)
dragged.currentPos = dragged.initialPos.add(snappedOffset.value) } else {
graphStore.setNodePosition(id, dragged.currentPos) dragged.currentPos = dragged.initialPos.add(snappedOffset.value)
graphStore.setNodePosition(id, dragged.currentPos)
}
} }
} })
} }
} }

View File

@ -1,9 +1,9 @@
import { Awareness } from '@/stores/awareness' import { Awareness } from '@/stores/awareness'
import * as astText from '@/util/ast/text'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import { Keccak, sha3_224 as SHA3 } from '@noble/hashes/sha3' import { Keccak, sha3_224 as SHA3 } from '@noble/hashes/sha3'
import type { Hash } from '@noble/hashes/utils' import type { Hash } from '@noble/hashes/utils'
import { bytesToHex } from '@noble/hashes/utils' import { bytesToHex } from '@noble/hashes/utils'
import { escapeTextLiteral } from 'shared/ast'
import type { DataServer } from 'shared/dataServer' import type { DataServer } from 'shared/dataServer'
import type { LanguageServer } from 'shared/languageServer' import type { LanguageServer } from 'shared/languageServer'
import { ErrorCode, RemoteRpcError } from 'shared/languageServer' import { ErrorCode, RemoteRpcError } from 'shared/languageServer'
@ -17,10 +17,10 @@ const DATA_DIR_NAME = 'data'
export function uploadedExpression(result: UploadResult) { export function uploadedExpression(result: UploadResult) {
switch (result.source) { switch (result.source) {
case 'Project': { case 'Project': {
return `enso_project.data/'${astText.escape(result.name)}' . read` return `enso_project.data/'${escapeTextLiteral(result.name)}' . read`
} }
case 'FileSystemRoot': { case 'FileSystemRoot': {
return `Data.read '${astText.escape(result.name)}'` return `Data.read '${escapeTextLiteral(result.name)}'`
} }
} }
} }

View File

@ -7,9 +7,7 @@ import { requiredImportsByFQN } from '@/stores/graph/imports'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase' import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { assert } from '@/util/assert' import { assert } from '@/util/assert'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import type { TokenId } from '@/util/ast/abstract'
import { ArgumentInfoKey } from '@/util/callTree' import { ArgumentInfoKey } from '@/util/callTree'
import { asNot } from '@/util/data/types.ts'
import { type Identifier, type QualifiedName } from '@/util/qualifiedName' import { type Identifier, type QualifiedName } from '@/util/qualifiedName'
import { computed } from 'vue' import { computed } from 'vue'
@ -51,7 +49,7 @@ const value = computed({
edit, edit,
portUpdate: { portUpdate: {
value: value ? 'True' : 'False', value: value ? 'True' : 'False',
origin: asNot<TokenId>(props.input.portId), origin: props.input.portId,
}, },
}) })
} }
@ -68,11 +66,9 @@ const argumentName = computed(() => {
<script lang="ts"> <script lang="ts">
function isBoolNode(ast: Ast.Ast) { function isBoolNode(ast: Ast.Ast) {
const candidate = const candidate =
ast instanceof Ast.PropertyAccess && ast.lhs?.code() === 'Boolean' ast instanceof Ast.PropertyAccess && ast.lhs?.code() === 'Boolean' ? ast.rhs
? ast.rhs : ast instanceof Ast.Ident ? ast.token
: ast instanceof Ast.Ident : undefined
? ast.token
: undefined
return candidate && ['True', 'False'].includes(candidate.code()) return candidate && ['True', 'False'].includes(candidate.code())
} }
function setBoolNode(ast: Ast.Mutable, value: Identifier): { requiresImport: boolean } { function setBoolNode(ast: Ast.Mutable, value: Identifier): { requiresImport: boolean } {
@ -90,15 +86,15 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
priority: 500, priority: 500,
score: (props) => { score: (props) => {
if (props.input.value instanceof Ast.Ast && isBoolNode(props.input.value)) return Score.Perfect if (props.input.value instanceof Ast.Ast && isBoolNode(props.input.value)) return Score.Perfect
return props.input.expectedType === 'Standard.Base.Data.Boolean.Boolean' return props.input.expectedType === 'Standard.Base.Data.Boolean.Boolean' ?
? Score.Good Score.Good
: Score.Mismatch : Score.Mismatch
}, },
}) })
</script> </script>
<template> <template>
<div class="CheckboxContainer" :class="{ primary }"> <div class="CheckboxContainer r-24" :class="{ primary }">
<span v-if="argumentName" class="name" v-text="argumentName" /> <span v-if="argumentName" class="name" v-text="argumentName" />
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown and pointerup --> <!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown and pointerup -->
<CheckboxWidget <CheckboxWidget

View File

@ -78,10 +78,12 @@ const application = computed(() => {
widgetCfg: widgetConfiguration.value, widgetCfg: widgetConfiguration.value,
subjectAsSelf: selfArgumentPreapplied.value, subjectAsSelf: selfArgumentPreapplied.value,
notAppliedArguments: notAppliedArguments:
noArgsCall != null && (
(!subjectTypeMatchesMethod.value || noArgsCall.notAppliedArguments.length > 0) noArgsCall != null &&
? noArgsCall.notAppliedArguments (!subjectTypeMatchesMethod.value || noArgsCall.notAppliedArguments.length > 0)
: undefined, ) ?
noArgsCall.notAppliedArguments
: undefined,
}) })
}) })
@ -107,9 +109,9 @@ const selfArgumentExternalId = computed<Opt<ExternalId>>(() => {
const knownArguments = methodCallInfo.value?.suggestion?.arguments const knownArguments = methodCallInfo.value?.suggestion?.arguments
const hasSelfArgument = knownArguments?.[0]?.name === 'self' const hasSelfArgument = knownArguments?.[0]?.name === 'self'
const selfArgument = const selfArgument =
hasSelfArgument && !selfArgumentPreapplied.value hasSelfArgument && !selfArgumentPreapplied.value ?
? analyzed.args.find((a) => a.argName === 'self' || a.argName == null)?.argument analyzed.args.find((a) => a.argName === 'self' || a.argName == null)?.argument
: getAccessOprSubject(analyzed.func) ?? analyzed.args[0]?.argument : getAccessOprSubject(analyzed.func) ?? analyzed.args[0]?.argument
return selfArgument?.externalId return selfArgument?.externalId
} }
@ -191,9 +193,9 @@ function handleArgUpdate(update: WidgetUpdate): boolean {
newArg = Ast.parse(value, edit) newArg = Ast.parse(value, edit)
} }
const name = const name =
argApp.argument.insertAsNamed && isIdentifier(argApp.argument.argInfo.name) argApp.argument.insertAsNamed && isIdentifier(argApp.argument.argInfo.name) ?
? argApp.argument.argInfo.name argApp.argument.argInfo.name
: undefined : undefined
edit edit
.getVersion(argApp.appTree) .getVersion(argApp.appTree)
.updateValue((oldAppTree) => Ast.App.new(edit, oldAppTree, name, newArg)) .updateValue((oldAppTree) => Ast.App.new(edit, oldAppTree, name, newArg))

View File

@ -2,8 +2,6 @@
import NumericInputWidget from '@/components/widgets/NumericInputWidget.vue' import NumericInputWidget from '@/components/widgets/NumericInputWidget.vue'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry' import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import type { TokenId } from '@/util/ast/abstract.ts'
import { asNot } from '@/util/data/types.ts'
import { computed } from 'vue' import { computed } from 'vue'
const props = defineProps(widgetProps(widgetDefinition)) const props = defineProps(widgetProps(widgetDefinition))
@ -14,7 +12,7 @@ const value = computed({
}, },
set(value) { set(value) {
props.onUpdate({ props.onUpdate({
portUpdate: { value: value.toString(), origin: asNot<TokenId>(props.input.portId) }, portUpdate: { value: value.toString(), origin: props.input.portId },
}) })
}, },
}) })

View File

@ -10,16 +10,13 @@ import { injectWidgetTree } from '@/providers/widgetTree'
import { PortViewInstance, useGraphStore } from '@/stores/graph' import { PortViewInstance, useGraphStore } from '@/stores/graph'
import { assert } from '@/util/assert' import { assert } from '@/util/assert'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import type { TokenId } from '@/util/ast/abstract'
import { ArgumentInfoKey } from '@/util/callTree' import { ArgumentInfoKey } from '@/util/callTree'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { asNot } from '@/util/data/types.ts'
import { cachedGetter } from '@/util/reactivity' import { cachedGetter } from '@/util/reactivity'
import { uuidv4 } from 'lib0/random' import { uuidv4 } from 'lib0/random'
import { isUuid } from 'shared/yjsModel' import { isUuid } from 'shared/yjsModel'
import { import {
computed, computed,
markRaw,
nextTick, nextTick,
onUpdated, onUpdated,
proxyRefs, proxyRefs,
@ -66,7 +63,7 @@ const isTarget = computed(
) )
const rootNode = shallowRef<HTMLElement>() const rootNode = shallowRef<HTMLElement>()
const nodeSize = useResizeObserver(rootNode, false) const nodeSize = useResizeObserver(rootNode)
// Compute the scene-space bounding rectangle of the expression's widget. Those bounds are later // Compute the scene-space bounding rectangle of the expression's widget. Those bounds are later
// used for edge positioning. Querying and updating those bounds is relatively expensive, so we only // used for edge positioning. Querying and updating those bounds is relatively expensive, so we only
@ -82,7 +79,7 @@ const randomUuid = uuidv4() as PortId
// effects depending on the port ID value will not be re-triggered unnecessarily. // effects depending on the port ID value will not be re-triggered unnecessarily.
const portId = cachedGetter<PortId>(() => { const portId = cachedGetter<PortId>(() => {
assert(!isUuid(props.input.portId)) assert(!isUuid(props.input.portId))
return asNot<TokenId>(props.input.portId) return props.input.portId
}) })
const innerWidget = computed(() => { const innerWidget = computed(() => {
@ -100,7 +97,7 @@ const randSlice = randomUuid.slice(0, 4)
watchEffect( watchEffect(
(onCleanup) => { (onCleanup) => {
const id = portId.value const id = portId.value
const instance = markRaw(new PortViewInstance(portRect, tree.nodeId, props.onUpdate)) const instance = new PortViewInstance(portRect, tree.nodeId, props.onUpdate)
graph.addPortInstance(id, instance) graph.addPortInstance(id, instance)
onCleanup(() => graph.removePortInstance(id, instance)) onCleanup(() => graph.removePortInstance(id, instance))
}, },
@ -109,7 +106,7 @@ watchEffect(
function updateRect() { function updateRect() {
let domNode = rootNode.value let domNode = rootNode.value
const rootDomNode = domNode?.closest('.node') const rootDomNode = domNode?.closest('.GraphNode')
if (domNode == null || rootDomNode == null) return if (domNode == null || rootDomNode == null) return
const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect()) const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect())

View File

@ -16,11 +16,9 @@ import {
type SuggestionEntryArgument, type SuggestionEntryArgument,
} from '@/stores/suggestionDatabase/entry.ts' } from '@/stores/suggestionDatabase/entry.ts'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import type { TokenId } from '@/util/ast/abstract.ts'
import { targetIsOutside } from '@/util/autoBlur' import { targetIsOutside } from '@/util/autoBlur'
import { ArgumentInfoKey } from '@/util/callTree' import { ArgumentInfoKey } from '@/util/callTree'
import { arrayEquals } from '@/util/data/array' import { arrayEquals } from '@/util/data/array'
import { asNot } from '@/util/data/types.ts'
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName' import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
@ -53,11 +51,9 @@ function tagFromEntry(entry: SuggestionEntry): Tag {
return { return {
label: entry.name, label: entry.name,
expression: expression:
entry.selfType != null entry.selfType != null ? `_.${entry.name}`
? `_.${entry.name}` : entry.memberOf ? `${qnLastSegment(entry.memberOf)}.${entry.name}`
: entry.memberOf : entry.name,
? `${qnLastSegment(entry.memberOf)}.${entry.name}`
: entry.name,
requiredImports: requiredImports(suggestions.entries, entry), requiredImports: requiredImports(suggestions.entries, entry),
} }
} }
@ -98,7 +94,11 @@ const selectedTag = computed(() => {
// To prevent partial prefix matches, we arrange tags in reverse lexicographical order. // To prevent partial prefix matches, we arrange tags in reverse lexicographical order.
const sortedTags = tags.value const sortedTags = tags.value
.map((tag, index) => [removeSurroundingParens(tag.expression), index] as [string, number]) .map((tag, index) => [removeSurroundingParens(tag.expression), index] as [string, number])
.sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0)) .sort(([a], [b]) =>
a < b ? 1
: a > b ? -1
: 0,
)
const [_, index] = sortedTags.find(([expr]) => currentExpression.startsWith(expr)) ?? [] const [_, index] = sortedTags.find(([expr]) => currentExpression.startsWith(expr)) ?? []
return index != null ? tags.value[index] : undefined return index != null ? tags.value[index] : undefined
} }
@ -146,13 +146,7 @@ watch(selectedIndex, (_index) => {
value = conflicts[0]?.fullyQualified value = conflicts[0]?.fullyQualified
} }
} }
props.onUpdate({ props.onUpdate({ edit, portUpdate: { value, origin: props.input.portId } })
edit,
portUpdate: {
value,
origin: asNot<TokenId>(props.input.portId),
},
})
}) })
const isHovered = ref(false) const isHovered = ref(false)

View File

@ -14,9 +14,9 @@ const icon = computed(() => tree.icon)
export const widgetDefinition = defineWidget(WidgetInput.isAst, { export const widgetDefinition = defineWidget(WidgetInput.isAst, {
priority: 1, priority: 1,
score: (props, _db) => score: (props, _db) =>
props.input.value.id === injectWidgetTree().connectedSelfArgumentId props.input.value.id === injectWidgetTree().connectedSelfArgumentId ?
? Score.Perfect Score.Perfect
: Score.Mismatch, : Score.Mismatch,
}) })
</script> </script>

View File

@ -1,23 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import EnsoTextInputWidget from '@/components/widgets/EnsoTextInputWidget.vue' import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import AutoSizedInput from '@/components/widgets/AutoSizedInput.vue'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry' import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { useGraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import type { TokenId } from '@/util/ast/abstract' import { MutableModule } from '@/util/ast/abstract'
import { asNot } from '@/util/data/types'
import { computed } from 'vue' import { computed } from 'vue'
const props = defineProps(widgetProps(widgetDefinition)) const props = defineProps(widgetProps(widgetDefinition))
const value = computed({ const graph = useGraphStore()
const inputTextLiteral = computed((): Ast.TextLiteral | undefined => {
if (props.input.value instanceof Ast.TextLiteral) return props.input.value
const valueStr = WidgetInput.valueRepr(props.input)
const parsed = valueStr != null ? Ast.parse(valueStr) : undefined
if (parsed instanceof Ast.TextLiteral) return parsed
return undefined
})
function makeNewLiteral(value: string) {
return Ast.TextLiteral.new(value, MutableModule.Transient())
}
const emptyTextLiteral = makeNewLiteral('')
const shownLiteral = computed(() => inputTextLiteral.value ?? emptyTextLiteral)
const closeToken = computed(() => shownLiteral.value.close ?? shownLiteral.value.open)
const textContents = computed({
get() { get() {
const valueStr = WidgetInput.valueRepr(props.input) return shownLiteral.value.rawTextContent
return typeof valueStr === 'string' && Ast.parse(valueStr) instanceof Ast.TextLiteral
? valueStr
: ''
}, },
set(value) { set(value) {
props.onUpdate({ if (props.input.value instanceof Ast.TextLiteral) {
portUpdate: { value: value.toString(), origin: asNot<TokenId>(props.input.portId) }, const edit = graph.startEdit()
}) edit.getVersion(props.input.value).setRawTextContent(value)
props.onUpdate({ edit })
} else {
props.onUpdate({
portUpdate: {
value: makeNewLiteral(value).code(),
origin: props.input.portId,
},
})
}
}, },
}) })
</script> </script>
@ -36,13 +61,29 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
</script> </script>
<template> <template>
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown --> <label class="WidgetText r-24" @pointerdown.stop>
<EnsoTextInputWidget v-model="value" class="WidgetText r-24" @pointerdown.stop /> <NodeWidget v-if="shownLiteral.open" :input="WidgetInput.FromAst(shownLiteral.open)" />
<AutoSizedInput v-model.lazy="textContents" />
<NodeWidget v-if="closeToken" :input="WidgetInput.FromAst(closeToken)" />
</label>
</template> </template>
<style scoped> <style scoped>
.WidgetText { .WidgetText {
display: inline-block; display: inline-flex;
vertical-align: middle; vertical-align: middle;
background: var(--color-widget);
border-radius: var(--radius-full);
position: relative;
user-select: none;
border-radius: var(--radius-full);
padding: 0px 4px;
min-width: 24px;
justify-content: center;
&:has(> .AutoSizedInput:focus) {
outline: none;
background: var(--color-widget-focus);
}
} }
</style> </style>

View File

@ -10,9 +10,9 @@ const props = defineProps(widgetProps(widgetDefinition))
export const widgetDefinition = defineWidget(ArgumentInfoKey, { export const widgetDefinition = defineWidget(ArgumentInfoKey, {
priority: -1, priority: -1,
score: (props) => score: (props) =>
props.nesting < 2 && props.input[ArgumentInfoKey].appKind === ApplicationKind.Prefix props.nesting < 2 && props.input[ArgumentInfoKey].appKind === ApplicationKind.Prefix ?
? Score.Perfect Score.Perfect
: Score.Mismatch, : Score.Mismatch,
}) })
</script> </script>

View File

@ -4,16 +4,15 @@ import ListWidget from '@/components/widgets/ListWidget.vue'
import { injectGraphNavigator } from '@/providers/graphNavigator' import { injectGraphNavigator } from '@/providers/graphNavigator'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry' import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import { MutableModule, type TokenId } from '@/util/ast/abstract.ts' import { MutableModule } from '@/util/ast/abstract.ts'
import { asNot } from '@/util/data/types.ts'
import { computed } from 'vue' import { computed } from 'vue'
const props = defineProps(widgetProps(widgetDefinition)) const props = defineProps(widgetProps(widgetDefinition))
const itemConfig = computed(() => const itemConfig = computed(() =>
props.input.dynamicConfig?.kind === 'Vector_Editor' props.input.dynamicConfig?.kind === 'Vector_Editor' ?
? props.input.dynamicConfig.item_editor props.input.dynamicConfig.item_editor
: undefined, : undefined,
) )
const defaultItem = computed(() => { const defaultItem = computed(() => {
@ -35,7 +34,7 @@ const value = computed({
// TODO[ao]: here we re-create AST. It would be better to reuse existing AST nodes. // TODO[ao]: here we re-create AST. It would be better to reuse existing AST nodes.
const newCode = `[${value.map((item) => item.code()).join(', ')}]` const newCode = `[${value.map((item) => item.code()).join(', ')}]`
props.onUpdate({ props.onUpdate({
portUpdate: { value: newCode, origin: asNot<TokenId>(props.input.portId) }, portUpdate: { value: newCode, origin: props.input.portId },
}) })
}, },
}) })
@ -51,8 +50,8 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
else if (props.input.expectedType?.startsWith('Standard.Base.Data.Vector.Vector')) else if (props.input.expectedType?.startsWith('Standard.Base.Data.Vector.Vector'))
return Score.Good return Score.Good
else if (props.input.value instanceof Ast.Ast) { else if (props.input.value instanceof Ast.Ast) {
return props.input.value.children().next().value.code() === '[' return props.input.value.children().next().value.code() === '[' ?
? Score.Perfect Score.Perfect
: Score.Mismatch : Score.Mismatch
} else return Score.Mismatch } else return Score.Mismatch
}, },

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useApproach } from '@/composables/animation' import { useApproach } from '@/composables/animation'
import type { Vec2 } from '@/util/data/vec2' import type { Vec2 } from '@/util/data/vec2'
import { computed, watch, type Ref } from 'vue' import { computed, shallowRef, watch } from 'vue'
const props = defineProps<{ const props = defineProps<{
position: Vec2 position: Vec2
@ -9,7 +9,15 @@ const props = defineProps<{
}>() }>()
const hidden = computed(() => props.anchor == null) const hidden = computed(() => props.anchor == null)
const lastSetAnchor: Ref<Vec2 | undefined> = computed(() => props.anchor ?? lastSetAnchor.value) const lastSetAnchor = shallowRef<Vec2>()
watch(
() => props.anchor,
(anchor) => {
if (anchor !== null && lastSetAnchor.value !== anchor) {
lastSetAnchor.value = anchor
}
},
)
const anchorAnimFactor = useApproach(() => (props.anchor != null ? 1 : 0), 60) const anchorAnimFactor = useApproach(() => (props.anchor != null ? 1 : 0), 60)
watch( watch(

View File

@ -49,7 +49,7 @@ function blur(event: Event) {
const rootNode = ref<HTMLElement>() const rootNode = ref<HTMLElement>()
const contentNode = ref<HTMLElement>() const contentNode = ref<HTMLElement>()
onMounted(() => (config.width = Math.max(config.width ?? config.nodeSize.x, MIN_WIDTH_PX))) onMounted(() => (config.width = MIN_WIDTH_PX))
function hideSelector() { function hideSelector() {
requestAnimationFrame(() => (isSelectorVisible.value = false)) requestAnimationFrame(() => (isSelectorVisible.value = false))
@ -61,7 +61,7 @@ const resizeRight = usePointer((pos, _, type) => {
} }
const width = const width =
(pos.absolute.x - (contentNode.value?.getBoundingClientRect().left ?? 0)) / config.scale (pos.absolute.x - (contentNode.value?.getBoundingClientRect().left ?? 0)) / config.scale
config.width = Math.max(config.nodeSize.x, width, MIN_WIDTH_PX) config.width = Math.max(width, MIN_WIDTH_PX)
}, PointerButtonMask.Main) }, PointerButtonMask.Main)
const resizeBottom = usePointer((pos, _, type) => { const resizeBottom = usePointer((pos, _, type) => {
@ -80,7 +80,7 @@ const resizeBottomRight = usePointer((pos, _, type) => {
if (pos.delta.x !== 0) { if (pos.delta.x !== 0) {
const width = const width =
(pos.absolute.x - (contentNode.value?.getBoundingClientRect().left ?? 0)) / config.scale (pos.absolute.x - (contentNode.value?.getBoundingClientRect().left ?? 0)) / config.scale
config.width = Math.max(config.nodeSize.x, width) config.width = Math.max(0, width)
} }
if (pos.delta.y !== 0) { if (pos.delta.y !== 0) {
const height = const height =
@ -117,12 +117,10 @@ const resizeBottomRight = usePointer((pos, _, type) => {
class="content scrollable" class="content scrollable"
:class="{ overflow: props.overflow }" :class="{ overflow: props.overflow }"
:style="{ :style="{
width: config.fullscreen width:
? undefined config.fullscreen ? undefined : `${Math.max(config.width ?? 0, config.nodeSize.x)}px`,
: `${Math.max(config.width ?? 0, config.nodeSize.x)}px`, height:
height: config.fullscreen config.fullscreen ? undefined : `${Math.max(config.height ?? 0, config.nodeSize.y)}px`,
? undefined
: `${Math.max(config.height ?? 0, config.nodeSize.y)}px`,
}" }"
@wheel.passive="onWheel" @wheel.passive="onWheel"
> >

View File

@ -380,9 +380,9 @@ function pushPoints(newPoints: Location[]) {
) { ) {
let position: [number, number] = [point.longitude, point.latitude] let position: [number, number] = [point.longitude, point.latitude]
let radius = let radius =
typeof point.radius === 'number' && !Number.isNaN(point.radius) typeof point.radius === 'number' && !Number.isNaN(point.radius) ?
? point.radius point.radius
: DEFAULT_POINT_RADIUS : DEFAULT_POINT_RADIUS
let color = point.color ?? ACCENT_COLOR let color = point.color ?? ACCENT_COLOR
let label = point.label ?? '' let label = point.label ?? ''
points.push({ position, color, radius, label }) points.push({ position, color, radius, label })

View File

@ -89,14 +89,16 @@ const fill = computed(() =>
const width = ref(Math.max(config.width ?? 0, config.nodeSize.x)) const width = ref(Math.max(config.width ?? 0, config.nodeSize.x))
watchPostEffect(() => { watchPostEffect(() => {
width.value = config.fullscreen width.value =
? containerNode.value?.parentElement?.clientWidth ?? 0 config.fullscreen ?
containerNode.value?.parentElement?.clientWidth ?? 0
: Math.max(config.width ?? 0, config.nodeSize.x) : Math.max(config.width ?? 0, config.nodeSize.x)
}) })
const height = ref(config.height ?? (config.nodeSize.x * 3) / 4) const height = ref(config.height ?? (config.nodeSize.x * 3) / 4)
watchPostEffect(() => { watchPostEffect(() => {
height.value = config.fullscreen height.value =
? containerNode.value?.parentElement?.clientHeight ?? 0 config.fullscreen ?
containerNode.value?.parentElement?.clientHeight ?? 0
: config.height ?? (config.nodeSize.x * 3) / 4 : config.height ?? (config.nodeSize.x * 3) / 4
}) })
const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right)) const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right))

View File

@ -248,14 +248,16 @@ const margin = computed(() => ({
})) }))
const width = ref(Math.max(config.width ?? 0, config.nodeSize.x)) const width = ref(Math.max(config.width ?? 0, config.nodeSize.x))
watchPostEffect(() => { watchPostEffect(() => {
width.value = config.fullscreen width.value =
? containerNode.value?.parentElement?.clientWidth ?? 0 config.fullscreen ?
containerNode.value?.parentElement?.clientWidth ?? 0
: Math.max(config.width ?? 0, config.nodeSize.x) : Math.max(config.width ?? 0, config.nodeSize.x)
}) })
const height = ref(config.height ?? (config.nodeSize.x * 3) / 4) const height = ref(config.height ?? (config.nodeSize.x * 3) / 4)
watchPostEffect(() => { watchPostEffect(() => {
height.value = config.fullscreen height.value =
? containerNode.value?.parentElement?.clientHeight ?? 0 config.fullscreen ?
containerNode.value?.parentElement?.clientHeight ?? 0
: config.height ?? (config.nodeSize.x * 3) / 4 : config.height ?? (config.nodeSize.x * 3) / 4
}) })
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right)) const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
@ -309,7 +311,9 @@ const zoom = computed(() =>
const medDelta = 0.05 const medDelta = 0.05
const maxDelta = 1 const maxDelta = 1
const wheelSpeedMultiplier = const wheelSpeedMultiplier =
event.deltaMode === 1 ? medDelta : event.deltaMode ? maxDelta : minDelta event.deltaMode === 1 ? medDelta
: event.deltaMode ? maxDelta
: minDelta
return -event.deltaY * wheelSpeedMultiplier return -event.deltaY * wheelSpeedMultiplier
}) })
.scaleExtent(ZOOM_EXTENT) .scaleExtent(ZOOM_EXTENT)

View File

@ -46,9 +46,9 @@ const props = defineProps<{ data: Data }>()
const theme: Theme = DEFAULT_THEME const theme: Theme = DEFAULT_THEME
const language = computed(() => const language = computed(() =>
props.data.dialect != null && sqlFormatter.supportedDialects.includes(props.data.dialect) props.data.dialect != null && sqlFormatter.supportedDialects.includes(props.data.dialect) ?
? props.data.dialect props.data.dialect
: 'sql', : 'sql',
) )
const formatted = computed(() => { const formatted = computed(() => {
if (props.data.error != null || props.data.code == null) { if (props.data.error != null || props.data.code == null) {

View File

@ -135,9 +135,8 @@ const SCALE_TO_D3_SCALE: Record<ScaleType, () => d3.ScaleContinuousNumeric<numbe
const data = computed<Data>(() => { const data = computed<Data>(() => {
let rawData = props.data let rawData = props.data
const unfilteredData = Array.isArray(rawData) const unfilteredData =
? rawData.map((y, index) => ({ x: index, y })) Array.isArray(rawData) ? rawData.map((y, index) => ({ x: index, y })) : rawData.data ?? []
: rawData.data ?? []
const data: Point[] = unfilteredData.filter( const data: Point[] = unfilteredData.filter(
(point) => (point) =>
typeof point.x === 'number' && typeof point.x === 'number' &&
@ -203,15 +202,15 @@ const margin = computed(() => {
} }
}) })
const width = computed(() => const width = computed(() =>
config.fullscreen config.fullscreen ?
? containerNode.value?.parentElement?.clientWidth ?? 0 containerNode.value?.parentElement?.clientWidth ?? 0
: Math.max(config.width ?? 0, config.nodeSize.x), : Math.max(config.width ?? 0, config.nodeSize.x),
) )
const height = computed(() => const height = computed(() =>
config.fullscreen config.fullscreen ?
? containerNode.value?.parentElement?.clientHeight ?? 0 containerNode.value?.parentElement?.clientHeight ?? 0
: config.height ?? (config.nodeSize.x * 3) / 4, : config.height ?? (config.nodeSize.x * 3) / 4,
) )
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right)) const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
@ -291,7 +290,9 @@ const zoom = computed(() =>
const medDelta = 0.05 const medDelta = 0.05
const maxDelta = 1 const maxDelta = 1
const wheelSpeedMultiplier = const wheelSpeedMultiplier =
event.deltaMode === 1 ? medDelta : event.deltaMode ? maxDelta : minDelta event.deltaMode === 1 ? medDelta
: event.deltaMode ? maxDelta
: minDelta
return -event.deltaY * wheelSpeedMultiplier return -event.deltaY * wheelSpeedMultiplier
}) })
.scaleExtent(ZOOM_EXTENT) .scaleExtent(ZOOM_EXTENT)

View File

@ -296,11 +296,9 @@ watchEffect(() => {
const dataHeader = ('header' in data_ ? data_.header : [])?.map(toField) ?? [] const dataHeader = ('header' in data_ ? data_.header : [])?.map(toField) ?? []
columnDefs = [...indicesHeader, ...dataHeader] columnDefs = [...indicesHeader, ...dataHeader]
const rows = const rows =
data_.data && data_.data.length > 0 data_.data && data_.data.length > 0 ? data_.data[0]?.length ?? 0
? data_.data[0]?.length ?? 0 : data_.indices && data_.indices.length > 0 ? data_.indices[0]?.length ?? 0
: data_.indices && data_.indices.length > 0 : 0
? data_.indices[0]?.length ?? 0
: 0
rowData = Array.from({ length: rows }, (_, i) => { rowData = Array.from({ length: rows }, (_, i) => {
const shift = data_.indices ? data_.indices.length : 0 const shift = data_.indices ? data_.indices.length : 0
return Object.fromEntries( return Object.fromEntries(

View File

@ -0,0 +1,82 @@
<script setup lang="ts">
import { useAutoBlur } from '@/util/autoBlur'
import { getTextWidthByFont } from '@/util/measurement'
import { computed, ref, watch, type StyleValue } from 'vue'
const [model, modifiers] = defineModel<string>()
const props = defineProps<{ autoSelect?: boolean }>()
const innerModel = modifiers.lazy ? ref(model.value) : model
if (modifiers.lazy) watch(model, (newVal) => (innerModel.value = newVal))
const onChange = modifiers.lazy ? () => (model.value = innerModel.value) : undefined
const inputNode = ref<HTMLInputElement>()
useAutoBlur(inputNode)
function onFocus() {
if (props.autoSelect) {
inputNode.value?.select()
}
}
const cssFont = computed(() => {
if (inputNode.value == null) return ''
let style = window.getComputedStyle(inputNode.value)
return style.font
})
const getTextWidth = (text: string) => getTextWidthByFont(text, cssFont.value)
const inputWidth = computed(() => getTextWidth(`${innerModel.value}`))
const inputStyle = computed<StyleValue>(() => ({ width: `${inputWidth.value}px` }))
function onEnterDown() {
inputNode.value?.blur()
}
defineExpose({
inputWidth,
getTextWidth,
select: () => inputNode.value?.select(),
focus: () => inputNode.value?.focus(),
})
</script>
<template>
<input
ref="inputNode"
v-model="innerModel"
class="AutoSizedInput"
:style="inputStyle"
@keydown.backspace.stop
@keydown.delete.stop
@keydown.enter.stop="onEnterDown"
@change="onChange"
@focus="onFocus"
/>
</template>
<style scoped>
.AutoSizedInput {
position: relative;
display: inline-block;
background: none;
border: none;
text-align: center;
font-weight: 800;
line-height: 171.5%;
height: 24px;
appearance: textfield;
-moz-appearance: textfield;
cursor: default;
user-select: all;
box-sizing: content-box;
&:focus {
outline: none;
}
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>

View File

@ -21,10 +21,18 @@ const sortedValuesAndIndices = computed(() => {
]) ])
switch (sortDirection.value) { switch (sortDirection.value) {
case SortDirection.ascending: { case SortDirection.ascending: {
return valuesAndIndices.sort((a, b) => (a[0] > b[0] ? 1 : a[0] < b[0] ? -1 : 0)) return valuesAndIndices.sort((a, b) =>
a[0] > b[0] ? 1
: a[0] < b[0] ? -1
: 0,
)
} }
case SortDirection.descending: { case SortDirection.descending: {
return valuesAndIndices.sort((a, b) => (a[0] > b[0] ? -1 : a[0] < b[0] ? 1 : 0)) return valuesAndIndices.sort((a, b) =>
a[0] > b[0] ? -1
: a[0] < b[0] ? 1
: 0,
)
} }
case SortDirection.none: case SortDirection.none:
default: { default: {

View File

@ -1,135 +0,0 @@
<script setup lang="ts">
import { useResizeObserver } from '@/composables/events'
import { escape, unescape } from '@/util/ast/text'
import { blurIfNecessary } from '@/util/autoBlur'
import { getTextWidthByFont } from '@/util/measurement'
import { computed, ref, watch, type StyleValue } from 'vue'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{ 'update:modelValue': [modelValue: string] }>()
// Edited value reflects the `modelValue`, but does not update it until the user defocuses the field.
const editedValue = ref(props.modelValue)
watch(
() => props.modelValue,
(newValue) => {
editedValue.value = newValue
},
)
const inputNode = ref<HTMLInputElement>()
const inputSize = useResizeObserver(inputNode)
const inputMeasurements = computed(() => {
if (inputNode.value == null) return { availableWidth: 0, font: '' }
let style = window.getComputedStyle(inputNode.value)
let availableWidth =
inputSize.value.x - (parseFloat(style.paddingLeft) + parseFloat(style.paddingRight))
return { availableWidth, font: style.font }
})
const inputStyle = computed<StyleValue>(() => {
if (inputNode.value == null) {
return {}
}
const value = `${editedValue.value}`
const measurements = inputMeasurements.value
const width = getTextWidthByFont(value, measurements.font)
return {
width: `${width}px`,
}
})
/** To prevent other elements from stealing mouse events (which breaks blur),
* we instead setup our own `pointerdown` handler while the input is focused.
* Any click outside of the input field causes `blur`.
* We dont want to `useAutoBlur` here, because it would require a separate `pointerdown` handler per input widget.
* Instead we setup a single handler for the currently focused widget only, and thus safe performance. */
function setupAutoBlur() {
const options = { capture: true }
function callback(event: MouseEvent) {
if (blurIfNecessary(inputNode, event)) {
window.removeEventListener('pointerdown', callback, options)
}
}
window.addEventListener('pointerdown', callback, options)
}
const separators = /(^('''|"""|['"]))|(('''|"""|['"])$)/g
/** Display the value in a more human-readable form for easier editing. */
function prepareForEditing() {
editedValue.value = unescape(editedValue.value.replace(separators, ''))
}
function focus() {
setupAutoBlur()
prepareForEditing()
}
const escapedValue = computed(() => `'${escape(editedValue.value)}'`)
function blur() {
emit('update:modelValue', escapedValue.value)
editedValue.value = props.modelValue
}
</script>
<template>
<div
class="EnsoTextInputWidget"
@pointerdown.stop="() => inputNode?.focus()"
@keydown.backspace.stop
@keydown.delete.stop
>
<input
ref="inputNode"
v-model="editedValue"
class="value"
:style="inputStyle"
@keydown.enter.stop="($event.target as HTMLInputElement).blur()"
@focus="focus"
@blur="blur"
/>
</div>
</template>
<style scoped>
.EnsoTextInputWidget {
position: relative;
user-select: none;
background: var(--color-widget);
border-radius: var(--radius-full);
}
.value {
position: relative;
display: inline-block;
background: none;
border: none;
min-width: 24px;
text-align: center;
font-weight: 800;
line-height: 171.5%;
height: 24px;
padding: 0px 4px;
appearance: textfield;
-moz-appearance: textfield;
cursor: default;
}
input {
width: 100%;
border-radius: inherit;
&:focus {
outline: none;
background-color: rgba(255, 255, 255, 15%);
}
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>

View File

@ -46,9 +46,9 @@ const dragMetaMimePrefix = 'application/x-enso-list-item;item='
function stringToHex(str: string) { function stringToHex(str: string) {
return Array.from(str, (c) => return Array.from(str, (c) =>
c.charCodeAt(0) < 128 c.charCodeAt(0) < 128 ?
? c.charCodeAt(0).toString(16) c.charCodeAt(0).toString(16)
: encodeURIComponent(c).replace(/%/g, '').toLowerCase(), : encodeURIComponent(c).replace(/%/g, '').toLowerCase(),
).join('') ).join('')
} }

View File

@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { PointerButtonMask, usePointer, useResizeObserver } from '@/composables/events' import { PointerButtonMask, usePointer } from '@/composables/events'
import { blurIfNecessary } from '@/util/autoBlur' import { computed, ref, watch, type ComponentInstance, type StyleValue } from 'vue'
import { getTextWidthByFont } from '@/util/measurement' import AutoSizedInput from './AutoSizedInput.vue'
import { computed, ref, watch, type StyleValue } from 'vue'
const props = defineProps<{ const props = defineProps<{
modelValue: number | string modelValue: number | string
@ -12,11 +11,11 @@ const emit = defineEmits<{ 'update:modelValue': [modelValue: number | string] }>
const inputFieldActive = ref(false) const inputFieldActive = ref(false)
// Edited value reflects the `modelValue`, but does not update it until the user defocuses the field. // Edited value reflects the `modelValue`, but does not update it until the user defocuses the field.
const editedValue = ref(props.modelValue) const editedValue = ref(`${props.modelValue}`)
watch( watch(
() => props.modelValue, () => props.modelValue,
(newValue) => { (newValue) => {
editedValue.value = newValue editedValue.value = `${newValue}`
}, },
) )
const SLIDER_INPUT_THRESHOLD = 4.0 const SLIDER_INPUT_THRESHOLD = 4.0
@ -24,31 +23,27 @@ const SLIDER_INPUT_THRESHOLD = 4.0
const dragPointer = usePointer( const dragPointer = usePointer(
(position, event, eventType) => { (position, event, eventType) => {
const slider = event.target const slider = event.target
if (!(slider instanceof HTMLElement)) { if (!(slider instanceof HTMLElement)) return false
return
}
if (eventType === 'stop' && Math.abs(position.relative.x) < SLIDER_INPUT_THRESHOLD) { if (eventType === 'stop' && Math.abs(position.relative.x) < SLIDER_INPUT_THRESHOLD) {
inputNode.value?.focus() event.stopImmediatePropagation()
return return false
} }
if (eventType === 'start') { if (eventType === 'start') {
event.stopImmediatePropagation() event.stopImmediatePropagation()
return return false
} }
if (inputFieldActive.value || props.limits == null) return if (inputFieldActive.value || props.limits == null) return false
const { min, max } = props.limits const { min, max } = props.limits
const rect = slider.getBoundingClientRect() const rect = slider.getBoundingClientRect()
const fractionRaw = (position.absolute.x - rect.left) / (rect.right - rect.left) const fractionRaw = (position.absolute.x - rect.left) / (rect.right - rect.left)
const fraction = Math.max(0, Math.min(1, fractionRaw)) const fraction = Math.max(0, Math.min(1, fractionRaw))
const newValue = min + Math.round(fraction * (max - min)) const newValue = min + Math.round(fraction * (max - min))
editedValue.value = newValue editedValue.value = `${newValue}`
if (eventType === 'stop') { if (eventType === 'stop') emitUpdate()
emit('update:modelValue', editedValue.value)
}
}, },
PointerButtonMask.Main, PointerButtonMask.Main,
(event) => !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey, (event) => !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey,
@ -62,132 +57,80 @@ const sliderWidth = computed(() => {
}%` }%`
}) })
const inputNode = ref<HTMLInputElement>() const inputComponent = ref<ComponentInstance<typeof AutoSizedInput>>()
const inputSize = useResizeObserver(inputNode) const MIN_CONTENT_WIDTH = 56
const inputMeasurements = computed(() => {
if (inputNode.value == null) return { availableWidth: 0, font: '' }
let style = window.getComputedStyle(inputNode.value)
let availableWidth =
inputSize.value.x - (parseFloat(style.paddingLeft) + parseFloat(style.paddingRight))
return { availableWidth, font: style.font }
})
const inputStyle = computed<StyleValue>(() => { const inputStyle = computed<StyleValue>(() => {
if (inputNode.value == null) { const value = `${editedValue.value}`
return {}
}
const value = `${props.modelValue}`
const dotIdx = value.indexOf('.') const dotIdx = value.indexOf('.')
let indent = 0 let indent = 0
if (dotIdx >= 0) { if (dotIdx >= 0 && inputComponent.value != null) {
const { inputWidth, getTextWidth } = inputComponent.value
const textBefore = value.slice(0, dotIdx) const textBefore = value.slice(0, dotIdx)
const textAfter = value.slice(dotIdx + 1) const textAfter = value.slice(dotIdx + 1)
const availableWidth = Math.max(inputWidth, MIN_CONTENT_WIDTH)
const measurements = inputMeasurements.value const beforeDot = getTextWidth(textBefore)
const total = getTextWidthByFont(value, measurements.font) const afterDot = getTextWidth(textAfter)
const beforeDot = getTextWidthByFont(textBefore, measurements.font) const blankSpace = Math.max(availableWidth - inputWidth, 0)
const afterDot = getTextWidthByFont(textAfter, measurements.font)
const blankSpace = Math.max(measurements.availableWidth - total, 0)
indent = Math.min(Math.max(-blankSpace, afterDot - beforeDot), blankSpace) indent = Math.min(Math.max(-blankSpace, afterDot - beforeDot), blankSpace)
} }
return { return {
textIndent: `${indent}px`, textIndent: `${indent}px`,
// Note: The input element here uses `box-sizing: content-box;`.
minWidth: `${MIN_CONTENT_WIDTH}px`,
} }
}) })
function blur() { function emitUpdate() {
inputFieldActive.value = false if (`${props.modelValue}` !== editedValue.value) {
emit('update:modelValue', editedValue.value) emit('update:modelValue', editedValue.value)
}
} }
/** To prevent other elements from stealing mouse events (which breaks blur), function blur() {
* we instead setup our own `pointerdown` handler while the input is focused. inputFieldActive.value = false
* Any click outside of the input field causes `blur`. emitUpdate()
* We dont want to `useAutoBlur` here, because it would require a separate `pointerdown` handler per input widget.
* Instead we setup a single handler for the currently focused widget only, and thus safe performance. */
function setupAutoBlur() {
const options = { capture: true }
function callback(event: MouseEvent) {
if (blurIfNecessary(inputNode, event)) {
window.removeEventListener('pointerdown', callback, options)
}
}
window.addEventListener('pointerdown', callback, options)
} }
function focus() { function focus() {
inputNode.value?.select()
inputFieldActive.value = true inputFieldActive.value = true
setupAutoBlur()
} }
</script> </script>
<template> <template>
<div <label class="NumericInputWidget">
class="NumericInputWidget" <div v-if="props.limits != null" class="slider" :style="{ width: sliderWidth }"></div>
v-on="dragPointer.events" <AutoSizedInput
@keydown.backspace.stop ref="inputComponent"
@keydown.delete.stop
>
<div v-if="props.limits != null" class="fraction" :style="{ width: sliderWidth }"></div>
<input
ref="inputNode"
v-model="editedValue" v-model="editedValue"
class="value" autoSelect
:style="inputStyle" :style="inputStyle"
@keydown.enter.stop="($event.target as HTMLInputElement).blur()" v-on="dragPointer.events"
@blur="blur" @blur="blur"
@focus="focus" @focus="focus"
/> />
</div> </label>
</template> </template>
<style scoped> <style scoped>
.NumericInputWidget { .NumericInputWidget {
position: relative; position: relative;
}
.AutoSizedInput {
user-select: none; user-select: none;
justify-content: space-around;
background: var(--color-widget); background: var(--color-widget);
border-radius: var(--radius-full); border-radius: var(--radius-full);
overflow: clip; overflow: clip;
width: 56px; padding: 0px 4px;
&:focus {
background: var(--color-widget-focus);
}
} }
.fraction { .slider {
position: absolute; position: absolute;
height: 100%; height: 100%;
left: 0; left: 0;
background: var(--color-widget); background: var(--color-widget);
} }
.value {
position: relative;
display: inline-block;
background: none;
border: none;
text-align: center;
min-width: 0;
font-weight: 800;
line-height: 171.5%;
height: 24px;
padding: 0px 4px;
appearance: textfield;
-moz-appearance: textfield;
cursor: default;
}
input {
width: 100%;
border-radius: inherit;
&:focus {
outline: none;
background-color: rgba(255, 255, 255, 15%);
}
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style> </style>

View File

@ -2,7 +2,7 @@
import type { Opt } from '@/util/data/opt' import type { Opt } from '@/util/data/opt'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import type { VueInstance } from '@vueuse/core' import { type VueInstance } from '@vueuse/core'
import { import {
computed, computed,
onScopeDispose, onScopeDispose,
@ -13,6 +13,7 @@ import {
watch, watch,
watchEffect, watchEffect,
type Ref, type Ref,
type ShallowRef,
type WatchSource, type WatchSource,
} from 'vue' } from 'vue'
@ -139,6 +140,45 @@ export function unrefElement(
return (plain as VueInstance)?.$el ?? plain return (plain as VueInstance)?.$el ?? plain
} }
interface ResizeObserverData {
refCount: number
boundRectUsers: number
contentRect: ShallowRef<Vec2>
boundRect: ShallowRef<Vec2>
}
const resizeObserverData = new WeakMap<Element, ResizeObserverData>()
function getOrCreateObserverData(element: Element): ResizeObserverData {
const existingData = resizeObserverData.get(element)
if (existingData) return existingData
const data: ResizeObserverData = {
refCount: 0,
boundRectUsers: 0,
contentRect: shallowRef<Vec2>(Vec2.Zero),
boundRect: shallowRef<Vec2>(Vec2.Zero),
}
resizeObserverData.set(element, data)
return data
}
const sharedResizeObserver: ResizeObserver | undefined =
typeof ResizeObserver === 'undefined' ? undefined : (
new ResizeObserver((entries) => {
for (const entry of entries) {
const data = resizeObserverData.get(entry.target)
if (data != null) {
if (entry.contentRect != null) {
data.contentRect.value = new Vec2(entry.contentRect.width, entry.contentRect.height)
}
if (data.boundRectUsers > 0) {
const rect = entry.target.getBoundingClientRect()
data.boundRect.value = new Vec2(rect.width, rect.height)
}
}
}
})
)
/** /**
* Get DOM node size and keep it up to date. * Get DOM node size and keep it up to date.
* *
@ -153,8 +193,8 @@ export function useResizeObserver(
elementRef: Ref<Element | undefined | null | VueInstance>, elementRef: Ref<Element | undefined | null | VueInstance>,
useContentRect = true, useContentRect = true,
): Ref<Vec2> { ): Ref<Vec2> {
const sizeRef = shallowRef<Vec2>(Vec2.Zero) if (!sharedResizeObserver) {
if (typeof ResizeObserver === 'undefined') { const sizeRef = shallowRef<Vec2>(Vec2.Zero)
// Fallback implementation for browsers/test environment that do not support ResizeObserver: // Fallback implementation for browsers/test environment that do not support ResizeObserver:
// Grab the size of the element every time the ref is assigned, or when the page is resized. // Grab the size of the element every time the ref is assigned, or when the page is resized.
function refreshSize() { function refreshSize() {
@ -168,36 +208,36 @@ export function useResizeObserver(
useEvent(window, 'resize', refreshSize) useEvent(window, 'resize', refreshSize)
return sizeRef return sizeRef
} }
const observer = new ResizeObserver((entries) => { const observer = sharedResizeObserver
let rect: { width: number; height: number } | null = null
const target = unrefElement(elementRef)
for (const entry of entries) {
if (entry.target === target) {
if (useContentRect) {
rect = entry.contentRect
} else {
rect = entry.target.getBoundingClientRect()
}
}
}
if (rect != null) {
sizeRef.value = new Vec2(rect.width, rect.height)
}
})
watchEffect((onCleanup) => { watchEffect((onCleanup) => {
const element = unrefElement(elementRef) const element = unrefElement(elementRef)
if (element != null) { if (element != null) {
observer.observe(element) const data = getOrCreateObserverData(element)
if (data.refCount === 0) observer.observe(element)
data.refCount += 1
if (!useContentRect) {
if (data.boundRectUsers === 0) {
const rect = element.getBoundingClientRect()
data.boundRect.value = new Vec2(rect.width, rect.height)
}
data.boundRectUsers += 1
}
onCleanup(() => { onCleanup(() => {
if (elementRef.value != null) { if (elementRef.value != null) {
observer.unobserve(element) data.refCount -= 1
if (!useContentRect) data.boundRectUsers -= 1
if (data.refCount === 0) observer.unobserve(element)
} }
}) })
} }
}) })
return sizeRef return computed(() => {
const element = unrefElement(elementRef)
if (element == null) return Vec2.Zero
const data = getOrCreateObserverData(element)
return useContentRect ? data.contentRect.value : data.boundRect.value
})
} }
export interface EventPosition { export interface EventPosition {
@ -240,7 +280,7 @@ export const enum PointerButtonMask {
* @returns * @returns
*/ */
export function usePointer( export function usePointer(
handler: (pos: EventPosition, event: PointerEvent, eventType: PointerEventType) => void, handler: (pos: EventPosition, event: PointerEvent, eventType: PointerEventType) => void | boolean,
requiredButtonMask: number = PointerButtonMask.Main, requiredButtonMask: number = PointerButtonMask.Main,
predicate?: (e: PointerEvent) => boolean, predicate?: (e: PointerEvent) => boolean,
) { ) {
@ -256,18 +296,22 @@ export function usePointer(
trackedElement?.releasePointerCapture(trackedPointer.value) trackedElement?.releasePointerCapture(trackedPointer.value)
} }
trackedPointer.value = null
if (trackedElement != null && initialGrabPos != null && lastPos != null) { if (trackedElement != null && initialGrabPos != null && lastPos != null) {
handler(computePosition(e, initialGrabPos, lastPos), e, 'stop') if (handler(computePosition(e, initialGrabPos, lastPos), e, 'stop') !== false) {
e.preventDefault()
}
lastPos = null lastPos = null
trackedElement = null trackedElement = null
} }
trackedPointer.value = null
} }
function doMove(e: PointerEvent) { function doMove(e: PointerEvent) {
if (trackedElement != null && initialGrabPos != null && lastPos != null) { if (trackedElement != null && initialGrabPos != null && lastPos != null) {
handler(computePosition(e, initialGrabPos, lastPos), e, 'move') if (handler(computePosition(e, initialGrabPos, lastPos), e, 'move') !== false) {
e.preventDefault()
}
lastPos = new Vec2(e.clientX, e.clientY) lastPos = new Vec2(e.clientX, e.clientY)
} }
} }
@ -280,7 +324,6 @@ export function usePointer(
} }
if (trackedPointer.value == null && e.currentTarget instanceof Element) { if (trackedPointer.value == null && e.currentTarget instanceof Element) {
e.preventDefault()
trackedPointer.value = e.pointerId trackedPointer.value = e.pointerId
// This is mostly SAFE, as virtually all `Element`s also extend `GlobalEventHandlers`. // This is mostly SAFE, as virtually all `Element`s also extend `GlobalEventHandlers`.
trackedElement = e.currentTarget as Element & GlobalEventHandlers trackedElement = e.currentTarget as Element & GlobalEventHandlers
@ -288,21 +331,21 @@ export function usePointer(
trackedElement.setPointerCapture?.(e.pointerId) trackedElement.setPointerCapture?.(e.pointerId)
initialGrabPos = new Vec2(e.clientX, e.clientY) initialGrabPos = new Vec2(e.clientX, e.clientY)
lastPos = initialGrabPos lastPos = initialGrabPos
handler(computePosition(e, initialGrabPos, lastPos), e, 'start') if (handler(computePosition(e, initialGrabPos, lastPos), e, 'start') !== false) {
e.preventDefault()
}
} }
}, },
pointerup(e: PointerEvent) { pointerup(e: PointerEvent) {
if (trackedPointer.value !== e.pointerId) { if (trackedPointer.value !== e.pointerId) {
return return
} }
e.preventDefault()
doStop(e) doStop(e)
}, },
pointermove(e: PointerEvent) { pointermove(e: PointerEvent) {
if (trackedPointer.value !== e.pointerId) { if (trackedPointer.value !== e.pointerId) {
return return
} }
e.preventDefault()
// handle release of all masked buttons as stop // handle release of all masked buttons as stop
if ((e.buttons & requiredButtonMask) !== 0) { if ((e.buttons & requiredButtonMask) !== 0) {
doMove(e) doMove(e)

View File

@ -4,7 +4,7 @@ import { useApproach } from '@/composables/animation'
import { PointerButtonMask, useEvent, usePointer, useResizeObserver } from '@/composables/events' import { PointerButtonMask, useEvent, usePointer, useResizeObserver } from '@/composables/events'
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 { computed, proxyRefs, ref, type Ref } from 'vue' import { computed, proxyRefs, shallowRef, type Ref } from 'vue'
function elemRect(target: Element | undefined): Rect { function elemRect(target: Element | undefined): Rect {
if (target != null && target instanceof Element) { if (target != null && target instanceof Element) {
@ -17,7 +17,7 @@ function elemRect(target: Element | undefined): Rect {
export type NavigatorComposable = ReturnType<typeof useNavigator> export type NavigatorComposable = ReturnType<typeof useNavigator>
export function useNavigator(viewportNode: Ref<Element | undefined>) { export function useNavigator(viewportNode: Ref<Element | undefined>) {
const size = useResizeObserver(viewportNode) const size = useResizeObserver(viewportNode)
const targetCenter = ref<Vec2>(Vec2.Zero) const targetCenter = shallowRef<Vec2>(Vec2.Zero)
const targetX = computed(() => targetCenter.value.x) const targetX = computed(() => targetCenter.value.x)
const targetY = computed(() => targetCenter.value.y) const targetY = computed(() => targetCenter.value.y)
const centerX = useApproach(targetX) const centerX = useApproach(targetX)
@ -32,7 +32,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
centerY.value = value.y centerY.value = value.y
}, },
}) })
const targetScale = ref(1) const targetScale = shallowRef(1)
const animatedScale = useApproach(targetScale) const animatedScale = useApproach(targetScale)
const scale = computed({ const scale = computed({
get() { get() {
@ -140,7 +140,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
let isPointerDown = false let isPointerDown = false
let scrolledThisFrame = false let scrolledThisFrame = false
const eventMousePos = ref<Vec2 | null>(null) const eventMousePos = shallowRef<Vec2 | null>(null)
let eventTargetScrollPos: Vec2 | null = null let eventTargetScrollPos: Vec2 | null = null
const sceneMousePos = computed(() => const sceneMousePos = computed(() =>
eventMousePos.value ? clientToScenePos(eventMousePos.value) : null, eventMousePos.value ? clientToScenePos(eventMousePos.value) : null,

View File

@ -6,7 +6,7 @@ import { type NodeId } from '@/stores/graph'
import type { Rect } from '@/util/data/rect' import type { Rect } from '@/util/data/rect'
import { intersectionSize } from '@/util/data/set' import { intersectionSize } from '@/util/data/set'
import type { Vec2 } from '@/util/data/vec2' import type { Vec2 } from '@/util/data/vec2'
import { computed, proxyRefs, reactive, ref, shallowRef } from 'vue' import { computed, proxyRefs, ref, shallowReactive, shallowRef } from 'vue'
export type SelectionComposable<T> = ReturnType<typeof useSelection<T>> export type SelectionComposable<T> = ReturnType<typeof useSelection<T>>
export function useSelection<T>( export function useSelection<T>(
@ -20,7 +20,7 @@ export function useSelection<T>(
) { ) {
const anchor = shallowRef<Vec2>() const anchor = shallowRef<Vec2>()
const initiallySelected = new Set<T>() const initiallySelected = new Set<T>()
const selected = reactive(new Set<T>()) const selected = shallowReactive(new Set<T>())
const hoveredNode = ref<NodeId>() const hoveredNode = ref<NodeId>()
const hoveredPort = ref<PortId>() const hoveredPort = ref<PortId>()
@ -28,11 +28,13 @@ export function useSelection<T>(
if (event.target instanceof Element) { if (event.target instanceof Element) {
const widgetPort = event.target.closest('.WidgetPort') const widgetPort = event.target.closest('.WidgetPort')
hoveredPort.value = hoveredPort.value =
widgetPort instanceof HTMLElement && (
'port' in widgetPort.dataset && widgetPort instanceof HTMLElement &&
typeof widgetPort.dataset.port === 'string' 'port' in widgetPort.dataset &&
? (widgetPort.dataset.port as PortId) typeof widgetPort.dataset.port === 'string'
: undefined ) ?
(widgetPort.dataset.port as PortId)
: undefined
} }
}) })
@ -125,18 +127,19 @@ export function useSelection<T>(
const pointer = usePointer((_pos, event, eventType) => { const pointer = usePointer((_pos, event, eventType) => {
if (eventType === 'start') { if (eventType === 'start') {
readInitiallySelected() readInitiallySelected()
} else if (eventType === 'stop') {
if (anchor.value == null) {
// If there was no drag, we want to handle "clicking-off" selected nodes.
selectionEventHandler(event)
} else {
anchor.value = undefined
}
initiallySelected.clear()
} else if (pointer.dragging) { } else if (pointer.dragging) {
if (anchor.value == null) { if (anchor.value == null) {
anchor.value = navigator.sceneMousePos?.copy() anchor.value = navigator.sceneMousePos?.copy()
} }
selectionEventHandler(event) selectionEventHandler(event)
} else if (eventType === 'stop') {
if (anchor.value == null) {
// If there was no drag, we want to handle "clicking-off" selected nodes.
selectionEventHandler(event)
}
anchor.value = undefined
initiallySelected.clear()
} }
}) })

View File

@ -56,9 +56,8 @@ export function createContextStore<F extends (...args: any[]) => any>(name: stri
): ReturnType<F> { ): ReturnType<F> {
// Right now this function assumes that an array always represents the arguments to the factory. // Right now this function assumes that an array always represents the arguments to the factory.
// If we ever need to mock an array as the context value, we'll worry about it then. // If we ever need to mock an array as the context value, we'll worry about it then.
const constructed: ReturnType<F> = Array.isArray(valueOrArgs) const constructed: ReturnType<F> =
? factory(...valueOrArgs) Array.isArray(valueOrArgs) ? factory(...valueOrArgs) : valueOrArgs
: valueOrArgs
if (app != null) app.provide(provideKey, constructed) if (app != null) app.provide(provideKey, constructed)
else provide(provideKey, constructed) else provide(provideKey, constructed)
return constructed return constructed

View File

@ -1,5 +1,5 @@
import { createContextStore } from '@/providers' import { createContextStore } from '@/providers'
import type { AstId } from '@/util/ast/abstract' import type { AstId, TokenId } from '@/util/ast/abstract'
import { identity } from '@vueuse/core' import { identity } from '@vueuse/core'
declare const portIdBrand: unique symbol declare const portIdBrand: unique symbol
@ -7,7 +7,7 @@ declare const portIdBrand: unique symbol
* Port identification. A port represents a fragment of code displayed/modified by the widget; * Port identification. A port represents a fragment of code displayed/modified by the widget;
* usually Ast nodes, but other ids are also possible (like argument placeholders). * usually Ast nodes, but other ids are also possible (like argument placeholders).
*/ */
export type PortId = AstId | (string & { [portIdBrand]: never }) export type PortId = AstId | TokenId | (string & { [portIdBrand]: never })
interface PortInfo { interface PortInfo {
portId: PortId portId: PortId

View File

@ -4,7 +4,7 @@ import type { WidgetConfiguration } from '@/providers/widgetRegistry/configurati
import type { GraphDb } from '@/stores/graph/graphDatabase' import type { GraphDb } from '@/stores/graph/graphDatabase'
import type { Typename } from '@/stores/suggestionDatabase/entry' import type { Typename } from '@/stores/suggestionDatabase/entry'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import { MutableModule, type TokenId } from '@/util/ast/abstract.ts' import { MutableModule } from '@/util/ast/abstract.ts'
import { computed, shallowReactive, type Component, type PropType } from 'vue' import { computed, shallowReactive, type Component, type PropType } from 'vue'
export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>> export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>>
@ -92,7 +92,7 @@ export interface WidgetInput {
* *
* Also, used as usage key (see {@link usageKeyForInput}) * Also, used as usage key (see {@link usageKeyForInput})
*/ */
portId: PortId | TokenId portId: PortId
/** /**
* An expected widget value. If Ast.Ast or Ast.Token, the widget represents an existing part of * An expected widget value. If Ast.Ast or Ast.Token, the widget represents an existing part of
* code. If string, it may be e.g. a default value of an argument. * code. If string, it may be e.g. a default value of an argument.
@ -171,12 +171,10 @@ export function widgetProps<T extends WidgetInput>(_def: WidgetDefinition<T>) {
type InputMatcherFn<T extends WidgetInput> = (input: WidgetInput) => input is T type InputMatcherFn<T extends WidgetInput> = (input: WidgetInput) => input is T
type InputMatcher<T extends WidgetInput> = keyof WidgetInput | InputMatcherFn<T> type InputMatcher<T extends WidgetInput> = keyof WidgetInput | InputMatcherFn<T>
type InputTy<M> = M extends (infer T)[] type InputTy<M> =
? InputTy<T> M extends (infer T)[] ? InputTy<T>
: M extends InputMatcherFn<infer T> : M extends InputMatcherFn<infer T> ? T
? T : M extends keyof WidgetInput ? WidgetInput & Required<Pick<WidgetInput, M>>
: M extends keyof WidgetInput
? WidgetInput & Required<Pick<WidgetInput, M>>
: never : never
export interface WidgetOptions<T extends WidgetInput> { export interface WidgetOptions<T extends WidgetInput> {

View File

@ -169,8 +169,8 @@ export function requiredImports(
] ]
switch (entry.kind) { switch (entry.kind) {
case SuggestionKind.Module: case SuggestionKind.Module:
return entry.reexportedIn return entry.reexportedIn ?
? unqualifiedImport(entry.reexportedIn) unqualifiedImport(entry.reexportedIn)
: [ : [
{ {
kind: 'Qualified', kind: 'Qualified',
@ -181,11 +181,11 @@ export function requiredImports(
return unqualifiedImport(entry.reexportedIn ? entry.reexportedIn : entry.definedIn) return unqualifiedImport(entry.reexportedIn ? entry.reexportedIn : entry.definedIn)
case SuggestionKind.Constructor: case SuggestionKind.Constructor:
if (directConImport) { if (directConImport) {
return entry.reexportedIn return (
? unqualifiedImport(entry.reexportedIn) entry.reexportedIn ? unqualifiedImport(entry.reexportedIn)
: entry.memberOf : entry.memberOf ? unqualifiedImport(entry.memberOf)
? unqualifiedImport(entry.memberOf)
: [] : []
)
} else { } else {
const selfType = selfTypeEntry(db, entry) const selfType = selfTypeEntry(db, entry)
return selfType ? requiredImports(db, selfType) : [] return selfType ? requiredImports(db, selfType) : []
@ -254,11 +254,9 @@ export function requiredImportEquals(left: RequiredImport, right: RequiredImport
/** Check if `existing` import statement covers `required`. */ /** Check if `existing` import statement covers `required`. */
export function covers(existing: Import, required: RequiredImport): boolean { export function covers(existing: Import, required: RequiredImport): boolean {
const [parent, name] = const [parent, name] =
required.kind === 'Qualified' required.kind === 'Qualified' ? qnSplit(required.module)
? qnSplit(required.module) : required.kind === 'Unqualified' ? [required.from, required.import]
: required.kind === 'Unqualified' : [undefined, '']
? [required.from, required.import]
: [undefined, '']
const directlyImported = const directlyImported =
required.kind === 'Qualified' && required.kind === 'Qualified' &&
existing.imported.kind === 'Module' && existing.imported.kind === 'Module' &&

View File

@ -34,7 +34,16 @@ import { SourceDocument } from 'shared/ast/sourceDocument'
import type { ExpressionUpdate, StackItem } from 'shared/languageServerTypes' import type { ExpressionUpdate, StackItem } from 'shared/languageServerTypes'
import type { LocalOrigin, SourceRangeKey, VisualizationMetadata } from 'shared/yjsModel' import type { LocalOrigin, SourceRangeKey, VisualizationMetadata } from 'shared/yjsModel'
import { defaultLocalOrigin, sourceRangeKey, visMetadataEquals } from 'shared/yjsModel' import { defaultLocalOrigin, sourceRangeKey, visMetadataEquals } from 'shared/yjsModel'
import { computed, markRaw, reactive, ref, toRef, watch, type ShallowRef } from 'vue' import {
computed,
markRaw,
reactive,
ref,
shallowReactive,
toRef,
watch,
type ShallowRef,
} from 'vue'
export type { export type {
Node, Node,
@ -53,7 +62,9 @@ export class PortViewInstance {
public rect: ShallowRef<Rect | undefined>, public rect: ShallowRef<Rect | undefined>,
public nodeId: NodeId, public nodeId: NodeId,
public onUpdate: (update: WidgetUpdate) => void, public onUpdate: (update: WidgetUpdate) => void,
) {} ) {
markRaw(this)
}
} }
export const useGraphStore = defineStore('graph', () => { export const useGraphStore = defineStore('graph', () => {
@ -77,7 +88,7 @@ export const useGraphStore = defineStore('graph', () => {
toRef(suggestionDb, 'groups'), toRef(suggestionDb, 'groups'),
proj.computedValueRegistry, proj.computedValueRegistry,
) )
const portInstances = reactive(new Map<PortId, Set<PortViewInstance>>()) const portInstances = shallowReactive(new Map<PortId, Set<PortViewInstance>>())
const editedNodeInfo = ref<NodeEditInfo>() const editedNodeInfo = ref<NodeEditInfo>()
const methodAst = ref<Ast.Function>() const methodAst = ref<Ast.Function>()
@ -107,8 +118,12 @@ export const useGraphStore = defineStore('graph', () => {
function handleModuleUpdate(module: Module, moduleChanged: boolean, update: ModuleUpdate) { function handleModuleUpdate(module: Module, moduleChanged: boolean, update: ModuleUpdate) {
const root = module.root() const root = module.root()
if (!root) return if (!root) return
moduleRoot.value = root if (moduleRoot.value != root) {
if (root instanceof Ast.BodyBlock) topLevel.value = root moduleRoot.value = root
}
if (root instanceof Ast.BodyBlock && topLevel.value != root) {
topLevel.value = root
}
// We can cast maps of unknown metadata fields to `NodeMetadata` because all `NodeMetadata` fields are optional. // We can cast maps of unknown metadata fields to `NodeMetadata` because all `NodeMetadata` fields are optional.
const nodeMetadataUpdates = update.metadataUpdated as any as { const nodeMetadataUpdates = update.metadataUpdated as any as {
id: AstId id: AstId
@ -375,25 +390,38 @@ export const useGraphStore = defineStore('graph', () => {
} }
function updateNodeRect(nodeId: NodeId, rect: Rect) { function updateNodeRect(nodeId: NodeId, rect: Rect) {
const nodeAst = syncModule.value?.tryGet(nodeId) nodeRects.set(nodeId, rect)
if (!nodeAst) return if (rect.pos.equals(Vec2.Zero)) {
if (rect.pos.equals(Vec2.Zero) && !nodeAst.nodeMetadata.get('position')) { nodesToPlace.push(nodeId)
const { position } = nonDictatedPlacement(rect.size, {
nodeRects: visibleNodeAreas.value,
// The rest of the properties should not matter.
selectedNodeRects: [],
screenBounds: Rect.Zero,
mousePosition: Vec2.Zero,
})
editNodeMetadata(nodeAst, (metadata) =>
metadata.set('position', { x: position.x, y: position.y }),
)
nodeRects.set(nodeId, new Rect(position, rect.size))
} else {
nodeRects.set(nodeId, rect)
} }
} }
const nodesToPlace = reactive<NodeId[]>([])
watch(nodesToPlace, (nodeIds) => {
if (nodeIds.length === 0) return
const nodesToProcess = [...nodeIds]
nodesToPlace.length = 0
batchEdits(() => {
for (const nodeId of nodesToProcess) {
const nodeAst = syncModule.value?.get(nodeId)
const rect = nodeRects.get(nodeId)
if (!rect || !nodeAst || nodeAst.nodeMetadata.get('position') != null) continue
const { position } = nonDictatedPlacement(rect.size, {
nodeRects: visibleNodeAreas.value,
// The rest of the properties should not matter.
selectedNodeRects: [],
screenBounds: Rect.Zero,
mousePosition: Vec2.Zero,
})
editNodeMetadata(nodeAst, (metadata) =>
metadata.set('position', { x: position.x, y: position.y }),
)
nodeRects.set(nodeId, new Rect(position, rect.size))
}
})
})
function updateVizRect(id: NodeId, rect: Rect | undefined) { function updateVizRect(id: NodeId, rect: Rect | undefined) {
if (rect) vizRects.set(id, rect) if (rect) vizRects.set(id, rect)
else vizRects.delete(id) else vizRects.delete(id)
@ -506,6 +534,11 @@ export const useGraphStore = defineStore('graph', () => {
return result! return result!
} }
function batchEdits(f: () => void) {
assert(syncModule.value != null)
syncModule.value.transact(f, 'local')
}
function editNodeMetadata(ast: Ast.Ast, f: (metadata: Ast.MutableNodeMetadata) => void) { function editNodeMetadata(ast: Ast.Ast, f: (metadata: Ast.MutableNodeMetadata) => void) {
edit((edit) => f(edit.getVersion(ast).mutableNodeMetadata()), true, true) edit((edit) => f(edit.getVersion(ast).mutableNodeMetadata()), true, true)
} }
@ -626,6 +659,7 @@ export const useGraphStore = defineStore('graph', () => {
createNode, createNode,
deleteNodes, deleteNodes,
ensureCorrectNodeOrder, ensureCorrectNodeOrder,
batchEdits,
setNodeContent, setNodeContent,
setNodePosition, setNodePosition,
setNodeVisualization, setNodeVisualization,

View File

@ -45,7 +45,6 @@ import {
shallowRef, shallowRef,
watch, watch,
watchEffect, watchEffect,
type ShallowRef,
type WatchSource, type WatchSource,
type WritableComputedRef, type WritableComputedRef,
} from 'vue' } from 'vue'
@ -220,9 +219,9 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
executionContextId: this.id, executionContextId: this.id,
expression: config.expression, expression: config.expression,
visualizationModule: config.visualizationModule, visualizationModule: config.visualizationModule,
...(config.positionalArgumentsExpressions ...(config.positionalArgumentsExpressions ?
? { positionalArgumentsExpressions: config.positionalArgumentsExpressions } { positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}), : {}),
}), }),
'Failed to attach visualization', 'Failed to attach visualization',
).then(() => { ).then(() => {
@ -237,9 +236,9 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
executionContextId: this.id, executionContextId: this.id,
expression: config.expression, expression: config.expression,
visualizationModule: config.visualizationModule, visualizationModule: config.visualizationModule,
...(config.positionalArgumentsExpressions ...(config.positionalArgumentsExpressions ?
? { positionalArgumentsExpressions: config.positionalArgumentsExpressions } { positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}), : {}),
}), }),
'Failed to modify visualization', 'Failed to modify visualization',
).then(() => { ).then(() => {
@ -582,9 +581,7 @@ export const useProjectStore = defineStore('project', () => {
diagnostics.value = newDiagnostics diagnostics.value = newDiagnostics
}) })
function useVisualizationData( function useVisualizationData(configuration: WatchSource<Opt<NodeVisualizationConfiguration>>) {
configuration: WatchSource<Opt<NodeVisualizationConfiguration>>,
): ShallowRef<Result<{}> | undefined> {
const id = random.uuidv4() as Uuid const id = random.uuidv4() as Uuid
watch( watch(
@ -598,28 +595,28 @@ export const useProjectStore = defineStore('project', () => {
{ immediate: true, flush: 'post' }, { immediate: true, flush: 'post' },
) )
return shallowRef( return computed(() => {
computed(() => { const json = visualizationDataRegistry.getRawData(id)
const json = visualizationDataRegistry.getRawData(id) if (!json?.ok) return json ?? undefined
if (!json?.ok) return json ?? undefined const parsed = Ok(JSON.parse(json.value))
else return Ok(JSON.parse(json.value)) markRaw(parsed)
}), return parsed
) })
} }
const dataflowErrors = new ReactiveMapping(computedValueRegistry.db, (id, info) => { const dataflowErrors = new ReactiveMapping(computedValueRegistry.db, (id, info) => {
const config = computed(() => const config = computed(() =>
info.payload.type === 'DataflowError' info.payload.type === 'DataflowError' ?
? { {
expressionId: id, expressionId: id,
visualizationModule: 'Standard.Visualization.Preprocessor', visualizationModule: 'Standard.Visualization.Preprocessor',
expression: { expression: {
module: 'Standard.Visualization.Preprocessor', module: 'Standard.Visualization.Preprocessor',
definedOnType: 'Standard.Visualization.Preprocessor', definedOnType: 'Standard.Visualization.Preprocessor',
name: 'error_preprocessor', name: 'error_preprocessor',
}, },
} }
: null, : null,
) )
const data = useVisualizationData(config) const data = useVisualizationData(config)
return computed<{ kind: 'Dataflow'; message: string } | undefined>(() => { return computed<{ kind: 'Dataflow'; message: string } | undefined>(() => {

View File

@ -416,10 +416,10 @@ async function rewriteImports(code: string, dir: string, id: string | undefined)
const pathJSON = JSON.stringify(path) const pathJSON = JSON.stringify(path)
const destructureExpression = `{ ${specifiers.join(', ')} }` const destructureExpression = `{ ${specifiers.join(', ')} }`
const rewritten = const rewritten =
namespace != null namespace != null ?
? `const ${namespace} = await window.__visualizationModules[${pathJSON}];` + `const ${namespace} = await window.__visualizationModules[${pathJSON}];` +
(specifiers.length > 0 ? `\nconst ${destructureExpression} = ${namespace};` : '') (specifiers.length > 0 ? `\nconst ${destructureExpression} = ${namespace};` : '')
: `const ${destructureExpression} = await window.__visualizationModules[${pathJSON}];` : `const ${destructureExpression} = await window.__visualizationModules[${pathJSON}];`
s.overwrite(stmt.start!, stmt.end!, rewritten) s.overwrite(stmt.start!, stmt.end!, rewritten)
if (isBuiltin) { if (isBuiltin) {
// No further action is needed. // No further action is needed.
@ -437,6 +437,7 @@ async function rewriteImports(code: string, dir: string, id: string | undefined)
if (mimetype != null) { if (mimetype != null) {
return importAsset(path, mimetype) return importAsset(path, mimetype)
} }
return Promise.resolve(undefined)
} }
} }
}) })
@ -484,9 +485,9 @@ onmessage = async (
case 'compile-request': { case 'compile-request': {
try { try {
const path = event.data.path const path = event.data.path
await (event.data.recompile await (event.data.recompile ?
? importVue(path) importVue(path)
: map.setIfUndefined(alreadyCompiledModules, path, () => importVue(path))) : map.setIfUndefined(alreadyCompiledModules, path, () => importVue(path)))
postMessage<CompilationResultResponse>({ postMessage<CompilationResultResponse>({
type: 'compilation-result-response', type: 'compilation-result-response',
id: event.data.id, id: event.data.id,

View File

@ -235,12 +235,12 @@ export const useVisualizationStore = defineStore('visualization', () => {
function* types(type: Opt<string>) { function* types(type: Opt<string>) {
const types = const types =
type == null type == null ?
? metadata.keys() metadata.keys()
: new Set([ : new Set([
...(metadata.visualizationIdToType.reverseLookup(type) ?? []), ...(metadata.visualizationIdToType.reverseLookup(type) ?? []),
...(metadata.visualizationIdToType.reverseLookup('Any') ?? []), ...(metadata.visualizationIdToType.reverseLookup('Any') ?? []),
]) ])
for (const type of types) yield fromVisualizationId(type) for (const type of types) yield fromVisualizationId(type)
} }

View File

@ -9,10 +9,13 @@ import {
readTokenSpan, readTokenSpan,
walkRecursive, walkRecursive,
} from '@/util/ast' } from '@/util/ast'
import { fc, test } from '@fast-check/vitest'
import { initializeFFI } from 'shared/ast/ffi' import { initializeFFI } from 'shared/ast/ffi'
import { Token, Tree } from 'shared/ast/generated/ast' import { Token, Tree } from 'shared/ast/generated/ast'
import type { LazyObject } from 'shared/ast/parserSupport' import type { LazyObject } from 'shared/ast/parserSupport'
import { assert, expect, test } from 'vitest' import { escapeTextLiteral, unescapeTextLiteral } from 'shared/ast/text'
import { assert, expect } from 'vitest'
import { TextLiteral } from '../abstract'
await initializeFFI() await initializeFFI()
@ -224,3 +227,46 @@ test.each([
expect(readAstSpan(ast, code)).toBe(expected?.repr) expect(readAstSpan(ast, code)).toBe(expected?.repr)
} }
}) })
test.each([
['', ''],
['\\x20', ' ', ' '],
['\\b', '\b'],
['abcdef_123', 'abcdef_123'],
['\\t\\r\\n\\v\\"\\\'\\`', '\t\r\n\v"\'`'],
['\\u00B6\\u{20}\\U\\u{D8\\xBFF}', '\xB6 \0\xD8\xBFF}', '\xB6 \\0\xD8\xBFF}'],
['\\`foo\\` \\`bar\\` \\`baz\\`', '`foo` `bar` `baz`'],
])(
'Applying and escaping text literal interpolation',
(escapedText: string, rawText: string, roundtrip?: string) => {
const actualApplied = unescapeTextLiteral(escapedText)
const actualEscaped = escapeTextLiteral(rawText)
expect(actualEscaped).toBe(roundtrip ?? escapedText)
expect(actualApplied).toBe(rawText)
},
)
const sometimesUnicodeString = fc.oneof(fc.string(), fc.unicodeString())
test.prop({ rawText: sometimesUnicodeString })('Text interpolation roundtrip', ({ rawText }) => {
expect(unescapeTextLiteral(escapeTextLiteral(rawText))).toBe(rawText)
})
test.prop({ rawText: sometimesUnicodeString })('AST text literal new', ({ rawText }) => {
const literal = TextLiteral.new(rawText)
expect(literal.rawTextContent).toBe(rawText)
})
test.prop({
boundary: fc.constantFrom('"', "'"),
rawText: sometimesUnicodeString,
})('AST text literal rawTextContent', ({ boundary, rawText }) => {
const literal = TextLiteral.new('')
literal.setBoundaries(boundary)
literal.setRawTextContent(rawText)
expect(literal.rawTextContent).toBe(rawText)
const expectInterpolated = rawText.includes('"') || boundary === "'"
const expectedCode = expectInterpolated ? `'${escapeTextLiteral(rawText)}'` : `"${rawText}"`
expect(literal.code()).toBe(expectedCode)
})

View File

@ -107,11 +107,9 @@ function printArgPattern(application: ArgumentApplication | Ast.Ast) {
while (current instanceof ArgumentApplication) { while (current instanceof ArgumentApplication) {
const sigil = const sigil =
current.argument instanceof ArgumentPlaceholder current.argument instanceof ArgumentPlaceholder ? '?'
? '?' : current.appTree instanceof Ast.App && current.appTree.argumentName ? '='
: current.appTree instanceof Ast.App && current.appTree.argumentName : '@'
? '='
: '@'
parts.push(sigil + (current.argument.argInfo?.name ?? '_')) parts.push(sigil + (current.argument.argInfo?.name ?? '_'))
current = current.target current = current.target
} }

View File

@ -92,11 +92,11 @@ test.each([
).toBe(extracted != null) ).toBe(extracted != null)
expect( expect(
patternAst.match(targetAst)?.map((match) => module.tryGet(match)?.code()), patternAst.match(targetAst)?.map((match) => module.tryGet(match)?.code()),
extracted != null extracted != null ?
? `'${target}' matches '${pattern}' with '__'s corresponding to ${JSON.stringify(extracted) `'${target}' matches '${pattern}' with '__'s corresponding to ${JSON.stringify(extracted)
.slice(1, -1) .slice(1, -1)
.replace(/"/g, "'")}` .replace(/"/g, "'")}`
: `'${target}' does not match '${pattern}'`, : `'${target}' does not match '${pattern}'`,
).toStrictEqual(extracted) ).toStrictEqual(extracted)
}) })

View File

@ -1,13 +0,0 @@
import * as astText from '@/util/ast/text'
import { unescape } from 'querystring'
import { expect, test } from 'vitest'
test.each([
{ string: 'abcdef_123', escaped: 'abcdef_123' },
{ string: '\t\r\n\v"\'`', escaped: '\\t\\r\\n\\v\\"\\\'``' },
{ string: '`foo` `bar` `baz`', escaped: '``foo`` ``bar`` ``baz``' },
])('`escape`', ({ string, escaped }) => {
const result = astText.escape(string)
expect(result).toBe(escaped)
expect(unescape(escaped)).toBe(string)
})

View File

@ -302,10 +302,9 @@ export class AliasAnalyzer {
const expression = caseLine.case?.expression const expression = caseLine.case?.expression
if (pattern) { if (pattern) {
const armStart = parsedTreeOrTokenRange(pattern)[0] const armStart = parsedTreeOrTokenRange(pattern)[0]
const armEnd = expression const armEnd =
? parsedTreeOrTokenRange(expression)[1] expression ? parsedTreeOrTokenRange(expression)[1]
: arrow : arrow ? parsedTreeOrTokenRange(arrow)[1]
? parsedTreeOrTokenRange(arrow)[1]
: parsedTreeOrTokenRange(pattern)[1] : parsedTreeOrTokenRange(pattern)[1]
const armRange: SourceRange = [armStart, armEnd] const armRange: SourceRange = [armStart, armEnd]

View File

@ -18,9 +18,8 @@ import { tryGetSoleValue } from 'shared/util/data/iterable'
import type { ExternalId, IdMap, SourceRange } from 'shared/yjsModel' import type { ExternalId, IdMap, SourceRange } from 'shared/yjsModel'
import { markRaw } from 'vue' import { markRaw } from 'vue'
type ExtractType<V, T> = T extends ReadonlyArray<infer Ts> type ExtractType<V, T> =
? Extract<V, { type: Ts }> T extends ReadonlyArray<infer Ts> ? Extract<V, { type: Ts }> : Extract<V, { type: T }>
: Extract<V, { type: T }>
type OneOrArray<T> = T | readonly T[] type OneOrArray<T> = T | readonly T[]
@ -149,8 +148,8 @@ export class AstExtended<T extends Tree | Token = Tree | Token, HasIdMap extends
} }
whitespaceLength() { whitespaceLength() {
return 'whitespaceLengthInCodeBuffer' in this.inner return 'whitespaceLengthInCodeBuffer' in this.inner ?
? this.inner.whitespaceLengthInCodeBuffer this.inner.whitespaceLengthInCodeBuffer
: this.inner.whitespaceLengthInCodeParsed : this.inner.whitespaceLengthInCodeParsed
} }
@ -179,10 +178,9 @@ export class AstExtended<T extends Tree | Token = Tree | Token, HasIdMap extends
} }
} }
type CondType<T, Cond extends boolean> = Cond extends true type CondType<T, Cond extends boolean> =
? T Cond extends true ? T
: Cond extends false : Cond extends false ? undefined
? undefined
: T | undefined : T | undefined
class AstExtendedCtx<HasIdMap extends boolean> { class AstExtendedCtx<HasIdMap extends boolean> {

View File

@ -3,9 +3,9 @@ import { Ast } from '@/util/ast'
export function nodeFromAst(ast: Ast.Ast): NodeDataFromAst | undefined { export function nodeFromAst(ast: Ast.Ast): NodeDataFromAst | undefined {
const { nodeCode, documentation } = const { nodeCode, documentation } =
ast instanceof Ast.Documented ast instanceof Ast.Documented ?
? { nodeCode: ast.expression, documentation: ast.documentation() } { nodeCode: ast.expression, documentation: ast.documentation() }
: { nodeCode: ast, documentation: undefined } : { nodeCode: ast, documentation: undefined }
if (!nodeCode) return if (!nodeCode) return
const pattern = nodeCode instanceof Ast.Assignment ? nodeCode.pattern : undefined const pattern = nodeCode instanceof Ast.Assignment ? nodeCode.pattern : undefined
const rootSpan = nodeCode instanceof Ast.Assignment ? nodeCode.expression : nodeCode const rootSpan = nodeCode instanceof Ast.Assignment ? nodeCode.expression : nodeCode

View File

@ -1,27 +0,0 @@
import { swapKeysAndValues } from '@/util/record'
const mapping: Record<string, string> = {
'\b': '\\b',
'\f': '\\f',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
'\v': '\\v',
'\\': '\\\\',
'"': '\\"',
"'": "\\'",
'`': '``',
}
const reverseMapping = swapKeysAndValues(mapping)
/** Escape a string so it can be safely spliced into an interpolated (`''`) Enso string.
* NOT USABLE to insert into raw strings. Does not include quotes. */
export function escape(string: string) {
return string.replace(/[\0\b\f\n\r\t\v\\"'`]/g, (match) => mapping[match]!)
}
/** The reverse of `escape`: transform the string into human-readable form, not suitable for interpolation. */
export function unescape(string: string) {
return string.replace(/\\[0bfnrtv\\"']|``/g, (match) => reverseMapping[match]!)
}

View File

@ -1,10 +1,43 @@
import { useEvent } from '@/composables/events' import { useEvent } from '@/composables/events'
import type { Ref } from 'vue' import { watchEffect, type Ref } from 'vue'
/** Automatically `blur` the currently active element on any mouse click outside of `root`. /** Automatically `blur` the currently active element on any mouse click outside of `root`.
* It is useful when other elements may capture pointer events, preventing default browser behavior for focus change. */ * It is useful when other elements may capture pointer events, preventing default browser behavior for focus change. */
export function useAutoBlur(root: Ref<HTMLElement | SVGElement | MathMLElement | undefined>) { export function useAutoBlur(root: Ref<HTMLElement | SVGElement | undefined>) {
useEvent(window, 'pointerdown', (event) => blurIfNecessary(root, event), { capture: true }) watchEffect((onCleanup) => {
const element = root.value
if (element) {
autoBlurRoots.add(element)
onCleanup(() => autoBlurRoots.delete(element))
}
})
}
const autoBlurRoots = new Set<HTMLElement | SVGElement | MathMLElement>()
export function registerAutoBlurHandler() {
useEvent(
window,
'pointerdown',
(event) => {
if (
!(event.target instanceof Element) ||
(!(document.activeElement instanceof HTMLElement) &&
!(document.activeElement instanceof SVGElement) &&
!(document.activeElement instanceof MathMLElement))
)
return false
for (const root of autoBlurRoots) {
if (root.contains(document.activeElement) && !root.contains(event.target)) {
document.activeElement.blur()
return true
}
}
return false
},
{ capture: true },
)
} }
/** Returns true if the target of the event is in the DOM subtree of the given `area` element. */ /** Returns true if the target of the event is in the DOM subtree of the given `area` element. */
@ -14,21 +47,3 @@ export function targetIsOutside(
): boolean { ): boolean {
return !!area.value && e.target instanceof Element && !area.value.contains(e.target) return !!area.value && e.target instanceof Element && !area.value.contains(e.target)
} }
/** Internal logic of `useAutoBlur`, useful for direct usage in some cases.
* Returns `true` if `event` does not target `root` and blurs currently active element.
* Otherwise returns `false` and does nothing. */
export function blurIfNecessary(
root: Ref<HTMLElement | SVGElement | MathMLElement | undefined>,
event: MouseEvent,
): boolean {
if (!root.value?.contains(document.activeElement) || !targetIsOutside(event, root)) return false
if (
!(document.activeElement instanceof HTMLElement) &&
!(document.activeElement instanceof SVGElement) &&
!(document.activeElement instanceof MathMLElement)
)
return false
document.activeElement.blur()
return true
}

View File

@ -178,8 +178,8 @@ export class ArgumentApplication {
const argFor = (key: 'lhs' | 'rhs', index: number) => { const argFor = (key: 'lhs' | 'rhs', index: number) => {
const tree = interpreted[key] const tree = interpreted[key]
const info = tryGetIndex(suggestion?.arguments, index) ?? unknownArgInfoNamed(key) const info = tryGetIndex(suggestion?.arguments, index) ?? unknownArgInfoNamed(key)
return tree != null return tree != null ?
? ArgumentAst.WithRetrievedConfig(tree, index, info, kind, widgetCfg) ArgumentAst.WithRetrievedConfig(tree, index, info, kind, widgetCfg)
: ArgumentPlaceholder.WithRetrievedConfig(callId, index, info, kind, false, widgetCfg) : ArgumentPlaceholder.WithRetrievedConfig(callId, index, info, kind, false, widgetCfg)
} }
return new ArgumentApplication( return new ArgumentApplication(
@ -308,9 +308,9 @@ export class ArgumentApplication {
}) })
} else { } else {
const argumentFromDefinition = const argumentFromDefinition =
argumentInCode.argName == null argumentInCode.argName == null ?
? takeNextArgumentFromDefinition() takeNextArgumentFromDefinition()
: takeNamedArgumentFromDefinition(argumentInCode.argName) : takeNamedArgumentFromDefinition(argumentInCode.argName)
const { index, info } = argumentFromDefinition ?? {} const { index, info } = argumentFromDefinition ?? {}
resolvedArgs.push({ resolvedArgs.push({
appTree: argumentInCode.appTree, appTree: argumentInCode.appTree,
@ -318,9 +318,9 @@ export class ArgumentApplication {
argumentInCode.argument, argumentInCode.argument,
index, index,
info ?? info ??
(argumentInCode.argName != null (argumentInCode.argName != null ?
? unknownArgInfoNamed(argumentInCode.argName) unknownArgInfoNamed(argumentInCode.argName)
: undefined), : undefined),
ApplicationKind.Prefix, ApplicationKind.Prefix,
widgetCfg, widgetCfg,
), ),
@ -375,9 +375,9 @@ export class ArgumentApplication {
toWidgetInput(): WidgetInput { toWidgetInput(): WidgetInput {
return { return {
portId: portId:
this.argument instanceof ArgumentAst this.argument instanceof ArgumentAst ?
? this.appTree.id this.appTree.id
: (`app:${this.argument.portId}` as PortId), : (`app:${this.argument.portId}` as PortId),
value: this.appTree, value: this.appTree,
[ArgumentApplicationKey]: this, [ArgumentApplicationKey]: this,
} }

View File

@ -20,11 +20,11 @@ const STRING_TO_BOOLEAN: Record<string, boolean> = {
* a string 'true', 'false', '1', or '0', it is converted to a boolean value. Otherwise, null is * a string 'true', 'false', '1', or '0', it is converted to a boolean value. Otherwise, null is
* returned. */ * returned. */
function parseBoolean(value: unknown): boolean | null { function parseBoolean(value: unknown): boolean | null {
return typeof value === 'boolean' return (
? value typeof value === 'boolean' ? value
: typeof value === 'string' : typeof value === 'string' ? STRING_TO_BOOLEAN[value] ?? null
? STRING_TO_BOOLEAN[value] ?? null
: null : null
)
} }
export interface StringConfig { export interface StringConfig {
@ -69,12 +69,12 @@ export interface Group<T = Required<RawGroup>> extends Config<T> {
} }
export interface Config<T = Required<RawConfig>> { export interface Config<T = Required<RawConfig>> {
options: T extends { options: infer Options extends object } options: T extends { options: infer Options extends object } ?
? { [K in keyof Options]: Option<Options[K]> } { [K in keyof Options]: Option<Options[K]> }
: {} : {}
groups: T extends { groups: infer Groups extends object } groups: T extends { groups: infer Groups extends object } ?
? { [K in keyof Groups]: Group<Groups[K]> } { [K in keyof Groups]: Group<Groups[K]> }
: {} : {}
} }
function loadOption<T>(option: T): Option<T> { function loadOption<T>(option: T): Option<T> {
@ -87,12 +87,14 @@ function loadOption<T>(option: T): Option<T> {
description: String(obj.description ?? ''), description: String(obj.description ?? ''),
defaultDescription: obj.defaultDescription != null ? String(obj.defaultDescription) : undefined, defaultDescription: obj.defaultDescription != null ? String(obj.defaultDescription) : undefined,
value: value:
typeof value === 'string' || (
typeof value === 'number' || typeof value === 'string' ||
typeof value === 'boolean' || typeof value === 'number' ||
(Array.isArray(value) && value.every((item) => typeof item === 'string')) typeof value === 'boolean' ||
? value (Array.isArray(value) && value.every((item) => typeof item === 'string'))
: '', ) ?
value
: '',
primary: Boolean(obj.primary ?? true), primary: Boolean(obj.primary ?? true),
} satisfies Option<RawOption> as any } satisfies Option<RawOption> as any
} }
@ -111,13 +113,13 @@ export function loadConfig<T>(config: T): Config<T> {
} }
return { return {
options: options:
'options' in config && typeof config.options === 'object' && config.options != null 'options' in config && typeof config.options === 'object' && config.options != null ?
? Object.fromEntries(Object.entries(config.options).map(([k, v]) => [k, loadOption(v)])) Object.fromEntries(Object.entries(config.options).map(([k, v]) => [k, loadOption(v)]))
: {}, : {},
groups: groups:
'groups' in config && typeof config.groups === 'object' && config.groups != null 'groups' in config && typeof config.groups === 'object' && config.groups != null ?
? Object.fromEntries(Object.entries(config.groups).map(([k, v]) => [k, loadGroup(v)])) Object.fromEntries(Object.entries(config.groups).map(([k, v]) => [k, loadGroup(v)]))
: {}, : {},
} satisfies Config as any } satisfies Config as any
} }

View File

@ -20,7 +20,7 @@ export function nextEvent<O extends ObservableV2<any>, NAME extends string>(
declare const EVENTS_BRAND: unique symbol declare const EVENTS_BRAND: unique symbol
declare module 'lib0/observable' { declare module 'lib0/observable' {
interface ObservableV2<EVENTS extends { [key: string]: (...arg0: any[]) => void }> { interface ObservableV2<EVENTS extends { [key in keyof EVENTS]: (...arg0: any[]) => void }> {
[EVENTS_BRAND]: EVENTS [EVENTS_BRAND]: EVENTS
} }
} }

View File

@ -1,5 +0,0 @@
export function asNot<Not>(value: any): Excluding<Not, typeof value> {
return value as any
}
type Excluding<Not, Value> = Value extends Not ? never : Value

View File

@ -1,3 +1,5 @@
import { shallowReactive } from 'vue'
let _measureContext: CanvasRenderingContext2D | undefined let _measureContext: CanvasRenderingContext2D | undefined
function getMeasureContext() { function getMeasureContext() {
return (_measureContext ??= document.createElement('canvas').getContext('2d')!) return (_measureContext ??= document.createElement('canvas').getContext('2d')!)
@ -14,11 +16,47 @@ export function getTextWidthBySizeAndFamily(
/** Helper function to get text width. `font` is a CSS font specification as per https://developer.mozilla.org/en-US/docs/Web/CSS/font. */ /** Helper function to get text width. `font` is a CSS font specification as per https://developer.mozilla.org/en-US/docs/Web/CSS/font. */
export function getTextWidthByFont(text: string | null | undefined, font: string) { export function getTextWidthByFont(text: string | null | undefined, font: string) {
if (text == null) { if (text == null || font == '' || !fontReady(font)) {
return 0 return 0
} }
const context = getMeasureContext() const context = getMeasureContext()
context.font = font context.font = font
const metrics = context.measureText(' ' + text) const metrics = context.measureText(text)
return metrics.width return metrics.width
} }
/**
* Stores loading status of queried fonts, so we can make the check synchronous and reactive.
* This is supposed to be global, since the font loading state is scoped to the document and cannot
* revert back to loading (assuming we don't dynamically change existing @font-face definitions to
* point to different URLs, which would be incredibly cursed).
*/
const fontsReady = shallowReactive(new Map())
/**
* Check if given font is ready to use. In case if it is not, the check will automatically register
* a reactive dependency, which will be notified once loading is complete.
* @param font
* @returns
*/
function fontReady(font: string): boolean {
const readyState = fontsReady.get(font)
if (readyState === undefined) {
let syncReady
try {
// This operation can fail if the provided font string is not a valid CSS font specifier.
syncReady = document.fonts.check(font)
} catch (e) {
console.error(e)
// In case of exception, treat the font as if it was loaded. That way we don't attempt loading
// it again, and the browser font fallback logic should still make things behave more or less
// correct.
syncReady = true
}
fontsReady.set(font, syncReady)
if (syncReady) return true
else document.fonts.load(font).then(() => fontsReady.set(font, true))
return false
}
return readyState
}

View File

@ -209,24 +209,21 @@ type NormalizeKeybindSegment = {
[K in KeybindSegment as Lowercase<K>]: K [K in KeybindSegment as Lowercase<K>]: K
} }
type SuggestedKeybindSegment = ModifierPlus | Pointer | Key type SuggestedKeybindSegment = ModifierPlus | Pointer | Key
type AutocompleteKeybind<T extends string, Key extends string = never> = T extends '+' type AutocompleteKeybind<T extends string, Key extends string = never> =
? T T extends '+' ? T
: T extends `${infer First}+${infer Rest}` : T extends `${infer First}+${infer Rest}` ?
? Lowercase<First> extends LowercaseModifier Lowercase<First> extends LowercaseModifier ?
? `${NormalizeKeybindSegment[Lowercase<First>] & string}+${AutocompleteKeybind<Rest>}` `${NormalizeKeybindSegment[Lowercase<First>] & string}+${AutocompleteKeybind<Rest>}`
: Lowercase<First> extends LowercasePointer | LowercaseKey : Lowercase<First> extends LowercasePointer | LowercaseKey ?
? AutocompleteKeybind<Rest, NormalizeKeybindSegment[Lowercase<First>] & string> AutocompleteKeybind<Rest, NormalizeKeybindSegment[Lowercase<First>] & string>
: `${Modifier}+${AutocompleteKeybind<Rest>}` : `${Modifier}+${AutocompleteKeybind<Rest>}`
: T extends '' : T extends '' ? SuggestedKeybindSegment
? SuggestedKeybindSegment : Lowercase<T> extends LowercasePointer | LowercaseKey ? NormalizeKeybindSegment[Lowercase<T>]
: Lowercase<T> extends LowercasePointer | LowercaseKey : Lowercase<T> extends LowercaseModifier ?
? NormalizeKeybindSegment[Lowercase<T>] [Key] extends [never] ?
: Lowercase<T> extends LowercaseModifier `${NormalizeKeybindSegment[Lowercase<T>] & string}+${SuggestedKeybindSegment}`
? [Key] extends [never]
? `${NormalizeKeybindSegment[Lowercase<T>] & string}+${SuggestedKeybindSegment}`
: `${NormalizeKeybindSegment[Lowercase<T>] & string}+${Key}` : `${NormalizeKeybindSegment[Lowercase<T>] & string}+${Key}`
: [Key] extends [never] : [Key] extends [never] ? SuggestedKeybindSegment
? SuggestedKeybindSegment
: Key : Key
type AutocompleteKeybinds<T extends string[]> = { type AutocompleteKeybinds<T extends string[]> = {
@ -235,8 +232,9 @@ type AutocompleteKeybinds<T extends string[]> = {
// `never extends T ? Result : InferenceSource` is a trick to unify `T` with the actual type of the // `never extends T ? Result : InferenceSource` is a trick to unify `T` with the actual type of the
// argument. // argument.
type Keybinds<T extends Record<K, string[]>, K extends keyof T = keyof T> = never extends T type Keybinds<T extends Record<K, string[]>, K extends keyof T = keyof T> =
? { never extends T ?
{
[K in keyof T]: AutocompleteKeybinds<T[K]> [K in keyof T]: AutocompleteKeybinds<T[K]>
} }
: T : T
@ -353,9 +351,9 @@ export function defineKeybinds<
return (event, stopAndPrevent = true) => { return (event, stopAndPrevent = true) => {
const eventModifierFlags = modifierFlagsForEvent(event) const eventModifierFlags = modifierFlagsForEvent(event)
const keybinds = const keybinds =
event instanceof KeyboardEvent event instanceof KeyboardEvent ?
? keyboardShortcuts[event.key.toLowerCase() as Key_]?.[eventModifierFlags] keyboardShortcuts[event.key.toLowerCase() as Key_]?.[eventModifierFlags]
: mouseShortcuts[buttonFlagsForEvent(event)]?.[eventModifierFlags] : mouseShortcuts[buttonFlagsForEvent(event)]?.[eventModifierFlags]
let handle = handlers[DefaultHandler] let handle = handlers[DefaultHandler]
if (keybinds != null) { if (keybinds != null) {
for (const bindingName in handlers) { for (const bindingName in handlers) {

View File

@ -42,8 +42,9 @@ watchEffect(async (onCleanup) => {
const prefixLength = props.prefix?.length ?? 0 const prefixLength = props.prefix?.length ?? 0
const directory = maybeDirectory const directory = maybeDirectory
const ls = await projectStore.lsRpcConnection const ls = await projectStore.lsRpcConnection
const maybeProjectRoot = (await projectStore.contentRoots).find((root) => root.type === 'Project') const maybeProjectRoot = (await projectStore.contentRoots).find(
?.id (root) => root.type === 'Project',
)?.id
if (!maybeProjectRoot) return if (!maybeProjectRoot) return
const projectRoot = maybeProjectRoot const projectRoot = maybeProjectRoot
async function walkFiles( async function walkFiles(

View File

@ -18,6 +18,7 @@
"outDir": "../../node_modules/.cache/tsc", "outDir": "../../node_modules/.cache/tsc",
"baseUrl": ".", "baseUrl": ".",
"noEmit": true, "noEmit": true,
"strict": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,

View File

@ -22,6 +22,7 @@
"outDir": "../../node_modules/.cache/tsc", "outDir": "../../node_modules/.cache/tsc",
"baseUrl": ".", "baseUrl": ".",
"noEmit": true, "noEmit": true,
"strict": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,

View File

@ -30,17 +30,16 @@ export default defineConfig({
}, },
resolve: { resolve: {
alias: { alias: {
...(process.env.E2E === 'true' ...(process.env.E2E === 'true' ?
? { '/src/main.ts': fileURLToPath(new URL('./e2e/main.ts', import.meta.url)) } { '/src/main.ts': fileURLToPath(new URL('./e2e/main.ts', import.meta.url)) }
: {}), : {}),
shared: fileURLToPath(new URL('./shared', import.meta.url)), shared: fileURLToPath(new URL('./shared', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)),
}, },
}, },
define: { define: {
REDIRECT_OVERRIDE: IS_CLOUD_BUILD REDIRECT_OVERRIDE:
? 'undefined' IS_CLOUD_BUILD ? 'undefined' : JSON.stringify(`http://localhost:${localServerPort}`),
: JSON.stringify(`http://localhost:${localServerPort}`),
IS_CLOUD_BUILD: JSON.stringify(IS_CLOUD_BUILD), IS_CLOUD_BUILD: JSON.stringify(IS_CLOUD_BUILD),
PROJECT_MANAGER_URL: JSON.stringify(projectManagerUrl), PROJECT_MANAGER_URL: JSON.stringify(projectManagerUrl),
IS_DEV_MODE: JSON.stringify(process.env.NODE_ENV === 'development'), IS_DEV_MODE: JSON.stringify(process.env.NODE_ENV === 'development'),

Some files were not shown because too many files have changed in this diff Show More