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[0].length
: parsedData.indices && parsedData.indices.length > 0
? parsedData.indices[0].length
: 0
? parsedData.indices[0].length
: 0
rowData = Array.apply(null, Array(rows)).map((_, i) => {
const row = {}
const shift = parsedData.indices ? parsedData.indices.length : 0

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { expect, type Page } from '@playwright/test'
import * as customExpect from './customExpect'
import { type Page } from '@playwright/test'
import { expect } from './customExpect'
import * as locate from './locate'
import { graphNodeByBinding } from './locate'
@ -12,14 +12,25 @@ export async function goToGraph(page: Page) {
await page.goto('/')
await expect(page.locator('.App')).toBeVisible()
// Wait until nodes are loaded.
await customExpect.toExist(locate.graphNode(page))
await expect(locate.graphNode(page)).toExist()
// 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(
'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 ===
// =================

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

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 os from 'os'
import * as actions from './actions'
import * as customExpect from './customExpect'
import { expect } from './customExpect'
import * as locate from './locate'
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()
async function expectAndCancelBrowser(expectedInput: string) {
await customExpect.toExist(locate.componentBrowser(page))
await customExpect.toExist(locate.componentBrowserEntry(page))
await expect(locate.componentBrowser(page)).toExist()
await expect(locate.componentBrowserEntry(page)).toExist()
await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue(expectedInput)
await page.keyboard.press('Escape')
await expect(locate.componentBrowser(page)).not.toBeVisible()
@ -79,7 +79,7 @@ test('Accepting suggestion', async ({ page }) => {
'.',
'read_text',
])
await customExpect.toBeSelected(locate.graphNode(page).last())
await expect(locate.graphNode(page).last()).toBeSelected()
// Clicking at highlighted entry
nodeCount = await locate.graphNode(page).count()
@ -93,7 +93,7 @@ test('Accepting suggestion', async ({ page }) => {
'.',
'read',
])
await customExpect.toBeSelected(locate.graphNode(page).last())
await expect(locate.graphNode(page).last()).toBeSelected()
// Accepting with Enter
nodeCount = await locate.graphNode(page).count()
@ -107,7 +107,7 @@ test('Accepting suggestion', async ({ page }) => {
'.',
'read',
])
await customExpect.toBeSelected(locate.graphNode(page).last())
await expect(locate.graphNode(page).last()).toBeSelected()
})
test('Accepting any written input', async ({ page }) => {
@ -127,14 +127,14 @@ test('Filling input with suggestions', async ({ page }) => {
// Entering module
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(
'Standard.Base.Data.',
)
// Applying suggestion
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(
'Standard.Base.Data.read ',
)
@ -167,8 +167,8 @@ test('Editing existing nodes', async ({ page }) => {
await expect(input).toHaveValue(`Data.read ${ADDED_PATH}`)
await page.keyboard.press('Enter')
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(node.locator('.WidgetToken')).toHaveText(['Data', '.', 'read'])
await expect(node.locator('.WidgetText input')).toHaveValue(ADDED_PATH)
await expect(node.locator('.WidgetToken')).toHaveText(['Data', '.', 'read', '"', '"'])
await expect(node.locator('.WidgetText input')).toHaveValue(ADDED_PATH.replaceAll('"', ''))
// Edit again, using "edit" button
await locate.graphNodeIcon(node).click()
@ -187,12 +187,12 @@ test('Visualization preview: type-based visualization selection', async ({ page
await actions.goToGraph(page)
const nodeCount = await locate.graphNode(page).count()
await locate.addNewNodeButton(page).click()
await customExpect.toExist(locate.componentBrowser(page))
await customExpect.toExist(locate.componentBrowserEntry(page))
await expect(locate.componentBrowser(page)).toExist()
await expect(locate.componentBrowserEntry(page)).toExist()
const input = locate.componentBrowserInput(page).locator('input')
await input.fill('4')
await expect(input).toHaveValue('4')
await customExpect.toExist(locate.jsonVisualization(page))
await expect(locate.jsonVisualization(page)).toExist()
await input.fill('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
@ -207,12 +207,12 @@ test('Visualization preview: user visualization selection', async ({ page }) =>
await actions.goToGraph(page)
const nodeCount = await locate.graphNode(page).count()
await locate.addNewNodeButton(page).click()
await customExpect.toExist(locate.componentBrowser(page))
await customExpect.toExist(locate.componentBrowserEntry(page))
await expect(locate.componentBrowser(page)).toExist()
await expect(locate.componentBrowserEntry(page)).toExist()
const input = locate.componentBrowserInput(page).locator('input')
await input.fill('4')
await expect(input).toHaveValue('4')
await customExpect.toExist(locate.jsonVisualization(page))
await expect(locate.jsonVisualization(page)).toExist()
await locate.showVisualizationSelectorButton(page).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

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,
* is an attached and visible DOM node. */
export function toExist(locator: Locator) {
// Counter-intuitive, but correct:
// https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-visible
return expect(locator.first()).toBeVisible()
}
export const expect = baseExpect.extend({
/** Ensures that at least one of the elements that the Locator points to,
* is an attached and visible DOM node. */
async toExist(locator: Locator) {
// Counter-intuitive, but correct:
// 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) {
return expect(locator).toHaveClass(/(?<=^| )selected(?=$| )/)
}
const message = () =>
this.utils.matcherHint(assertionName, locator, '', {
isNot: this.isNot,
})
export module not {
export function toBeSelected(locator: Locator) {
return expect(locator).not.toHaveClass(/(?<=^| )selected(?=$| )/)
}
}
return {
message,
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 customExpect from './customExpect'
import { expect } from './customExpect'
import * as locate from './locate'
import { graphNodeByBinding } from './locate'
@ -17,11 +17,11 @@ test('Load Fullscreen Visualisation', async ({ page }) => {
const fullscreenButton = locate.enterFullscreenButton(aggregatedNode)
await fullscreenButton.click()
const vis = locate.jsonVisualization(page)
await customExpect.toExist(vis)
await customExpect.toExist(locate.exitFullscreenButton(page))
await expect(vis).toExist()
await expect(locate.exitFullscreenButton(page)).toExist()
const visBoundingBox = await vis.boundingBox()
expect(visBoundingBox!.height).toBe(808)
expect(visBoundingBox!.width).toBe(1920)
expect(visBoundingBox?.height).toBeGreaterThan(600)
expect(visBoundingBox?.width).toBe(1920)
const jsonContent = await vis.textContent().then((text) => JSON.parse(text!))
expect(jsonContent).toEqual({
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 customExpect from './customExpect'
import { expect } from './customExpect'
import * as locate from './locate'
test('node can open and load visualization', async ({ page }) => {
await actions.goToGraph(page)
const node = locate.graphNode(page).last()
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 customExpect.toExist(locate.anyVisualization(page))
await expect(locate.anyVisualization(page)).toExist()
await locate.showVisualizationSelectorButton(page).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.
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 customExpect from './customExpect'
import { expect } from './customExpect'
import * as locate from './locate'
test('graph can open and render nodes', async ({ page }) => {
await actions.goToGraph(page)
await customExpect.toExist(locate.graphEditor(page))
await customExpect.toExist(locate.graphNode(page))
await expect(locate.graphEditor(page)).toExist()
await expect(locate.graphNode(page)).toExist()
// check simple node's content (without input widgets)
const sumNode = locate.graphNodeByBinding(page, 'sum')

View File

@ -132,10 +132,9 @@ export function graphNodeIcon(node: Node) {
// === Data locators ===
type SanitizeClassName<T extends string> = T extends `${infer A}.${infer B}`
? SanitizeClassName<`${A}${B}`>
: T extends `${infer A} ${infer B}`
? SanitizeClassName<`${A}${B}`>
type SanitizeClassName<T extends string> =
T extends `${infer A}.${infer B}` ? SanitizeClassName<`${A}${B}`>
: T extends `${infer A} ${infer B}` ? SanitizeClassName<`${A}${B}`>
: 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 { createApp, ref } from 'vue'
import { mockDataHandler, mockLSHandler } from '../mock/engine'
import '../src/assets/base.css'
import '../src/assets/main.css'
import { provideGuiConfig } from '../src/providers/guiConfig'
import { provideVisualizationConfig } from '../src/providers/visualizationConfig'
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 * as actions from './actions'
import * as customExpect from './customExpect'
import { expect } from './customExpect'
import * as locate from './locate'
test('Selecting nodes by click', async ({ page }) => {
await actions.goToGraph(page)
const node1 = locate.graphNodeByBinding(page, 'five')
const node2 = locate.graphNodeByBinding(page, 'ten')
await customExpect.not.toBeSelected(node1)
await customExpect.not.toBeSelected(node2)
await expect(node1).not.toBeSelected()
await expect(node2).not.toBeSelected()
await locate.graphNodeIcon(node1).click()
await customExpect.toBeSelected(node1)
await customExpect.not.toBeSelected(node2)
await expect(node1).toBeSelected()
await expect(node2).not.toBeSelected()
await locate.graphNodeIcon(node2).click()
await customExpect.not.toBeSelected(node1)
await customExpect.toBeSelected(node2)
await expect(node1).not.toBeSelected()
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 customExpect.toBeSelected(node1)
await customExpect.toBeSelected(node2)
await expect(node1).toBeSelected()
await expect(node2).toBeSelected()
await locate.graphNodeIcon(node2).click()
await customExpect.not.toBeSelected(node1)
await customExpect.toBeSelected(node2)
await expect(node1).not.toBeSelected()
await expect(node2).toBeSelected()
await page.mouse.click(200, 200)
await customExpect.not.toBeSelected(node1)
await customExpect.not.toBeSelected(node2)
await page.mouse.click(600, 200)
await expect(node1).not.toBeSelected()
await expect(node2).not.toBeSelected()
})
test('Selecting nodes by area drag', async ({ page }) => {
await actions.goToGraph(page)
const node1 = locate.graphNodeByBinding(page, 'five')
const node2 = locate.graphNodeByBinding(page, 'ten')
await customExpect.not.toBeSelected(node1)
await customExpect.not.toBeSelected(node2)
await expect(node1).not.toBeSelected()
await expect(node2).not.toBeSelected()
const node1BBox = await node1.locator('.selection').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 expect(page.locator('.SelectionBrush')).toBeVisible()
await page.mouse.move(node2BBox.x + node2BBox.width, node2BBox.y + node2BBox.height)
await customExpect.toBeSelected(node1)
await customExpect.toBeSelected(node2)
await expect(node1).toBeSelected()
await expect(node2).toBeSelected()
await page.mouse.up()
await customExpect.toBeSelected(node1)
await customExpect.toBeSelected(node2)
await expect(node1).toBeSelected()
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 customExpect from './customExpect'
import { expect } from './customExpect'
import { mockExpressionUpdate } from './expressionUpdates'
import * as locate from './locate'
import { graphNodeByBinding } from './locate'
@ -26,7 +26,7 @@ test('Load Table Visualisation', async ({ page }) => {
await page.keyboard.press('Space')
await page.waitForTimeout(1000)
const tableVisualization = locate.tableVisualization(page)
await customExpect.toExist(tableVisualization)
await expect(tableVisualization).toExist()
await expect(tableVisualization).toContainText('10 rows.')
await expect(tableVisualization).toContainText('0,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 { expect } from './customExpect'
import { mockMethodCallInfo } from './expressionUpdates'
import * as locate from './locate'
@ -32,7 +33,7 @@ test('Widget in plain AST', async ({ page }) => {
const numberNode = locate.graphNodeByBinding(page, 'five')
const numberWidget = numberNode.locator('.WidgetNumber')
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 listWidget = listNode.locator('.WidgetVector')
@ -41,7 +42,7 @@ test('Widget in plain AST', async ({ page }) => {
const textNode = locate.graphNodeByBinding(page, 'text')
const textWidget = textNode.locator('.WidgetText')
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 }) => {
@ -71,7 +72,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
await onProblemsArg.click()
await dropDown.expectVisibleWithOptions(page, ['Ignore', 'Report_Warning', 'Report_Error'])
await dropDown.clickOption(page, 'Report_Error')
await expect(onProblemsArg.locator('.WidgetToken')).toHaveText([
await expect(onProblemsArg.locator('.WidgetToken')).toContainText([
'Problem_Behavior',
'.',
'Report_Error',
@ -89,7 +90,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
await page.getByText('Report_Error').click()
await dropDown.expectVisibleWithOptions(page, ['Ignore', 'Report_Warning', 'Report_Error'])
await dropDown.clickOption(page, 'Report_Warning')
await expect(onProblemsArg.locator('.WidgetToken')).toHaveText([
await expect(onProblemsArg.locator('.WidgetToken')).toContainText([
'Problem_Behavior',
'.',
'Report_Warning',
@ -101,7 +102,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
await expect(page.locator('.dropdownContainer')).toBeVisible()
await dropDown.expectVisibleWithOptions(page, ['"File 1"', '"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)
await mockMethodCallInfo(page, 'data', {
@ -115,7 +116,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
await page.getByText('path').click()
await dropDown.expectVisibleWithOptions(page, ['"File 1"', '"File 2"'])
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 }) => {
@ -142,7 +143,11 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
// Add first aggregate
const columnsArg = argumentNames.filter({ has: page.getByText('columns') })
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(
page,
{
@ -164,7 +169,7 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
await firstItem.click()
await dropDown.expectVisibleWithOptions(page, ['Group_By', 'Count', 'Count_Distinct'])
await dropDown.clickOption(page, 'Count_Distinct')
await expect(columnsArg.locator('.WidgetToken')).toHaveText([
await expect(columnsArg.locator('.WidgetToken')).toContainText([
'Aggregate_Column',
'.',
'Count_Distinct',
@ -190,16 +195,16 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
await columnArg.click()
await dropDown.expectVisibleWithOptions(page, ['"column 1"', '"column 2"'])
await dropDown.clickOption(page, '"column 1"')
await expect(columnsArg.locator('.WidgetToken')).toHaveText([
await expect(columnsArg.locator('.WidgetToken')).toContainText([
'Aggregate_Column',
'.',
'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
await columnsArg.locator('.add-item').click()
await expect(columnsArg.locator('.WidgetToken')).toHaveText([
await expect(columnsArg.locator('.WidgetToken')).toContainText([
'Aggregate_Column',
'.',
'Count_Distinct',
@ -229,8 +234,12 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
await secondColumnArg.click()
await dropDown.expectVisibleWithOptions(page, ['"column 1"', '"column 2"'])
await dropDown.clickOption(page, '"column 2"')
await expect(secondItem.locator('.WidgetToken')).toHaveText(['Aggregate_Column', '.', 'Group_By'])
await expect(secondItem.locator('.EnsoTextInputWidget > input').first()).toHaveValue('"column 2"')
await expect(secondItem.locator('.WidgetToken')).toContainText([
'Aggregate_Column',
'.',
'Group_By',
])
await expect(secondItem.locator('.WidgetText > input').first()).toHaveValue('column 2')
// Switch aggregates
//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()
// await page.mouse.up()
// await expect(columnsArg.locator('.WidgetToken')).toHaveText([
// await expect(columnsArg.locator('.WidgetToken')).toContainText([
// 'Aggregate_Column',
// '.',
// 'Group_By',

View File

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

View File

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

View File

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

View File

@ -62,13 +62,13 @@
"lib0": "^0.2.85",
"magic-string": "^0.30.3",
"murmurhash": "^2.0.1",
"pinia": "^2.1.6",
"pinia": "^2.1.7",
"postcss-inline-svg": "^6.0.0",
"postcss-nesting": "^12.0.1",
"rimraf": "^5.0.5",
"semver": "^7.5.4",
"sucrase": "^3.34.0",
"vue": "^3.3.4",
"vue": "^3.4.19",
"ws": "^8.13.0",
"y-codemirror.next": "^0.3.2",
"y-protocols": "^1.0.5",
@ -79,9 +79,9 @@
},
"devDependencies": {
"@danmarshall/deckgl-typings": "^4.9.28",
"@eslint/eslintrc": "^2.1.2",
"@eslint/js": "^8.49.0",
"@histoire/plugin-vue": "^0.17.1",
"@eslint/eslintrc": "^3.0.2",
"@eslint/js": "^8.57.0",
"@histoire/plugin-vue": "^0.17.12",
"@open-rpc/server-js": "^1.9.4",
"@playwright/test": "^1.40.0",
"@rushstack/eslint-patch": "^1.3.2",
@ -92,45 +92,45 @@
"@types/hash-sum": "^1.0.0",
"@types/jsdom": "^21.1.1",
"@types/mapbox-gl": "^2.7.13",
"@types/node": "^18.17.5",
"@types/node": "^20.11.21",
"@types/shuffle-seed": "^1.1.0",
"@types/unbzip2-stream": "^1.4.3",
"@types/wicg-file-system-access": "^2023.10.2",
"@types/ws": "^8.5.5",
"@vitejs/plugin-react": "^4.0.4",
"@vitejs/plugin-vue": "^4.3.1",
"@vitest/coverage-v8": "^0.34.6",
"@vitejs/plugin-vue": "^5.0.4",
"@vitest/coverage-v8": "^1.3.1",
"@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/test-utils": "^2.4.1",
"@vue/tsconfig": "^0.4.0",
"@vue/test-utils": "^2.4.4",
"@vue/tsconfig": "^0.5.1",
"change-case": "^4.1.2",
"cross-env": "^7.0.3",
"css.escape": "^1.5.1",
"d3": "^7.4.0",
"esbuild": "^0.19.3",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.16.1",
"eslint-plugin-vue": "^9.22.0",
"floating-vue": "^2.0.0-beta.24",
"hash-wasm": "^4.10.0",
"histoire": "^0.17.2",
"jsdom": "^22.1.0",
"playwright": "^1.39.0",
"postcss-nesting": "^12.0.1",
"prettier": "^3.0.0",
"prettier-plugin-organize-imports": "^3.2.3",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"shuffle-seed": "^1.1.6",
"sql-formatter": "^13.0.0",
"tailwindcss": "^3.2.7",
"tar": "^6.2.0",
"tsx": "^3.12.6",
"tsx": "^4.7.1",
"typescript": "~5.2.2",
"unbzip2-stream": "^1.4.3",
"vite": "^4.4.9",
"vite-plugin-inspect": "^0.7.38",
"vitest": "^0.34.2",
"vitest": "^1.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,
trace: 'retain-on-failure',
viewport: { width: 1920, height: 1600 },
...(DEBUG
? {}
: {
launchOptions: {
ignoreDefaultArgs: ['--headless'],
args: [
// Much closer to headful Chromium than classic headless.
'--headless=new',
// Required for `backdrop-filter: blur` to work.
'--use-angle=swiftshader',
// FIXME: `--disable-gpu` disables `backdrop-filter: blur`, which is not handled by
// the software (CPU) compositor. This SHOULD be fixed eventually, but this flag
// MUST stay as CI does not have a GPU.
'--disable-gpu',
// Fully disable GPU process.
'--disable-software-rasterizer',
// Disable text subpixel antialiasing.
'--font-render-hinting=none',
'--disable-skia-runtime-opts',
'--disable-system-font-check',
'--disable-font-subpixel-positioning',
'--disable-lcd-text',
],
},
}),
...(DEBUG ?
{}
: {
launchOptions: {
ignoreDefaultArgs: ['--headless'],
args: [
// Much closer to headful Chromium than classic headless.
'--headless=new',
// Required for `backdrop-filter: blur` to work.
'--use-angle=swiftshader',
// FIXME: `--disable-gpu` disables `backdrop-filter: blur`, which is not handled by
// the software (CPU) compositor. This SHOULD be fixed eventually, but this flag
// MUST stay as CI does not have a GPU.
'--disable-gpu',
// Fully disable GPU process.
'--disable-software-rasterizer',
// Disable text subpixel antialiasing.
'--font-render-hinting=none',
'--disable-skia-runtime-opts',
'--disable-system-font-check',
'--disable-font-subpixel-positioning',
'--disable-lcd-text',
],
},
}),
},
// projects: [
// {
@ -112,9 +112,9 @@ export default defineConfig({
E2E: 'true',
},
command:
process.env.CI || process.env.PROD
? `npx vite build && npx vite preview --port ${PORT} --strictPort`
: `npx vite dev --port ${PORT}`,
process.env.CI || process.env.PROD ?
`npx vite build && npx vite preview --port ${PORT} --strictPort`
: `npx vite dev --port ${PORT}`,
// Build from scratch apparently can take a while on CI machines.
timeout: 120 * 1000,
port: PORT,

View File

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

View File

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

View File

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

View File

@ -56,8 +56,9 @@ export class SourceDocument {
}
}
if (printed.code !== this.text_) {
const textEdits = update.updateRoots.has(root.id)
? [{ range: [0, this.text_.length] satisfies SourceRange, insert: printed.code }]
const textEdits =
update.updateRoots.has(root.id) ?
[{ range: [0, this.text_.length] satisfies SourceRange, insert: printed.code }]
: subtreeTextEdits
this.text_ = printed.code
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 { AstId, Owned } from '.'
import type { AstId, NodeChild, Owned } from '.'
import { Ast, newExternalId } from '.'
import { assert } from '../util/assert'
import type { ExternalId } from '../yjsModel'
@ -11,6 +11,10 @@ export function isToken(t: unknown): t is Token {
return t instanceof Token
}
export function isTokenChild(child: NodeChild<unknown>): child is NodeChild<Token> {
return isToken(child.node)
}
declare const brandTokenId: unique symbol
export type TokenId = ExternalId & { [brandTokenId]: never }

View File

@ -16,8 +16,10 @@ import {
ROOT_ID,
Token,
asOwned,
escapeTextLiteral,
isIdentifier,
isToken,
isTokenChild,
isTokenId,
newExternalId,
parentId,
@ -1194,24 +1196,6 @@ export interface MutableImport extends Import, 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 {
token: any
ast: any
@ -1247,6 +1231,16 @@ function rawToConcrete(module: Module): RefMap<RawRefs, ConcreteRefs> {
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> {
type: 'token'
readonly token: T['token']
@ -1335,19 +1329,22 @@ export class TextLiteral extends Ast {
return asOwned(new MutableTextLiteral(module, fields))
}
static new(rawText: string, module: MutableModule): Owned<MutableTextLiteral> {
const escaped = escape(rawText)
static new(rawText: string, module?: MutableModule): Owned<MutableTextLiteral> {
const escaped = escapeTextLiteral(rawText)
const parsed = parse(`'${escaped}'`, module)
if (!(parsed instanceof MutableTextLiteral)) {
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 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)
}
@ -1358,10 +1355,63 @@ export class TextLiteral extends Ast {
for (const e of elements) yield* fieldConcreteChildren(e)
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 {
declare readonly module: MutableModule
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 {}
applyMixins(MutableTextLiteral, [MutableAst])
@ -1953,8 +2003,9 @@ function lineFromRaw(raw: RawBlockLine, module: Module): BlockLine {
const expression = raw.expression ? module.get(raw.expression.node) : undefined
return {
newline: { ...raw.newline, node: module.getToken(raw.newline.node) },
expression: expression
? {
expression:
expression ?
{
whitespace: raw.expression?.whitespace,
node: expression,
}
@ -1966,8 +2017,9 @@ function ownedLineFromRaw(raw: RawBlockLine, module: MutableModule): OwnedBlockL
const expression = raw.expression ? module.get(raw.expression.node).takeIfParented() : undefined
return {
newline: { ...raw.newline, node: module.getToken(raw.newline.node) },
expression: expression
? {
expression:
expression ?
{
whitespace: raw.expression?.whitespace,
node: expression,
}
@ -1978,8 +2030,9 @@ function ownedLineFromRaw(raw: RawBlockLine, module: MutableModule): OwnedBlockL
function lineToRaw(line: OwnedBlockLine, module: MutableModule, block: AstId): RawBlockLine {
return {
newline: line.newline ?? unspaced(Token.new('\n', RawAst.Token.Type.Newline)),
expression: line.expression
? {
expression:
line.expression ?
{
whitespace: line.expression?.whitespace,
node: claimChild(module, line.expression.node, block),
}
@ -2084,40 +2137,24 @@ export class MutableWildcard extends Wildcard implements MutableAst {
export interface MutableWildcard extends Wildcard, MutableAst {}
applyMixins(MutableWildcard, [MutableAst])
export type Mutable<T extends Ast = Ast> = T extends App
? MutableApp
: T extends Assignment
? MutableAssignment
: T extends BodyBlock
? MutableBodyBlock
: T extends Documented
? MutableDocumented
: T extends Function
? MutableFunction
: T extends Generic
? MutableGeneric
: T extends Group
? MutableGroup
: T extends Ident
? MutableIdent
: T extends Import
? MutableImport
: 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
export type Mutable<T extends Ast = Ast> =
T extends App ? MutableApp
: T extends Assignment ? MutableAssignment
: T extends BodyBlock ? MutableBodyBlock
: T extends Documented ? MutableDocumented
: T extends Function ? MutableFunction
: T extends Generic ? MutableGeneric
: T extends Group ? MutableGroup
: T extends Ident ? MutableIdent
: T extends Import ? MutableImport
: 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
export function materializeMutable(module: MutableModule, fields: FixedMap<AstFields>): MutableAst {
@ -2296,15 +2333,27 @@ function concreteChild(
}
type StrictIdentLike = Identifier | IdentifierToken
function toIdentStrict(ident: StrictIdentLike): IdentifierToken {
return isToken(ident) ? ident : (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierToken)
function toIdentStrict(ident: StrictIdentLike): 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
function toIdent(ident: IdentLike): IdentifierOrOperatorIdentifierToken {
return isToken(ident)
? ident
: (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierOrOperatorIdentifierToken)
function toIdent(ident: IdentLike): IdentifierOrOperatorIdentifierToken
function toIdent(ident: IdentLike | undefined): IdentifierOrOperatorIdentifierToken | undefined
function toIdent(ident: IdentLike | undefined): IdentifierOrOperatorIdentifierToken | undefined {
return (
ident ?
isToken(ident) ? ident
: (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierOrOperatorIdentifierToken)
: undefined
)
}
function makeEquals(): Token {

View File

@ -668,10 +668,9 @@ export class ByteBuffer {
offset<T extends OffsetConstraint>(bbPos: number, vtableOffset: number): Offset<T> {
const vtable = bbPos - this.view.getInt32(bbPos, true)
return (
vtableOffset < this.view.getInt16(vtable, true)
? this.view.getInt16(vtable + vtableOffset, true)
: 0
) as Offset<T>
vtableOffset < this.view.getInt16(vtable, true) ?
this.view.getInt16(vtable + vtableOffset, true)
: 0) as Offset<T>
}
union(t: Table, offset: number): Table {
@ -1311,8 +1310,8 @@ export class VisualizationUpdate implements Table {
visualizationContext(obj?: VisualizationContext): VisualizationContext | null {
const offset = this.bb.offset(this.bbPos, 4)
return offset
? (obj ?? new VisualizationContext()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
return offset ?
(obj ?? new VisualizationContext()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null
}
@ -1328,8 +1327,8 @@ export class VisualizationUpdate implements Table {
dataArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 6)
return offset
? new Uint8Array(
return offset ?
new Uint8Array(
this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
this.bb.vectorLength(this.bbPos + offset),
@ -1414,8 +1413,8 @@ export class Path implements Table {
rawSegments(index: number): ArrayBuffer {
const offset = this.bb.offset(this.bbPos, 6)
return offset
? this.bb.rawMessage(this.bb.vector(this.bbPos + offset) + index * 4)
return offset ?
this.bb.rawMessage(this.bb.vector(this.bbPos + offset) + index * 4)
: new Uint8Array()
}
@ -1517,8 +1516,8 @@ export class WriteFileCommand implements Table {
contentsArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 6)
return offset
? new Uint8Array(
return offset ?
new Uint8Array(
this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
this.bb.vectorLength(this.bbPos + offset),
@ -1662,8 +1661,8 @@ export class FileContentsReply implements Table {
contentsArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 4)
return offset
? new Uint8Array(
return offset ?
new Uint8Array(
this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
this.bb.vectorLength(this.bbPos + offset),
@ -1761,8 +1760,8 @@ export class WriteBytesCommand implements Table {
bytesArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 10)
return offset
? new Uint8Array(
return offset ?
new Uint8Array(
this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
this.bb.vectorLength(this.bbPos + offset),
@ -1855,8 +1854,8 @@ export class WriteBytesReply implements Table {
checksum(obj?: EnsoDigest): EnsoDigest | null {
const offset = this.bb.offset(this.bbPos, 4)
return offset
? (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
return offset ?
(obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null
}
@ -1913,8 +1912,8 @@ export class ReadBytesCommand implements Table {
segment(obj?: FileSegment): FileSegment | null {
const offset = this.bb.offset(this.bbPos, 4)
return offset
? (obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
return offset ?
(obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null
}
@ -1962,8 +1961,8 @@ export class ReadBytesReply implements Table {
checksum(obj?: EnsoDigest): EnsoDigest | null {
const offset = this.bb.offset(this.bbPos, 4)
return offset
? (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
return offset ?
(obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null
}
@ -1979,8 +1978,8 @@ export class ReadBytesReply implements Table {
bytesArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 6)
return offset
? new Uint8Array(
return offset ?
new Uint8Array(
this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
this.bb.vectorLength(this.bbPos + offset),
@ -2064,8 +2063,8 @@ export class ChecksumBytesCommand implements Table {
segment(obj?: FileSegment): FileSegment | null {
const offset = this.bb.offset(this.bbPos, 4)
return offset
? (obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
return offset ?
(obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null
}
@ -2122,8 +2121,8 @@ export class ChecksumBytesReply implements Table {
checksum(obj?: EnsoDigest): EnsoDigest | null {
const offset = this.bb.offset(this.bbPos, 4)
return offset
? (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
return offset ?
(obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
: null
}
@ -2181,8 +2180,8 @@ export class EnsoDigest implements Table {
bytesArray(): Uint8Array | null {
const offset = this.bb.offset(this.bbPos, 4)
return offset
? new Uint8Array(
return offset ?
new Uint8Array(
this.bb.view.buffer,
this.bb.view.byteOffset + this.bb.vector(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 messagePrefix = message ? message + ' ' : ''
const elementRepresentation =
convertedArray.length > 5
? `${convertedArray.slice(0, 5).join(', ')},...`
: convertedArray.join(', ')
convertedArray.length > 5 ?
`${convertedArray.slice(0, 5).join(', ')},...`
: convertedArray.join(', ')
assert(
convertedArray.length === length,
`${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
/** Whether to connect to other peers or not */
this.shouldConnect = false
this._checkInterval = this.sendPings
? setInterval(() => {
this._checkInterval =
this.sendPings ?
setInterval(() => {
if (
this.connected &&
messageReconnectTimeout < time.getUnixTime() - this.lastMessageReceived

View File

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

View File

@ -15,6 +15,7 @@
--color-frame-bg: rgb(255 255 255 / 0.3);
--color-frame-selected-bg: rgb(255 255 255 / 0.7);
--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-port-connected: rgb(255 255 255 / 0.15);

View File

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

View File

@ -227,9 +227,8 @@ function updateListener() {
commitPendingChanges()
currentModule = newModule
} else if (transaction.docChanged && currentModule) {
pendingChanges = pendingChanges
? pendingChanges.compose(transaction.changes)
: transaction.changes
pendingChanges =
pendingChanges ? pendingChanges.compose(transaction.changes) : transaction.changes
// 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.
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.
watch([viewInitialized, () => projectStore.diagnostics], ([ready, diagnostics]) => {
if (!ready) return
executionContextDiagnostics.value = graphStore.moduleSource.text
? lsDiagnosticsToCMDiagnostics(graphStore.moduleSource.text, diagnostics)
executionContextDiagnostics.value =
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;
color: white;
width: 100%;
@ -420,11 +420,11 @@ const editorStyle = computed(() => {
transition: outline 0.1s ease-in-out;
}
.CodeEditor :is(.cm-focused) {
.CodeEditor :deep(.cm-focused) {
outline: 1px solid rgba(0, 0, 0, 0.5);
}
.CodeEditor :is(.cm-tooltip-hover) {
.CodeEditor :deep(.cm-tooltip-hover) {
padding: 4px;
border-radius: 4px;
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;
}
</style>

View File

@ -69,7 +69,9 @@ export function lsDiagnosticsToCMDiagnostics(
continue
}
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 })
}
return results

View File

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

View File

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

View File

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

View File

@ -55,46 +55,64 @@ const suggestionDb = useSuggestionDbStore()
const interaction = provideInteractionHandler()
/// === 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() {
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,
})
const removeToast = () => toast.dismiss(startupToast)
const removeToast = () => toast.dismiss(ToastId.startup)
projectStore.firstExecution.then(removeToast)
onScopeDispose(removeToast)
}
function initConnectionLostToast() {
let connectionLostToast = 'connectionLostToast'
document.addEventListener(
ProjectManagerEvents.loadingFailed,
() => {
toast.error('Lost connection to Language Server.', {
toastOnce(ToastId.connectionLost, 'Lost connection to Language Server.', {
type: 'error',
autoClose: false,
toastId: connectionLostToast,
})
},
{ once: true },
)
onUnmounted(() => {
toast.dismiss(connectionLostToast)
toast.dismiss(ToastId.connectionLost)
})
}
projectStore.lsRpcConnection.then(
(ls) => {
ls.client.onError((err) => {
toast.error(`Language server error: ${err}`)
toastOnce(ToastId.lspError, `Language server error: ${err}`, { type: 'error' })
})
},
(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) => {
toast.error(`Execution Failed: ${JSON.stringify(err)}`, {})
toastOnce(ToastId.executionFailed, `Execution Failed: ${JSON.stringify(err)}`, { type: 'error' })
})
onMounted(() => {

View File

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

View File

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

View File

@ -29,7 +29,7 @@ const iconForType: Record<GraphNodeMessageType, Icon | undefined> = {
<template>
<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>
</template>

View File

@ -20,7 +20,6 @@ import type { Result } from '@/util/data/result'
import type { URLString } from '@/util/data/urlString'
import { Vec2 } from '@/util/data/vec2'
import type { Icon } from '@/util/iconName'
import { debouncedGetter } from '@/util/reactivity'
import { computedAsync } from '@vueuse/core'
import { isIdentifier } from 'shared/ast'
import { visIdentifierEquals, type VisualizationIdentifier } from 'shared/yjsModel'
@ -91,9 +90,9 @@ const defaultVisualizationForCurrentNodeSource = computed<VisualizationIdentifie
return {
name: raw.value.name,
module:
raw.value.library == null
? { kind: 'Builtin' }
: { kind: 'Library', name: raw.value.library.name },
raw.value.library == null ?
{ kind: 'Builtin' }
: { kind: 'Library', name: raw.value.library.name },
}
},
)
@ -225,26 +224,23 @@ watchEffect(async () => {
})
const isBelowToolbar = ref(false)
let width = ref<Opt<number>>(props.width)
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))
let userSetHeight = ref(150)
watchEffect(() =>
emit(
'update:rect',
const rect = computed(
() =>
new Rect(
props.nodePosition,
new Vec2(
width.value ?? props.nodeSize.x,
height.value + (isBelowToolbar.value ? TOP_WITH_TOOLBAR_PX : TOP_WITHOUT_TOOLBAR_PX),
Math.max(props.width ?? 0, props.nodeSize.x),
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)))
@ -262,16 +258,16 @@ provideVisualizationConfig({
return props.scale
},
get width() {
return width.value ?? null
return rect.value.width
},
set width(value) {
width.value = value
emit('update:width', value)
},
get height() {
return height.value
return userSetHeight.value
},
set height(value) {
height.value = value
userSetHeight.value = value
},
get isBelowToolbar() {
return isBelowToolbar.value

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import { Awareness } from '@/stores/awareness'
import * as astText from '@/util/ast/text'
import { Vec2 } from '@/util/data/vec2'
import { Keccak, sha3_224 as SHA3 } from '@noble/hashes/sha3'
import type { Hash } from '@noble/hashes/utils'
import { bytesToHex } from '@noble/hashes/utils'
import { escapeTextLiteral } from 'shared/ast'
import type { DataServer } from 'shared/dataServer'
import type { LanguageServer } from 'shared/languageServer'
import { ErrorCode, RemoteRpcError } from 'shared/languageServer'
@ -17,10 +17,10 @@ const DATA_DIR_NAME = 'data'
export function uploadedExpression(result: UploadResult) {
switch (result.source) {
case 'Project': {
return `enso_project.data/'${astText.escape(result.name)}' . read`
return `enso_project.data/'${escapeTextLiteral(result.name)}' . read`
}
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 { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import type { TokenId } from '@/util/ast/abstract'
import { ArgumentInfoKey } from '@/util/callTree'
import { asNot } from '@/util/data/types.ts'
import { type Identifier, type QualifiedName } from '@/util/qualifiedName'
import { computed } from 'vue'
@ -51,7 +49,7 @@ const value = computed({
edit,
portUpdate: {
value: value ? 'True' : 'False',
origin: asNot<TokenId>(props.input.portId),
origin: props.input.portId,
},
})
}
@ -68,11 +66,9 @@ const argumentName = computed(() => {
<script lang="ts">
function isBoolNode(ast: Ast.Ast) {
const candidate =
ast instanceof Ast.PropertyAccess && ast.lhs?.code() === 'Boolean'
? ast.rhs
: ast instanceof Ast.Ident
? ast.token
: undefined
ast instanceof Ast.PropertyAccess && ast.lhs?.code() === 'Boolean' ? ast.rhs
: ast instanceof Ast.Ident ? ast.token
: undefined
return candidate && ['True', 'False'].includes(candidate.code())
}
function setBoolNode(ast: Ast.Mutable, value: Identifier): { requiresImport: boolean } {
@ -90,15 +86,15 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
priority: 500,
score: (props) => {
if (props.input.value instanceof Ast.Ast && isBoolNode(props.input.value)) return Score.Perfect
return props.input.expectedType === 'Standard.Base.Data.Boolean.Boolean'
? Score.Good
return props.input.expectedType === 'Standard.Base.Data.Boolean.Boolean' ?
Score.Good
: Score.Mismatch
},
})
</script>
<template>
<div class="CheckboxContainer" :class="{ primary }">
<div class="CheckboxContainer r-24" :class="{ primary }">
<span v-if="argumentName" class="name" v-text="argumentName" />
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown and pointerup -->
<CheckboxWidget

View File

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

View File

@ -2,8 +2,6 @@
import NumericInputWidget from '@/components/widgets/NumericInputWidget.vue'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast'
import type { TokenId } from '@/util/ast/abstract.ts'
import { asNot } from '@/util/data/types.ts'
import { computed } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
@ -14,7 +12,7 @@ const value = computed({
},
set(value) {
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 { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import type { TokenId } from '@/util/ast/abstract'
import { ArgumentInfoKey } from '@/util/callTree'
import { Rect } from '@/util/data/rect'
import { asNot } from '@/util/data/types.ts'
import { cachedGetter } from '@/util/reactivity'
import { uuidv4 } from 'lib0/random'
import { isUuid } from 'shared/yjsModel'
import {
computed,
markRaw,
nextTick,
onUpdated,
proxyRefs,
@ -66,7 +63,7 @@ const isTarget = computed(
)
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
// 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.
const portId = cachedGetter<PortId>(() => {
assert(!isUuid(props.input.portId))
return asNot<TokenId>(props.input.portId)
return props.input.portId
})
const innerWidget = computed(() => {
@ -100,7 +97,7 @@ const randSlice = randomUuid.slice(0, 4)
watchEffect(
(onCleanup) => {
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)
onCleanup(() => graph.removePortInstance(id, instance))
},
@ -109,7 +106,7 @@ watchEffect(
function updateRect() {
let domNode = rootNode.value
const rootDomNode = domNode?.closest('.node')
const rootDomNode = domNode?.closest('.GraphNode')
if (domNode == null || rootDomNode == null) return
const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect())

View File

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

View File

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

View File

@ -1,23 +1,48 @@
<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 { useGraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast'
import type { TokenId } from '@/util/ast/abstract'
import { asNot } from '@/util/data/types'
import { MutableModule } from '@/util/ast/abstract'
import { computed } from 'vue'
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() {
const valueStr = WidgetInput.valueRepr(props.input)
return typeof valueStr === 'string' && Ast.parse(valueStr) instanceof Ast.TextLiteral
? valueStr
: ''
return shownLiteral.value.rawTextContent
},
set(value) {
props.onUpdate({
portUpdate: { value: value.toString(), origin: asNot<TokenId>(props.input.portId) },
})
if (props.input.value instanceof Ast.TextLiteral) {
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>
@ -36,13 +61,29 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
</script>
<template>
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown -->
<EnsoTextInputWidget v-model="value" class="WidgetText r-24" @pointerdown.stop />
<label 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>
<style scoped>
.WidgetText {
display: inline-block;
display: inline-flex;
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>

View File

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

View File

@ -4,16 +4,15 @@ import ListWidget from '@/components/widgets/ListWidget.vue'
import { injectGraphNavigator } from '@/providers/graphNavigator'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast'
import { MutableModule, type TokenId } from '@/util/ast/abstract.ts'
import { asNot } from '@/util/data/types.ts'
import { MutableModule } from '@/util/ast/abstract.ts'
import { computed } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
const itemConfig = computed(() =>
props.input.dynamicConfig?.kind === 'Vector_Editor'
? props.input.dynamicConfig.item_editor
: undefined,
props.input.dynamicConfig?.kind === 'Vector_Editor' ?
props.input.dynamicConfig.item_editor
: undefined,
)
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.
const newCode = `[${value.map((item) => item.code()).join(', ')}]`
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'))
return Score.Good
else if (props.input.value instanceof Ast.Ast) {
return props.input.value.children().next().value.code() === '['
? Score.Perfect
return props.input.value.children().next().value.code() === '[' ?
Score.Perfect
: Score.Mismatch
} else return Score.Mismatch
},

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useApproach } from '@/composables/animation'
import type { Vec2 } from '@/util/data/vec2'
import { computed, watch, type Ref } from 'vue'
import { computed, shallowRef, watch } from 'vue'
const props = defineProps<{
position: Vec2
@ -9,7 +9,15 @@ const props = defineProps<{
}>()
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)
watch(

View File

@ -49,7 +49,7 @@ function blur(event: Event) {
const rootNode = 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() {
requestAnimationFrame(() => (isSelectorVisible.value = false))
@ -61,7 +61,7 @@ const resizeRight = usePointer((pos, _, type) => {
}
const width =
(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)
const resizeBottom = usePointer((pos, _, type) => {
@ -80,7 +80,7 @@ const resizeBottomRight = usePointer((pos, _, type) => {
if (pos.delta.x !== 0) {
const width =
(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) {
const height =
@ -117,12 +117,10 @@ const resizeBottomRight = usePointer((pos, _, type) => {
class="content scrollable"
:class="{ overflow: props.overflow }"
:style="{
width: config.fullscreen
? undefined
: `${Math.max(config.width ?? 0, config.nodeSize.x)}px`,
height: config.fullscreen
? undefined
: `${Math.max(config.height ?? 0, config.nodeSize.y)}px`,
width:
config.fullscreen ? undefined : `${Math.max(config.width ?? 0, config.nodeSize.x)}px`,
height:
config.fullscreen ? undefined : `${Math.max(config.height ?? 0, config.nodeSize.y)}px`,
}"
@wheel.passive="onWheel"
>

View File

@ -380,9 +380,9 @@ function pushPoints(newPoints: Location[]) {
) {
let position: [number, number] = [point.longitude, point.latitude]
let radius =
typeof point.radius === 'number' && !Number.isNaN(point.radius)
? point.radius
: DEFAULT_POINT_RADIUS
typeof point.radius === 'number' && !Number.isNaN(point.radius) ?
point.radius
: DEFAULT_POINT_RADIUS
let color = point.color ?? ACCENT_COLOR
let label = point.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))
watchPostEffect(() => {
width.value = config.fullscreen
? containerNode.value?.parentElement?.clientWidth ?? 0
width.value =
config.fullscreen ?
containerNode.value?.parentElement?.clientWidth ?? 0
: Math.max(config.width ?? 0, config.nodeSize.x)
})
const height = ref(config.height ?? (config.nodeSize.x * 3) / 4)
watchPostEffect(() => {
height.value = config.fullscreen
? containerNode.value?.parentElement?.clientHeight ?? 0
height.value =
config.fullscreen ?
containerNode.value?.parentElement?.clientHeight ?? 0
: config.height ?? (config.nodeSize.x * 3) / 4
})
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))
watchPostEffect(() => {
width.value = config.fullscreen
? containerNode.value?.parentElement?.clientWidth ?? 0
width.value =
config.fullscreen ?
containerNode.value?.parentElement?.clientWidth ?? 0
: Math.max(config.width ?? 0, config.nodeSize.x)
})
const height = ref(config.height ?? (config.nodeSize.x * 3) / 4)
watchPostEffect(() => {
height.value = config.fullscreen
? containerNode.value?.parentElement?.clientHeight ?? 0
height.value =
config.fullscreen ?
containerNode.value?.parentElement?.clientHeight ?? 0
: config.height ?? (config.nodeSize.x * 3) / 4
})
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 maxDelta = 1
const wheelSpeedMultiplier =
event.deltaMode === 1 ? medDelta : event.deltaMode ? maxDelta : minDelta
event.deltaMode === 1 ? medDelta
: event.deltaMode ? maxDelta
: minDelta
return -event.deltaY * wheelSpeedMultiplier
})
.scaleExtent(ZOOM_EXTENT)

View File

@ -46,9 +46,9 @@ const props = defineProps<{ data: Data }>()
const theme: Theme = DEFAULT_THEME
const language = computed(() =>
props.data.dialect != null && sqlFormatter.supportedDialects.includes(props.data.dialect)
? props.data.dialect
: 'sql',
props.data.dialect != null && sqlFormatter.supportedDialects.includes(props.data.dialect) ?
props.data.dialect
: 'sql',
)
const formatted = computed(() => {
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>(() => {
let rawData = props.data
const unfilteredData = Array.isArray(rawData)
? rawData.map((y, index) => ({ x: index, y }))
: rawData.data ?? []
const unfilteredData =
Array.isArray(rawData) ? rawData.map((y, index) => ({ x: index, y })) : rawData.data ?? []
const data: Point[] = unfilteredData.filter(
(point) =>
typeof point.x === 'number' &&
@ -203,15 +202,15 @@ const margin = computed(() => {
}
})
const width = computed(() =>
config.fullscreen
? containerNode.value?.parentElement?.clientWidth ?? 0
: Math.max(config.width ?? 0, config.nodeSize.x),
config.fullscreen ?
containerNode.value?.parentElement?.clientWidth ?? 0
: Math.max(config.width ?? 0, config.nodeSize.x),
)
const height = computed(() =>
config.fullscreen
? containerNode.value?.parentElement?.clientHeight ?? 0
: config.height ?? (config.nodeSize.x * 3) / 4,
config.fullscreen ?
containerNode.value?.parentElement?.clientHeight ?? 0
: config.height ?? (config.nodeSize.x * 3) / 4,
)
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 maxDelta = 1
const wheelSpeedMultiplier =
event.deltaMode === 1 ? medDelta : event.deltaMode ? maxDelta : minDelta
event.deltaMode === 1 ? medDelta
: event.deltaMode ? maxDelta
: minDelta
return -event.deltaY * wheelSpeedMultiplier
})
.scaleExtent(ZOOM_EXTENT)

View File

@ -296,11 +296,9 @@ watchEffect(() => {
const dataHeader = ('header' in data_ ? data_.header : [])?.map(toField) ?? []
columnDefs = [...indicesHeader, ...dataHeader]
const rows =
data_.data && data_.data.length > 0
? data_.data[0]?.length ?? 0
: data_.indices && data_.indices.length > 0
? data_.indices[0]?.length ?? 0
: 0
data_.data && data_.data.length > 0 ? data_.data[0]?.length ?? 0
: data_.indices && data_.indices.length > 0 ? data_.indices[0]?.length ?? 0
: 0
rowData = Array.from({ length: rows }, (_, i) => {
const shift = data_.indices ? data_.indices.length : 0
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) {
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: {
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:
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) {
return Array.from(str, (c) =>
c.charCodeAt(0) < 128
? c.charCodeAt(0).toString(16)
: encodeURIComponent(c).replace(/%/g, '').toLowerCase(),
c.charCodeAt(0) < 128 ?
c.charCodeAt(0).toString(16)
: encodeURIComponent(c).replace(/%/g, '').toLowerCase(),
).join('')
}

View File

@ -1,8 +1,7 @@
<script setup lang="ts">
import { PointerButtonMask, usePointer, useResizeObserver } from '@/composables/events'
import { blurIfNecessary } from '@/util/autoBlur'
import { getTextWidthByFont } from '@/util/measurement'
import { computed, ref, watch, type StyleValue } from 'vue'
import { PointerButtonMask, usePointer } from '@/composables/events'
import { computed, ref, watch, type ComponentInstance, type StyleValue } from 'vue'
import AutoSizedInput from './AutoSizedInput.vue'
const props = defineProps<{
modelValue: number | string
@ -12,11 +11,11 @@ const emit = defineEmits<{ 'update:modelValue': [modelValue: number | string] }>
const inputFieldActive = ref(false)
// 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(
() => props.modelValue,
(newValue) => {
editedValue.value = newValue
editedValue.value = `${newValue}`
},
)
const SLIDER_INPUT_THRESHOLD = 4.0
@ -24,31 +23,27 @@ const SLIDER_INPUT_THRESHOLD = 4.0
const dragPointer = usePointer(
(position, event, eventType) => {
const slider = event.target
if (!(slider instanceof HTMLElement)) {
return
}
if (!(slider instanceof HTMLElement)) return false
if (eventType === 'stop' && Math.abs(position.relative.x) < SLIDER_INPUT_THRESHOLD) {
inputNode.value?.focus()
return
event.stopImmediatePropagation()
return false
}
if (eventType === 'start') {
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 rect = slider.getBoundingClientRect()
const fractionRaw = (position.absolute.x - rect.left) / (rect.right - rect.left)
const fraction = Math.max(0, Math.min(1, fractionRaw))
const newValue = min + Math.round(fraction * (max - min))
editedValue.value = newValue
if (eventType === 'stop') {
emit('update:modelValue', editedValue.value)
}
editedValue.value = `${newValue}`
if (eventType === 'stop') emitUpdate()
},
PointerButtonMask.Main,
(event) => !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey,
@ -62,132 +57,80 @@ const sliderWidth = computed(() => {
}%`
})
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 inputComponent = ref<ComponentInstance<typeof AutoSizedInput>>()
const MIN_CONTENT_WIDTH = 56
const inputStyle = computed<StyleValue>(() => {
if (inputNode.value == null) {
return {}
}
const value = `${props.modelValue}`
const value = `${editedValue.value}`
const dotIdx = value.indexOf('.')
let indent = 0
if (dotIdx >= 0) {
if (dotIdx >= 0 && inputComponent.value != null) {
const { inputWidth, getTextWidth } = inputComponent.value
const textBefore = value.slice(0, dotIdx)
const textAfter = value.slice(dotIdx + 1)
const measurements = inputMeasurements.value
const total = getTextWidthByFont(value, measurements.font)
const beforeDot = getTextWidthByFont(textBefore, measurements.font)
const afterDot = getTextWidthByFont(textAfter, measurements.font)
const blankSpace = Math.max(measurements.availableWidth - total, 0)
const availableWidth = Math.max(inputWidth, MIN_CONTENT_WIDTH)
const beforeDot = getTextWidth(textBefore)
const afterDot = getTextWidth(textAfter)
const blankSpace = Math.max(availableWidth - inputWidth, 0)
indent = Math.min(Math.max(-blankSpace, afterDot - beforeDot), blankSpace)
}
return {
textIndent: `${indent}px`,
// Note: The input element here uses `box-sizing: content-box;`.
minWidth: `${MIN_CONTENT_WIDTH}px`,
}
})
function blur() {
inputFieldActive.value = false
emit('update:modelValue', editedValue.value)
function emitUpdate() {
if (`${props.modelValue}` !== editedValue.value) {
emit('update:modelValue', editedValue.value)
}
}
/** 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)
function blur() {
inputFieldActive.value = false
emitUpdate()
}
function focus() {
inputNode.value?.select()
inputFieldActive.value = true
setupAutoBlur()
}
</script>
<template>
<div
class="NumericInputWidget"
v-on="dragPointer.events"
@keydown.backspace.stop
@keydown.delete.stop
>
<div v-if="props.limits != null" class="fraction" :style="{ width: sliderWidth }"></div>
<input
ref="inputNode"
<label class="NumericInputWidget">
<div v-if="props.limits != null" class="slider" :style="{ width: sliderWidth }"></div>
<AutoSizedInput
ref="inputComponent"
v-model="editedValue"
class="value"
autoSelect
:style="inputStyle"
@keydown.enter.stop="($event.target as HTMLInputElement).blur()"
v-on="dragPointer.events"
@blur="blur"
@focus="focus"
/>
</div>
</label>
</template>
<style scoped>
.NumericInputWidget {
position: relative;
}
.AutoSizedInput {
user-select: none;
justify-content: space-around;
background: var(--color-widget);
border-radius: var(--radius-full);
overflow: clip;
width: 56px;
padding: 0px 4px;
&:focus {
background: var(--color-widget-focus);
}
}
.fraction {
.slider {
position: absolute;
height: 100%;
left: 0;
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>

View File

@ -2,7 +2,7 @@
import type { Opt } from '@/util/data/opt'
import { Vec2 } from '@/util/data/vec2'
import type { VueInstance } from '@vueuse/core'
import { type VueInstance } from '@vueuse/core'
import {
computed,
onScopeDispose,
@ -13,6 +13,7 @@ import {
watch,
watchEffect,
type Ref,
type ShallowRef,
type WatchSource,
} from 'vue'
@ -139,6 +140,45 @@ export function unrefElement(
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.
*
@ -153,8 +193,8 @@ export function useResizeObserver(
elementRef: Ref<Element | undefined | null | VueInstance>,
useContentRect = true,
): Ref<Vec2> {
const sizeRef = shallowRef<Vec2>(Vec2.Zero)
if (typeof ResizeObserver === 'undefined') {
if (!sharedResizeObserver) {
const sizeRef = shallowRef<Vec2>(Vec2.Zero)
// 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.
function refreshSize() {
@ -168,36 +208,36 @@ export function useResizeObserver(
useEvent(window, 'resize', refreshSize)
return sizeRef
}
const observer = new ResizeObserver((entries) => {
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)
}
})
const observer = sharedResizeObserver
watchEffect((onCleanup) => {
const element = unrefElement(elementRef)
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(() => {
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 {
@ -240,7 +280,7 @@ export const enum PointerButtonMask {
* @returns
*/
export function usePointer(
handler: (pos: EventPosition, event: PointerEvent, eventType: PointerEventType) => void,
handler: (pos: EventPosition, event: PointerEvent, eventType: PointerEventType) => void | boolean,
requiredButtonMask: number = PointerButtonMask.Main,
predicate?: (e: PointerEvent) => boolean,
) {
@ -256,18 +296,22 @@ export function usePointer(
trackedElement?.releasePointerCapture(trackedPointer.value)
}
trackedPointer.value = 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
trackedElement = null
}
trackedPointer.value = null
}
function doMove(e: PointerEvent) {
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)
}
}
@ -280,7 +324,6 @@ export function usePointer(
}
if (trackedPointer.value == null && e.currentTarget instanceof Element) {
e.preventDefault()
trackedPointer.value = e.pointerId
// This is mostly SAFE, as virtually all `Element`s also extend `GlobalEventHandlers`.
trackedElement = e.currentTarget as Element & GlobalEventHandlers
@ -288,21 +331,21 @@ export function usePointer(
trackedElement.setPointerCapture?.(e.pointerId)
initialGrabPos = new Vec2(e.clientX, e.clientY)
lastPos = initialGrabPos
handler(computePosition(e, initialGrabPos, lastPos), e, 'start')
if (handler(computePosition(e, initialGrabPos, lastPos), e, 'start') !== false) {
e.preventDefault()
}
}
},
pointerup(e: PointerEvent) {
if (trackedPointer.value !== e.pointerId) {
return
}
e.preventDefault()
doStop(e)
},
pointermove(e: PointerEvent) {
if (trackedPointer.value !== e.pointerId) {
return
}
e.preventDefault()
// handle release of all masked buttons as stop
if ((e.buttons & requiredButtonMask) !== 0) {
doMove(e)

View File

@ -4,7 +4,7 @@ import { useApproach } from '@/composables/animation'
import { PointerButtonMask, useEvent, usePointer, useResizeObserver } from '@/composables/events'
import { Rect } from '@/util/data/rect'
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 {
if (target != null && target instanceof Element) {
@ -17,7 +17,7 @@ function elemRect(target: Element | undefined): Rect {
export type NavigatorComposable = ReturnType<typeof useNavigator>
export function useNavigator(viewportNode: Ref<Element | undefined>) {
const size = useResizeObserver(viewportNode)
const targetCenter = ref<Vec2>(Vec2.Zero)
const targetCenter = shallowRef<Vec2>(Vec2.Zero)
const targetX = computed(() => targetCenter.value.x)
const targetY = computed(() => targetCenter.value.y)
const centerX = useApproach(targetX)
@ -32,7 +32,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
centerY.value = value.y
},
})
const targetScale = ref(1)
const targetScale = shallowRef(1)
const animatedScale = useApproach(targetScale)
const scale = computed({
get() {
@ -140,7 +140,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
let isPointerDown = false
let scrolledThisFrame = false
const eventMousePos = ref<Vec2 | null>(null)
const eventMousePos = shallowRef<Vec2 | null>(null)
let eventTargetScrollPos: Vec2 | null = null
const sceneMousePos = computed(() =>
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 { intersectionSize } from '@/util/data/set'
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 function useSelection<T>(
@ -20,7 +20,7 @@ export function useSelection<T>(
) {
const anchor = shallowRef<Vec2>()
const initiallySelected = new Set<T>()
const selected = reactive(new Set<T>())
const selected = shallowReactive(new Set<T>())
const hoveredNode = ref<NodeId>()
const hoveredPort = ref<PortId>()
@ -28,11 +28,13 @@ export function useSelection<T>(
if (event.target instanceof Element) {
const widgetPort = event.target.closest('.WidgetPort')
hoveredPort.value =
widgetPort instanceof HTMLElement &&
'port' in widgetPort.dataset &&
typeof widgetPort.dataset.port === 'string'
? (widgetPort.dataset.port as PortId)
: undefined
(
widgetPort instanceof HTMLElement &&
'port' in widgetPort.dataset &&
typeof widgetPort.dataset.port === 'string'
) ?
(widgetPort.dataset.port as PortId)
: undefined
}
})
@ -125,18 +127,19 @@ export function useSelection<T>(
const pointer = usePointer((_pos, event, eventType) => {
if (eventType === 'start') {
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) {
if (anchor.value == null) {
anchor.value = navigator.sceneMousePos?.copy()
}
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> {
// 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.
const constructed: ReturnType<F> = Array.isArray(valueOrArgs)
? factory(...valueOrArgs)
: valueOrArgs
const constructed: ReturnType<F> =
Array.isArray(valueOrArgs) ? factory(...valueOrArgs) : valueOrArgs
if (app != null) app.provide(provideKey, constructed)
else provide(provideKey, constructed)
return constructed

View File

@ -1,5 +1,5 @@
import { createContextStore } from '@/providers'
import type { AstId } from '@/util/ast/abstract'
import type { AstId, TokenId } from '@/util/ast/abstract'
import { identity } from '@vueuse/core'
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;
* 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 {
portId: PortId

View File

@ -4,7 +4,7 @@ import type { WidgetConfiguration } from '@/providers/widgetRegistry/configurati
import type { GraphDb } from '@/stores/graph/graphDatabase'
import type { Typename } from '@/stores/suggestionDatabase/entry'
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'
export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>>
@ -92,7 +92,7 @@ export interface WidgetInput {
*
* 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
* 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 InputMatcher<T extends WidgetInput> = keyof WidgetInput | InputMatcherFn<T>
type InputTy<M> = M extends (infer T)[]
? InputTy<T>
: M extends InputMatcherFn<infer T>
? T
: M extends keyof WidgetInput
? WidgetInput & Required<Pick<WidgetInput, M>>
type InputTy<M> =
M extends (infer T)[] ? InputTy<T>
: M extends InputMatcherFn<infer T> ? T
: M extends keyof WidgetInput ? WidgetInput & Required<Pick<WidgetInput, M>>
: never
export interface WidgetOptions<T extends WidgetInput> {

View File

@ -169,8 +169,8 @@ export function requiredImports(
]
switch (entry.kind) {
case SuggestionKind.Module:
return entry.reexportedIn
? unqualifiedImport(entry.reexportedIn)
return entry.reexportedIn ?
unqualifiedImport(entry.reexportedIn)
: [
{
kind: 'Qualified',
@ -181,11 +181,11 @@ export function requiredImports(
return unqualifiedImport(entry.reexportedIn ? entry.reexportedIn : entry.definedIn)
case SuggestionKind.Constructor:
if (directConImport) {
return entry.reexportedIn
? unqualifiedImport(entry.reexportedIn)
: entry.memberOf
? unqualifiedImport(entry.memberOf)
return (
entry.reexportedIn ? unqualifiedImport(entry.reexportedIn)
: entry.memberOf ? unqualifiedImport(entry.memberOf)
: []
)
} else {
const selfType = selfTypeEntry(db, entry)
return selfType ? requiredImports(db, selfType) : []
@ -254,11 +254,9 @@ export function requiredImportEquals(left: RequiredImport, right: RequiredImport
/** Check if `existing` import statement covers `required`. */
export function covers(existing: Import, required: RequiredImport): boolean {
const [parent, name] =
required.kind === 'Qualified'
? qnSplit(required.module)
: required.kind === 'Unqualified'
? [required.from, required.import]
: [undefined, '']
required.kind === 'Qualified' ? qnSplit(required.module)
: required.kind === 'Unqualified' ? [required.from, required.import]
: [undefined, '']
const directlyImported =
required.kind === 'Qualified' &&
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 { LocalOrigin, SourceRangeKey, VisualizationMetadata } 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 {
Node,
@ -53,7 +62,9 @@ export class PortViewInstance {
public rect: ShallowRef<Rect | undefined>,
public nodeId: NodeId,
public onUpdate: (update: WidgetUpdate) => void,
) {}
) {
markRaw(this)
}
}
export const useGraphStore = defineStore('graph', () => {
@ -77,7 +88,7 @@ export const useGraphStore = defineStore('graph', () => {
toRef(suggestionDb, 'groups'),
proj.computedValueRegistry,
)
const portInstances = reactive(new Map<PortId, Set<PortViewInstance>>())
const portInstances = shallowReactive(new Map<PortId, Set<PortViewInstance>>())
const editedNodeInfo = ref<NodeEditInfo>()
const methodAst = ref<Ast.Function>()
@ -107,8 +118,12 @@ export const useGraphStore = defineStore('graph', () => {
function handleModuleUpdate(module: Module, moduleChanged: boolean, update: ModuleUpdate) {
const root = module.root()
if (!root) return
moduleRoot.value = root
if (root instanceof Ast.BodyBlock) topLevel.value = root
if (moduleRoot.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.
const nodeMetadataUpdates = update.metadataUpdated as any as {
id: AstId
@ -375,25 +390,38 @@ export const useGraphStore = defineStore('graph', () => {
}
function updateNodeRect(nodeId: NodeId, rect: Rect) {
const nodeAst = syncModule.value?.tryGet(nodeId)
if (!nodeAst) return
if (rect.pos.equals(Vec2.Zero) && !nodeAst.nodeMetadata.get('position')) {
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)
nodeRects.set(nodeId, rect)
if (rect.pos.equals(Vec2.Zero)) {
nodesToPlace.push(nodeId)
}
}
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) {
if (rect) vizRects.set(id, rect)
else vizRects.delete(id)
@ -506,6 +534,11 @@ export const useGraphStore = defineStore('graph', () => {
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) {
edit((edit) => f(edit.getVersion(ast).mutableNodeMetadata()), true, true)
}
@ -626,6 +659,7 @@ export const useGraphStore = defineStore('graph', () => {
createNode,
deleteNodes,
ensureCorrectNodeOrder,
batchEdits,
setNodeContent,
setNodePosition,
setNodeVisualization,

View File

@ -45,7 +45,6 @@ import {
shallowRef,
watch,
watchEffect,
type ShallowRef,
type WatchSource,
type WritableComputedRef,
} from 'vue'
@ -220,9 +219,9 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
executionContextId: this.id,
expression: config.expression,
visualizationModule: config.visualizationModule,
...(config.positionalArgumentsExpressions
? { positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}),
...(config.positionalArgumentsExpressions ?
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}),
}),
'Failed to attach visualization',
).then(() => {
@ -237,9 +236,9 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
executionContextId: this.id,
expression: config.expression,
visualizationModule: config.visualizationModule,
...(config.positionalArgumentsExpressions
? { positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}),
...(config.positionalArgumentsExpressions ?
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}),
}),
'Failed to modify visualization',
).then(() => {
@ -582,9 +581,7 @@ export const useProjectStore = defineStore('project', () => {
diagnostics.value = newDiagnostics
})
function useVisualizationData(
configuration: WatchSource<Opt<NodeVisualizationConfiguration>>,
): ShallowRef<Result<{}> | undefined> {
function useVisualizationData(configuration: WatchSource<Opt<NodeVisualizationConfiguration>>) {
const id = random.uuidv4() as Uuid
watch(
@ -598,28 +595,28 @@ export const useProjectStore = defineStore('project', () => {
{ immediate: true, flush: 'post' },
)
return shallowRef(
computed(() => {
const json = visualizationDataRegistry.getRawData(id)
if (!json?.ok) return json ?? undefined
else return Ok(JSON.parse(json.value))
}),
)
return computed(() => {
const json = visualizationDataRegistry.getRawData(id)
if (!json?.ok) return json ?? undefined
const parsed = Ok(JSON.parse(json.value))
markRaw(parsed)
return parsed
})
}
const dataflowErrors = new ReactiveMapping(computedValueRegistry.db, (id, info) => {
const config = computed(() =>
info.payload.type === 'DataflowError'
? {
expressionId: id,
visualizationModule: 'Standard.Visualization.Preprocessor',
expression: {
module: 'Standard.Visualization.Preprocessor',
definedOnType: 'Standard.Visualization.Preprocessor',
name: 'error_preprocessor',
},
}
: null,
info.payload.type === 'DataflowError' ?
{
expressionId: id,
visualizationModule: 'Standard.Visualization.Preprocessor',
expression: {
module: 'Standard.Visualization.Preprocessor',
definedOnType: 'Standard.Visualization.Preprocessor',
name: 'error_preprocessor',
},
}
: null,
)
const data = useVisualizationData(config)
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 destructureExpression = `{ ${specifiers.join(', ')} }`
const rewritten =
namespace != null
? `const ${namespace} = await window.__visualizationModules[${pathJSON}];` +
(specifiers.length > 0 ? `\nconst ${destructureExpression} = ${namespace};` : '')
: `const ${destructureExpression} = await window.__visualizationModules[${pathJSON}];`
namespace != null ?
`const ${namespace} = await window.__visualizationModules[${pathJSON}];` +
(specifiers.length > 0 ? `\nconst ${destructureExpression} = ${namespace};` : '')
: `const ${destructureExpression} = await window.__visualizationModules[${pathJSON}];`
s.overwrite(stmt.start!, stmt.end!, rewritten)
if (isBuiltin) {
// No further action is needed.
@ -437,6 +437,7 @@ async function rewriteImports(code: string, dir: string, id: string | undefined)
if (mimetype != null) {
return importAsset(path, mimetype)
}
return Promise.resolve(undefined)
}
}
})
@ -484,9 +485,9 @@ onmessage = async (
case 'compile-request': {
try {
const path = event.data.path
await (event.data.recompile
? importVue(path)
: map.setIfUndefined(alreadyCompiledModules, path, () => importVue(path)))
await (event.data.recompile ?
importVue(path)
: map.setIfUndefined(alreadyCompiledModules, path, () => importVue(path)))
postMessage<CompilationResultResponse>({
type: 'compilation-result-response',
id: event.data.id,

View File

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

View File

@ -9,10 +9,13 @@ import {
readTokenSpan,
walkRecursive,
} from '@/util/ast'
import { fc, test } from '@fast-check/vitest'
import { initializeFFI } from 'shared/ast/ffi'
import { Token, Tree } from 'shared/ast/generated/ast'
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()
@ -224,3 +227,46 @@ test.each([
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) {
const sigil =
current.argument instanceof ArgumentPlaceholder
? '?'
: current.appTree instanceof Ast.App && current.appTree.argumentName
? '='
: '@'
current.argument instanceof ArgumentPlaceholder ? '?'
: current.appTree instanceof Ast.App && current.appTree.argumentName ? '='
: '@'
parts.push(sigil + (current.argument.argInfo?.name ?? '_'))
current = current.target
}

View File

@ -92,11 +92,11 @@ test.each([
).toBe(extracted != null)
expect(
patternAst.match(targetAst)?.map((match) => module.tryGet(match)?.code()),
extracted != null
? `'${target}' matches '${pattern}' with '__'s corresponding to ${JSON.stringify(extracted)
.slice(1, -1)
.replace(/"/g, "'")}`
: `'${target}' does not match '${pattern}'`,
extracted != null ?
`'${target}' matches '${pattern}' with '__'s corresponding to ${JSON.stringify(extracted)
.slice(1, -1)
.replace(/"/g, "'")}`
: `'${target}' does not match '${pattern}'`,
).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
if (pattern) {
const armStart = parsedTreeOrTokenRange(pattern)[0]
const armEnd = expression
? parsedTreeOrTokenRange(expression)[1]
: arrow
? parsedTreeOrTokenRange(arrow)[1]
const armEnd =
expression ? parsedTreeOrTokenRange(expression)[1]
: arrow ? parsedTreeOrTokenRange(arrow)[1]
: parsedTreeOrTokenRange(pattern)[1]
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 { markRaw } from 'vue'
type ExtractType<V, T> = T extends ReadonlyArray<infer Ts>
? Extract<V, { type: Ts }>
: Extract<V, { type: T }>
type ExtractType<V, T> =
T extends ReadonlyArray<infer Ts> ? Extract<V, { type: Ts }> : Extract<V, { type: T }>
type OneOrArray<T> = T | readonly T[]
@ -149,8 +148,8 @@ export class AstExtended<T extends Tree | Token = Tree | Token, HasIdMap extends
}
whitespaceLength() {
return 'whitespaceLengthInCodeBuffer' in this.inner
? this.inner.whitespaceLengthInCodeBuffer
return 'whitespaceLengthInCodeBuffer' in this.inner ?
this.inner.whitespaceLengthInCodeBuffer
: 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
? T
: Cond extends false
? undefined
type CondType<T, Cond extends boolean> =
Cond extends true ? T
: Cond extends false ? undefined
: T | undefined
class AstExtendedCtx<HasIdMap extends boolean> {

View File

@ -3,9 +3,9 @@ import { Ast } from '@/util/ast'
export function nodeFromAst(ast: Ast.Ast): NodeDataFromAst | undefined {
const { nodeCode, documentation } =
ast instanceof Ast.Documented
? { nodeCode: ast.expression, documentation: ast.documentation() }
: { nodeCode: ast, documentation: undefined }
ast instanceof Ast.Documented ?
{ nodeCode: ast.expression, documentation: ast.documentation() }
: { nodeCode: ast, documentation: undefined }
if (!nodeCode) return
const pattern = nodeCode instanceof Ast.Assignment ? nodeCode.pattern : undefined
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 type { Ref } from 'vue'
import { watchEffect, type Ref } from 'vue'
/** 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. */
export function useAutoBlur(root: Ref<HTMLElement | SVGElement | MathMLElement | undefined>) {
useEvent(window, 'pointerdown', (event) => blurIfNecessary(root, event), { capture: true })
export function useAutoBlur(root: Ref<HTMLElement | SVGElement | undefined>) {
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. */
@ -14,21 +47,3 @@ export function targetIsOutside(
): boolean {
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 tree = interpreted[key]
const info = tryGetIndex(suggestion?.arguments, index) ?? unknownArgInfoNamed(key)
return tree != null
? ArgumentAst.WithRetrievedConfig(tree, index, info, kind, widgetCfg)
return tree != null ?
ArgumentAst.WithRetrievedConfig(tree, index, info, kind, widgetCfg)
: ArgumentPlaceholder.WithRetrievedConfig(callId, index, info, kind, false, widgetCfg)
}
return new ArgumentApplication(
@ -308,9 +308,9 @@ export class ArgumentApplication {
})
} else {
const argumentFromDefinition =
argumentInCode.argName == null
? takeNextArgumentFromDefinition()
: takeNamedArgumentFromDefinition(argumentInCode.argName)
argumentInCode.argName == null ?
takeNextArgumentFromDefinition()
: takeNamedArgumentFromDefinition(argumentInCode.argName)
const { index, info } = argumentFromDefinition ?? {}
resolvedArgs.push({
appTree: argumentInCode.appTree,
@ -318,9 +318,9 @@ export class ArgumentApplication {
argumentInCode.argument,
index,
info ??
(argumentInCode.argName != null
? unknownArgInfoNamed(argumentInCode.argName)
: undefined),
(argumentInCode.argName != null ?
unknownArgInfoNamed(argumentInCode.argName)
: undefined),
ApplicationKind.Prefix,
widgetCfg,
),
@ -375,9 +375,9 @@ export class ArgumentApplication {
toWidgetInput(): WidgetInput {
return {
portId:
this.argument instanceof ArgumentAst
? this.appTree.id
: (`app:${this.argument.portId}` as PortId),
this.argument instanceof ArgumentAst ?
this.appTree.id
: (`app:${this.argument.portId}` as PortId),
value: this.appTree,
[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
* returned. */
function parseBoolean(value: unknown): boolean | null {
return typeof value === 'boolean'
? value
: typeof value === 'string'
? STRING_TO_BOOLEAN[value] ?? null
return (
typeof value === 'boolean' ? value
: typeof value === 'string' ? STRING_TO_BOOLEAN[value] ?? null
: null
)
}
export interface StringConfig {
@ -69,12 +69,12 @@ export interface Group<T = Required<RawGroup>> extends Config<T> {
}
export interface Config<T = Required<RawConfig>> {
options: T extends { options: infer Options extends object }
? { [K in keyof Options]: Option<Options[K]> }
: {}
groups: T extends { groups: infer Groups extends object }
? { [K in keyof Groups]: Group<Groups[K]> }
: {}
options: T extends { options: infer Options extends object } ?
{ [K in keyof Options]: Option<Options[K]> }
: {}
groups: T extends { groups: infer Groups extends object } ?
{ [K in keyof Groups]: Group<Groups[K]> }
: {}
}
function loadOption<T>(option: T): Option<T> {
@ -87,12 +87,14 @@ function loadOption<T>(option: T): Option<T> {
description: String(obj.description ?? ''),
defaultDescription: obj.defaultDescription != null ? String(obj.defaultDescription) : undefined,
value:
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
(Array.isArray(value) && value.every((item) => typeof item === 'string'))
? value
: '',
(
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
(Array.isArray(value) && value.every((item) => typeof item === 'string'))
) ?
value
: '',
primary: Boolean(obj.primary ?? true),
} satisfies Option<RawOption> as any
}
@ -111,13 +113,13 @@ export function loadConfig<T>(config: T): Config<T> {
}
return {
options:
'options' in config && typeof config.options === 'object' && config.options != null
? Object.fromEntries(Object.entries(config.options).map(([k, v]) => [k, loadOption(v)]))
: {},
'options' in config && typeof config.options === 'object' && config.options != null ?
Object.fromEntries(Object.entries(config.options).map(([k, v]) => [k, loadOption(v)]))
: {},
groups:
'groups' in config && typeof config.groups === 'object' && config.groups != null
? Object.fromEntries(Object.entries(config.groups).map(([k, v]) => [k, loadGroup(v)]))
: {},
'groups' in config && typeof config.groups === 'object' && config.groups != null ?
Object.fromEntries(Object.entries(config.groups).map(([k, v]) => [k, loadGroup(v)]))
: {},
} 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 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
}
}

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
function getMeasureContext() {
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. */
export function getTextWidthByFont(text: string | null | undefined, font: string) {
if (text == null) {
if (text == null || font == '' || !fontReady(font)) {
return 0
}
const context = getMeasureContext()
context.font = font
const metrics = context.measureText(' ' + text)
const metrics = context.measureText(text)
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
}
type SuggestedKeybindSegment = ModifierPlus | Pointer | Key
type AutocompleteKeybind<T extends string, Key extends string = never> = T extends '+'
? T
: T extends `${infer First}+${infer Rest}`
? Lowercase<First> extends LowercaseModifier
? `${NormalizeKeybindSegment[Lowercase<First>] & string}+${AutocompleteKeybind<Rest>}`
: Lowercase<First> extends LowercasePointer | LowercaseKey
? AutocompleteKeybind<Rest, NormalizeKeybindSegment[Lowercase<First>] & string>
type AutocompleteKeybind<T extends string, Key extends string = never> =
T extends '+' ? T
: T extends `${infer First}+${infer Rest}` ?
Lowercase<First> extends LowercaseModifier ?
`${NormalizeKeybindSegment[Lowercase<First>] & string}+${AutocompleteKeybind<Rest>}`
: Lowercase<First> extends LowercasePointer | LowercaseKey ?
AutocompleteKeybind<Rest, NormalizeKeybindSegment[Lowercase<First>] & string>
: `${Modifier}+${AutocompleteKeybind<Rest>}`
: T extends ''
? SuggestedKeybindSegment
: Lowercase<T> extends LowercasePointer | LowercaseKey
? NormalizeKeybindSegment[Lowercase<T>]
: Lowercase<T> extends LowercaseModifier
? [Key] extends [never]
? `${NormalizeKeybindSegment[Lowercase<T>] & string}+${SuggestedKeybindSegment}`
: T extends '' ? SuggestedKeybindSegment
: Lowercase<T> extends LowercasePointer | LowercaseKey ? NormalizeKeybindSegment[Lowercase<T>]
: Lowercase<T> extends LowercaseModifier ?
[Key] extends [never] ?
`${NormalizeKeybindSegment[Lowercase<T>] & string}+${SuggestedKeybindSegment}`
: `${NormalizeKeybindSegment[Lowercase<T>] & string}+${Key}`
: [Key] extends [never]
? SuggestedKeybindSegment
: [Key] extends [never] ? SuggestedKeybindSegment
: Key
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
// 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]>
}
: T
@ -353,9 +351,9 @@ export function defineKeybinds<
return (event, stopAndPrevent = true) => {
const eventModifierFlags = modifierFlagsForEvent(event)
const keybinds =
event instanceof KeyboardEvent
? keyboardShortcuts[event.key.toLowerCase() as Key_]?.[eventModifierFlags]
: mouseShortcuts[buttonFlagsForEvent(event)]?.[eventModifierFlags]
event instanceof KeyboardEvent ?
keyboardShortcuts[event.key.toLowerCase() as Key_]?.[eventModifierFlags]
: mouseShortcuts[buttonFlagsForEvent(event)]?.[eventModifierFlags]
let handle = handlers[DefaultHandler]
if (keybinds != null) {
for (const bindingName in handlers) {

View File

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

View File

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

View File

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

View File

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