Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Text input widget #8873

Merged
merged 16 commits into from
Jan 30, 2024
Merged
23 changes: 6 additions & 17 deletions app/gui2/e2e/widgets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,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('.WidgetToken')).toHaveText(['"', 'File 2', '"'])
await expect(pathArg.locator('.EnsoTextInputWidget > input')).toHaveValue('"File 2"')

// Change value on `path` (dynamic config)
await mockMethodCallInfo(page, 'data', {
Expand All @@ -91,10 +91,10 @@ test('Selection widgets in Data.read node', async ({ page }) => {
},
notAppliedArguments: [1],
})
await page.getByText('File 2').click()
await page.getByText('path').click()
await dropDown.expectVisibleWithOptions(page, ['File 1', 'File 2'])
await dropDown.clickOption(page, 'File 1')
await expect(pathArg.locator('.WidgetToken')).toHaveText(['"', 'File 1', '"'])
await expect(pathArg.locator('.EnsoTextInputWidget > input')).toHaveValue('"File 1"')
})

test('Managing aggregates in `aggregate` node', async ({ page }) => {
Expand Down Expand Up @@ -169,20 +169,15 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
'Aggregate_Column',
'.',
'Count_Distinct',
'"',
'column 1',
'"',
])
await expect(columnsArg.locator('.EnsoTextInputWidget > input')).toHaveValue('"column 1"')

// Add another aggregate
await columnsArg.locator('.add-item').click()
await expect(columnsArg.locator('.WidgetToken')).toHaveText([
'Aggregate_Column',
'.',
'Count_Distinct',
'"',
'column 1',
'"',
'Aggregate_Column',
'.',
'Group_By',
Expand All @@ -209,14 +204,8 @@ 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',
'"',
'column 2',
'"',
])
await expect(secondItem.locator('.WidgetToken')).toHaveText(['Aggregate_Column', '.', 'Group_By'])
await expect(secondItem.locator('.EnsoTextInputWidget > input')).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).
Expand Down
1 change: 1 addition & 0 deletions app/gui2/src/components/GraphEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ const handleClick = useDoubleClick(
graphBindingsHandler(e)
},
() => {
if (keyboardBusy()) return false
stackNavigator.exitNode()
},
).handleClick
Expand Down
48 changes: 48 additions & 0 deletions app/gui2/src/components/GraphEditor/widgets/WidgetText.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script setup lang="ts">
import EnsoTextInputWidget from '@/components/widgets/EnsoTextInputWidget.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 { computed } from 'vue'

const props = defineProps(widgetProps(widgetDefinition))
const graph = useGraphStore()
const value = computed({
get() {
const valueStr = WidgetInput.valueRepr(props.input)
return valueStr ?? ''
},
set(value) {
props.onUpdate({
edit: graph.astModule.edit(),
portUpdate: { value: value.toString(), origin: asNot<TokenId>(props.input.portId) },
})
},
})
</script>

<script lang="ts">
export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
priority: 1001,
score: (props) => {
if (props.input.value instanceof Ast.TextLiteral) return Score.Perfect
if (props.input.dynamicConfig?.kind === 'Text_Input') return Score.Perfect
const type = props.input.expectedType
if (type === 'Standard.Base.Data.Text') return Score.Good
return Score.Mismatch
},
})
</script>

<template>
<EnsoTextInputWidget v-model="value" class="WidgetText r-24" />
</template>

