Text input widget (#8873)

Closes #8823

https://github.com/enso-org/enso/assets/6566674/966576ec-6507-401c-98d3-bd71c2ffc6b2

Adds a basic text widget for text literals. 

### Important Notes

Several known restrictions:
- Separators would always be replaced with single quotation marks. All types of separators in Enso are supported though, and they would be correctly escaped if needed.
- Logic for widget selection probably needs refinement (works for text literals and `Text` types, but does not work for `Text | Integer`, for example)
- **(!)** There is a very annoying issue when the input field suddenly loses focus, closing the editing mode and discarding any changes. Debugging shows that it happens when we receive an engine update (and probably recreate the node component/widget tree (???)). It requires a separate investigation.
This commit is contained in:
Ilya Bogdanov 2024-01-30 18:05:28 +04:00 committed by GitHub
parent 081c8c889c
commit ad6348a12a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 272 additions and 37 deletions

View File

@ -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', {
@ -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 }) => {
@ -169,10 +169,8 @@ 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()
@ -180,9 +178,6 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
'Aggregate_Column',
'.',
'Count_Distinct',
'"',
'column 1',
'"',
'Aggregate_Column',
'.',
'Group_By',
@ -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).

View File

@ -311,6 +311,7 @@ const handleClick = useDoubleClick(
graphBindingsHandler(e)
},
() => {
if (keyboardBusy()) return false
stackNavigator.exitNode()
},
).handleClick

View File

@ -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>

View File

@ -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'
@ -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

View File

@ -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'
@ -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)

View File

@ -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 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"
@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

@ -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<{
@ -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)
}
@ -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"
@ -137,7 +142,7 @@ function focus() {
</template>
<style scoped>
.SliderWidget {
.NumericInputWidget {
position: relative;
user-select: none;
justify-content: space-around;

View File

@ -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',
@ -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)
})

View File

@ -4,7 +4,7 @@ import { parseEnso } from '@/util/ast'
import { Err, Ok, type Result } from '@/util/data/result'
import { is_ident_or_operator } from '@/util/ffi'
import type { LazyObject } from '@/util/parserSupport'
import { unsafeEntries } from '@/util/record'
import { swapKeysAndValues, unsafeEntries } from '@/util/record'
import * as map from 'lib0/map'
import * as random from 'lib0/random'
import { reactive } from 'vue'
@ -1665,12 +1665,19 @@ const mapping: Record<string, string> = {
'`': '``',
}
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. */
function escape(string: string) {
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]!)
}
export class MutableTextLiteral extends TextLiteral implements MutableAst {
declare readonly module: MutableModule
declare readonly fields: FixedMap<AstFields & TextLiteralFields>

View File

@ -3,18 +3,22 @@ function getMeasureContext() {
return (_measureContext ??= document.createElement('canvas').getContext('2d')!)
}
/** Helper function to get text width to make sure that labels on the x axis do not overlap,
* and keeps it readable. */
export function getTextWidth(
/** Helper function to get text width. Accepts font size and family only. For a more precise control, use `getTextWidthByFont`. */
export function getTextWidthBySizeAndFamily(
text: string | null | undefined,
fontSize = '11.5px',
fontFamily = "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
) {
return getTextWidthByFont(text, `${fontSize} ${fontFamily}`)
}
/** 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) {
return 0
}
const context = getMeasureContext()
context.font = `${fontSize} ${fontFamily}`
context.font = font
const metrics = context.measureText(' ' + text)
return metrics.width
}

View File

@ -7,3 +7,15 @@ export function unsafeEntries<K extends PropertyKey, V>(obj: Record<K, V>): [K,
export function unsafeKeys<K extends PropertyKey>(obj: Record<K, unknown>): K[] {
return Object.keys(obj) as any
}
/** Swap keys and value in a record. */
export function swapKeysAndValues<K extends PropertyKey, V extends PropertyKey>(
record: Record<K, V>,
): Record<V, K> {
const swappedRecord: Record<V, K> = {} as Record<V, K>
for (const key in record) {
const value = record[key]
swappedRecord[value] = key as K
}
return swappedRecord
}

View File

@ -4,13 +4,14 @@ import { computed, ref } from 'vue'
import CheckboxWidget from '@/components/widgets/CheckboxWidget.vue'
import DropdownWidget from '@/components/widgets/DropdownWidget.vue'
import EnsoTextInputWidget from '@/components/widgets/EnsoTextInputWidget.vue'
import NumericInputWidget from '@/components/widgets/NumericInputWidget.vue'
// === Checkbox props ===
const checkboxState = ref(false)
// === Slider props ===
// === Numeric props ===
const state = ref(0)
const min = ref(0)
@ -20,6 +21,10 @@ const sliderLimits = computed(() => {
return withLimits.value ? { min: min.value, max: max.value } : undefined
})
// === Text props ===
const text = ref('')
// === Dropdown props ===
const color = ref('#357ab9')
@ -47,6 +52,13 @@ const values = ref(['address', 'age', 'id', 'language', 'location', 'workplace']
<HstNumber v-model="max" title="max" />
</template>
</Variant>
<Variant title="text" :meta="{ customBackground: backgroundColor }">
<EnsoTextInputWidget v-model="text" />
<template #controls>
<HstText v-model="text" title="v-model" />
</template>
</Variant>
<Variant title="dropdown">
<div style="height: 140px">
<div style="position: relative">