<style scoped>
.WidgetText {
display: inline-block;
vertical-align: middle;
}
</style>
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import SvgIcon from '@/components/SvgIcon.vue'
import { useEvent } from '@/composables/events'
import { getTextWidth } from '@/util/measurement'
import { getTextWidthBySizeAndFamily } from '@/util/measurement'
import { defineKeybinds } from '@/util/shortcuts'
import { VisualizationContainer, useVisualizationConfig } from '@/util/visualizationBuiltins'
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
Expand Down Expand Up @@ -261,9 +261,13 @@ watchPostEffect(() => {
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
const xLabelTop = computed(() => boxHeight.value + margin.value.bottom - AXIS_LABEL_HEIGHT / 2)
const xLabelLeft = computed(() => boxWidth.value / 2 + getTextWidth(axis.value.x?.label) / 2)
const xLabelLeft = computed(
() => boxWidth.value / 2 + getTextWidthBySizeAndFamily(axis.value.x?.label) / 2,
)
const yLabelTop = computed(() => -margin.value.left + AXIS_LABEL_HEIGHT)
const yLabelLeft = computed(() => -boxHeight.value / 2 + getTextWidth(axis.value.y?.label) / 2)
const yLabelLeft = computed(
() => -boxHeight.value / 2 + getTextWidthBySizeAndFamily(axis.value.y?.label) / 2,
)

let startX = 0
let startY = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import SvgIcon from '@/components/SvgIcon.vue'
import { useEvent } from '@/composables/events'
import { useVisualizationConfig } from '@/providers/visualizationConfig'
import { getTextWidth } from '@/util/measurement'
import { getTextWidthBySizeAndFamily } from '@/util/measurement'
import { VisualizationContainer, defineKeybinds } from '@/util/visualizationBuiltins'
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'

Expand Down Expand Up @@ -222,11 +222,13 @@ const xLabelLeft = computed(
() =>
margin.value.left +
boxWidth.value / 2 -
getTextWidth(data.value.axis.x.label, LABEL_FONT_STYLE) / 2,
getTextWidthBySizeAndFamily(data.value.axis.x.label, LABEL_FONT_STYLE) / 2,
)
const xLabelTop = computed(() => boxHeight.value + margin.value.top + 20)
const yLabelLeft = computed(
() => -boxHeight.value / 2 + getTextWidth(data.value.axis.y.label, LABEL_FONT_STYLE) / 2,
() =>
-boxHeight.value / 2 +
getTextWidthBySizeAndFamily(data.value.axis.y.label, LABEL_FONT_STYLE) / 2,
)
const yLabelTop = computed(() => -margin.value.left + 15)

Expand Down
134 changes: 134 additions & 0 deletions app/gui2/src/components/widgets/EnsoTextInputWidget.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { useResizeObserver } from '@/composables/events'
import { escape, unescape } from '@/util/ast/abstract'
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 don’t 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"
@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>
17 changes: 11 additions & 6 deletions app/gui2/src/components/widgets/NumericInputWidget.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { PointerButtonMask, usePointer, useResizeObserver } from '@/composables/events'
import { blurIfNecessary } from '@/util/autoBlur'
import { getTextWidth } from '@/util/measurement'
import { getTextWidthByFont } from '@/util/measurement'
import { computed, ref, watch, type StyleValue } from 'vue'

const props = defineProps<{
Expand Down Expand Up @@ -84,9 +84,9 @@ const inputStyle = computed<StyleValue>(() => {
const textAfter = value.slice(dotIdx + 1)

const measurements = inputMeasurements.value
const total = getTextWidth(value, measurements.font)
const beforeDot = getTextWidth(textBefore, measurements.font)
const afterDot = getTextWidth(textAfter, measurements.font)
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)
indent = Math.min(Math.max(-blankSpace, afterDot - beforeDot), blankSpace)
}
Expand Down Expand Up @@ -123,7 +123,12 @@ function focus() {
</script>

<template>
<div class="SliderWidget" v-on="dragPointer.events">
<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"
Expand All @@ -137,7 +142,7 @@ function focus() {
</template>

<style scoped>
.SliderWidget {
.NumericInputWidget {
position: relative;
user-select: none;
justify-content: space-around;
Expand Down
19 changes: 18 additions & 1 deletion app/gui2/src/util/ast/__tests__/abstract.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import { expect, test } from 'vitest'
import { MutableModule, type Identifier } from '../abstract'
import { MutableModule, escape, unescape, type Identifier } from '../abstract'

//const disabledCases = [
// ' a',
Expand Down Expand Up @@ -473,3 +473,20 @@ test('Splice', () => {
expect(spliced.module).toBe(module)
expect(spliced.code()).toBe('foo')
})

test.each([
['Hello, World!', 'Hello, World!'],
['Hello\t\tWorld!', 'Hello\\t\\tWorld!'],
['He\nllo, W\rorld!', 'He\\nllo, W\\rorld!'],
['Hello,\vWorld!', 'Hello,\\vWorld!'],
['Hello, \\World!', 'Hello, \\World!'],
['Hello, `World!`', 'Hello, ``World!``'],
["'Hello, World!'", "\\'Hello, World!\\'"],
['"Hello, World!"', '\\"Hello, World!\\"'],
['Hello, \fWorld!', 'Hello, \\fWorld!'],
['Hello, \bWorld!', 'Hello, \\bWorld!'],
])('Text literals escaping and unescaping', (original, expectedEscaped) => {
const escaped = escape(original)
expect(escaped).toBe(expectedEscaped)
expect(unescape(escaped)).toBe(original)
})
Loading
Loading