Show where an inspector row can be dragged and answer the pointer
After #310 rows could be dragged by their mark, but nothing said which marks take a drag, and the pointer on one changed nothing but the cursor. - A mark that drags is now a Grip: the same widget wrapped, the same size, so the value column stays where it was. Once sortBlocks takes it, it carries six faint dots at its left edge. - Marks that cannot drag stay plain: the variables of a placed object and the fields of a typed struct show no dots. - With the pointer on a grip its dots light up and the whole block that would move is washed in the accent colour, a struct with its fields. While the block is carried the wash gives way to the dimming, and the grip stays lit. - While a block is carried the other grips let the pointer pass: no lit dots, no wash. Every grip of the column shows grabbing instead of grab until the drop. - The wash first used controlHover like list rows. In the inspector it is darker than the panel and vanished next to the dark fields, so it became the accent at 15 percent, the colour of the drop line. - __gm.inspectorMark also reports the dots: none, idle or lit. Tests: - Gate on melektaus-atx: 236 files, 3576 tests (+4 in block-drag.test.ts, one of them a drag passing over another grip). - e2e 22: 40 checks (+1, sum 1740). 22f-iii reads idle dots, moves the mouse onto the mark and reads them lit. - Two counter-checks bite: no wash on hover fails 1 test, grips never made grabbable fail 3. A third: hover ignoring the drag fails 1. - Screenshots of a lit single row and a lit struct block looked at. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2c19c89878
commit
c74b6b1cab
10 files changed
+270
-23
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1739 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1740 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AssetType } from '../../core/model/AssetTypes';
|
||||
import {
|
||||
Button,
|
||||
Grip,
|
||||
Label,
|
||||
Panel,
|
||||
SectionHeader,
|
||||
@@ -169,7 +170,7 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
/** The mark in front of the row named `label` – what a variable, a field or a port is dragged by (#310). */
|
||||
inspectorMark: (label: string, last = false) => {
|
||||
const mark = besideOf(label, last, -1);
|
||||
return mark && centreOf(mark);
|
||||
return mark && { ...centreOf(mark), dots: mark instanceof Grip ? mark.dotsShown : 'none' };
|
||||
},
|
||||
/** The eyedropper beside the object field of a row (#276): where to press it, and whether it is lit. */
|
||||
inspectorEyedropper: (label: string, last = false) => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core
|
||||
import {
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Grip,
|
||||
HStack,
|
||||
Label,
|
||||
Menu,
|
||||
@@ -295,7 +296,7 @@ function portRow(
|
||||
): SortBlock {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
const mark = markLabel(port.kind);
|
||||
const mark = new Grip(markLabel(port.kind));
|
||||
row.add(mark);
|
||||
row.add(name, 1);
|
||||
for (const extra of extras) row.add(extra);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PropertyDef, StructFieldDef } from '../../core/properties/Property
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Grip,
|
||||
HStack,
|
||||
Icon,
|
||||
Label,
|
||||
@@ -88,7 +89,7 @@ export interface VariableOptions {
|
||||
|
||||
/** A variable's first row, and its mark – where the asset lets it be dragged to another place (#310). */
|
||||
export interface VariableRow extends FieldRow {
|
||||
grip: Widget;
|
||||
grip: Grip;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +105,7 @@ export function buildVariable(
|
||||
): VariableRow {
|
||||
const { edit, typing, keep, hidden, actions } = options;
|
||||
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
|
||||
const grip = markLabel(def.kind);
|
||||
const grip = new Grip(markLabel(def.kind));
|
||||
const head = gridRow(0, grip, name);
|
||||
let setValue: (next: PropertyValue) => void = () => {};
|
||||
let below: Widget | undefined;
|
||||
@@ -258,10 +259,10 @@ function fieldRow(
|
||||
field: StructFieldDef,
|
||||
path: readonly string[],
|
||||
edit: VariableEditing | undefined,
|
||||
): { row: HStack; mark: Widget } {
|
||||
): { row: HStack; mark: Grip } {
|
||||
const depth = path.length + 1;
|
||||
const name = nameCell(field.key, depth, edit && ((next) => edit.renameField(path, field, next)));
|
||||
const mark = markLabel(field.kind);
|
||||
const mark = new Grip(markLabel(field.kind));
|
||||
return { row: gridRow(depth, mark, name), mark };
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Grip,
|
||||
HStack,
|
||||
Label,
|
||||
Menu,
|
||||
@@ -354,7 +355,7 @@ export class InlineList extends VStack {
|
||||
const label = new Label(String(index), { style: 'small', align: 'right' });
|
||||
label.minWidth = INDEX_WIDTH;
|
||||
if (tall) label.minHeight = Theme.rowHeight;
|
||||
row.add(label);
|
||||
const grip = row.add(new Grip(label));
|
||||
// A typed list's items are its type: there is no other kind to turn one into.
|
||||
const mark = this.listType(access) ? markLabel(kind) : this.kindPicker(access, [index], kind);
|
||||
if (tall) mark.minHeight = Theme.rowHeight;
|
||||
@@ -378,10 +379,10 @@ export class InlineList extends VStack {
|
||||
const widgets = this.rowWidgets[index];
|
||||
if (widgets) widgets.remove = remove;
|
||||
this.add(row);
|
||||
if (!below) return { grip: label, rows: [row] };
|
||||
if (!below) return { grip, rows: [row] };
|
||||
const items = indented(below, INDEX_WIDTH + Theme.gap);
|
||||
this.add(items);
|
||||
return { grip: label, rows: [row, items] };
|
||||
return { grip, rows: [row, items] };
|
||||
}
|
||||
|
||||
/** A list inside this one: its head goes where a value would, its items below, one step further in. */
|
||||
|
||||
+96
-13
@@ -1,13 +1,14 @@
|
||||
import { type Container, type FederatedPointerEvent, Graphics, Point } from 'pixi.js';
|
||||
import { cancelFrameWork, onFrame } from './render';
|
||||
import { cancelFrameWork, onFrame, requestRender } from './render';
|
||||
import { blockGap, type BlockSpan, dropSlot, edgeScroll, gapLineY } from './sortable';
|
||||
import { Theme } from './Theme';
|
||||
import type { Widget } from './Widget';
|
||||
import type { Grip } from './widgets/Grip';
|
||||
import { ScrollContainer } from './widgets/ScrollContainer';
|
||||
|
||||
/** One thing to reorder: where it is taken hold of, and the rows it fills in its column. */
|
||||
export interface SortBlock {
|
||||
grip: Widget;
|
||||
grip: Grip;
|
||||
/** Siblings in one column, top to bottom. */
|
||||
rows: readonly Widget[];
|
||||
}
|
||||
@@ -16,25 +17,106 @@ export interface SortBlock {
|
||||
const DRAG_THRESHOLD = 4;
|
||||
const FRAME_KEY = 'block-drag';
|
||||
const DIMMED = 0.4;
|
||||
/** The block under a lit grip: a wash of the accent the drop line is drawn in. */
|
||||
const HOVER_ALPHA = 0.15;
|
||||
|
||||
/**
|
||||
* Lets the blocks of one column be reordered by dragging their grips (#310). Unlike a
|
||||
* `SortableList` the rows are ordinary widgets of any height – a struct is its head with its fields
|
||||
* below – so a block is told by the rows it covers. While dragging, the block is dimmed, a line
|
||||
* shows where it lands, and a scrolled column follows the pointer to its edge. `move` gets the
|
||||
* indices the model takes, the block already taken out.
|
||||
* below – so a block is told by the rows it covers. A grip shows its dots, and the pointer on it
|
||||
* lights them and backs the block that would go with it. While dragging, the block is dimmed, a
|
||||
* line shows where it lands, and a scrolled column follows the pointer to its edge; the other grips
|
||||
* pass the pointer by. `move` gets the indices the model takes, the block already taken out.
|
||||
*/
|
||||
export function sortBlocks(blocks: readonly SortBlock[], move: (from: number, to: number) => void): void {
|
||||
if (blocks.length < 2) return;
|
||||
blocks.forEach((block, index) => {
|
||||
block.grip.eventMode = 'static';
|
||||
block.grip.cursor = 'grab';
|
||||
block.grip.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (e.button === 0) new BlockDrag(blocks, index, e.global.y, move);
|
||||
const hovers = blocks.map((block) => new BlockHover(block));
|
||||
blocks.forEach(({ grip }, index) => {
|
||||
const hover = hovers[index]!;
|
||||
grip.setGrabbable(true);
|
||||
grip.cursor = 'grab';
|
||||
grip.on('pointerover', () => hover.enter());
|
||||
grip.on('pointerout', () => hover.leave());
|
||||
grip.once('destroyed', () => hover.hide());
|
||||
grip.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (e.button === 0) new BlockDrag(blocks, index, e.global.y, move, hovers);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The answer to the pointer on a grip: its dots lit, the block's rows backed. Held while dragging. */
|
||||
class BlockHover {
|
||||
private over = false;
|
||||
private held = false;
|
||||
private back: Graphics | null = null;
|
||||
|
||||
constructor(private readonly block: SortBlock) {}
|
||||
|
||||
enter(): void {
|
||||
this.over = true;
|
||||
if (this.held) return;
|
||||
this.block.grip.setLit(true);
|
||||
this.show();
|
||||
}
|
||||
|
||||
leave(): void {
|
||||
this.over = false;
|
||||
if (this.held) return;
|
||||
this.block.grip.setLit(false);
|
||||
this.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag in the column took over: no grip answers the pointer until it ends, and each shows the
|
||||
* hand that holds. Only the dragged one stays lit, its block is dimmed instead.
|
||||
*/
|
||||
hold(dragged: boolean): void {
|
||||
this.held = true;
|
||||
this.hide();
|
||||
this.block.grip.setLit(dragged);
|
||||
this.block.grip.cursor = 'grabbing';
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.held = false;
|
||||
const { grip } = this.block;
|
||||
if (grip.destroyed) return;
|
||||
grip.cursor = 'grab';
|
||||
grip.setLit(this.over);
|
||||
if (this.over) this.show();
|
||||
}
|
||||
|
||||
private show(): void {
|
||||
const column = this.block.rows[0]?.parent;
|
||||
if (!column) return;
|
||||
this.back ??= new Graphics();
|
||||
const { left, right, top, bottom } = blockBox(column, this.block);
|
||||
this.back
|
||||
.clear()
|
||||
.rect(left, top, right - left, bottom - top)
|
||||
.fill({ color: Theme.colors.accent, alpha: HOVER_ALPHA });
|
||||
column.addChildAt(this.back, 0);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (!this.back) return;
|
||||
if (!this.back.destroyed) this.back.destroy();
|
||||
this.back = null;
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
/** The block's box in its column, from the grip's indent: a field stays inside its struct. */
|
||||
function blockBox(column: Container, { grip, rows }: SortBlock) {
|
||||
return {
|
||||
left: column.toLocal(grip.getGlobalPosition()).x,
|
||||
right: Math.max(...rows.map((row) => row.x + row.w)),
|
||||
top: Math.min(...rows.map((row) => row.y)),
|
||||
bottom: Math.max(...rows.map((row) => row.y + row.h)),
|
||||
};
|
||||
}
|
||||
|
||||
class BlockDrag {
|
||||
private readonly column: Container | null;
|
||||
private readonly scroll: ScrollContainer | null;
|
||||
@@ -49,6 +131,7 @@ class BlockDrag {
|
||||
private readonly from: number,
|
||||
private readonly startY: number,
|
||||
private readonly move: (from: number, to: number) => void,
|
||||
private readonly hovers: readonly BlockHover[],
|
||||
) {
|
||||
const block = blocks[from]!;
|
||||
this.column = block.rows[0]?.parent ?? null;
|
||||
@@ -80,6 +163,7 @@ class BlockDrag {
|
||||
if (!this.column) return;
|
||||
if (!this.active) {
|
||||
this.active = true;
|
||||
this.hovers.forEach((hover, index) => hover.hold(index === this.from));
|
||||
for (const row of this.rows) {
|
||||
this.alphas.push(row.alpha);
|
||||
row.alpha = DIMMED;
|
||||
@@ -110,9 +194,7 @@ class BlockDrag {
|
||||
const spans = this.spans();
|
||||
const y = this.localY();
|
||||
if (dropSlot(y, spans, this.from) === null) return;
|
||||
// From the grip's own indent, so a field's line shows it stays inside its struct.
|
||||
const left = column.toLocal(this.blocks[this.from]!.grip.getGlobalPosition()).x;
|
||||
const right = Math.max(...this.rows.map((row) => row.x + row.w));
|
||||
const { left, right } = blockBox(column, this.blocks[this.from]!);
|
||||
const at = Math.round(gapLineY(blockGap(y, spans), spans));
|
||||
this.line.rect(left, at - 1, Math.max(0, right - left), 2).fill(Theme.colors.accent);
|
||||
}
|
||||
@@ -141,6 +223,7 @@ class BlockDrag {
|
||||
if (!row.destroyed) row.alpha = this.alphas[i] ?? 1;
|
||||
});
|
||||
if (!this.line.destroyed) this.line.destroy();
|
||||
for (const hover of this.hovers) hover.release();
|
||||
if (to !== null) this.move(this.from, to);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export { Button, type ButtonOptions } from './widgets/Button';
|
||||
export { TooltipLayer, tooltips, type TooltipHost, type TooltipTarget } from './widgets/Tooltip';
|
||||
export { placeTooltip, TOOLTIP_DELAY_MS, TOOLTIP_GAP, type TooltipBox, withShortcut } from './tooltip';
|
||||
export { CHEVRON_SLOT, Dropdown, drawChevron } from './widgets/Dropdown';
|
||||
export { Grip } from './widgets/Grip';
|
||||
export { Icon } from './widgets/Icon';
|
||||
export { UI_ICON_SVG } from './icons';
|
||||
export { Checkbox } from './widgets/Checkbox';
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { crisp } from '../pixelGrid';
|
||||
import { Theme } from '../Theme';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
|
||||
const DOT = 2;
|
||||
const DOT_GAP = 1;
|
||||
const IDLE_ALPHA = 0.5;
|
||||
|
||||
/**
|
||||
* What a row is taken hold of by to drag it to another place (#310): its mark, the same size. Once
|
||||
* `sortBlocks` makes it grabbable it carries a column of dots at its left edge, lit while the
|
||||
* pointer is on it.
|
||||
*/
|
||||
export class Grip extends Widget {
|
||||
private readonly dots = crisp(new Graphics());
|
||||
private grabbable = false;
|
||||
private lit = false;
|
||||
|
||||
constructor(readonly content: Widget) {
|
||||
super();
|
||||
// One target for over/out: moving onto the mark must not read as leaving the grip.
|
||||
this.interactiveChildren = false;
|
||||
this.addChild(this.dots, content);
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
const size = this.content.measure();
|
||||
return { w: Math.max(this.minWidth, size.w), h: Math.max(this.minHeight, size.h) };
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
this.content.resize(this.w, this.h);
|
||||
}
|
||||
|
||||
setGrabbable(on: boolean): void {
|
||||
if (this.grabbable === on) return;
|
||||
this.grabbable = on;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
setLit(on: boolean): void {
|
||||
if (this.lit === on) return;
|
||||
this.lit = on;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Whether the dots are drawn, and lit (test hook). */
|
||||
get dotsShown(): 'none' | 'idle' | 'lit' {
|
||||
return this.grabbable ? (this.lit ? 'lit' : 'idle') : 'none';
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
this.dots.clear();
|
||||
if (!this.grabbable) return;
|
||||
const top = Math.round(this.h / 2 - (3 * DOT + 2 * DOT_GAP) / 2);
|
||||
for (let col = 0; col < 2; col++)
|
||||
for (let row = 0; row < 3; row++)
|
||||
this.dots.rect(col * (DOT + DOT_GAP), top + row * (DOT + DOT_GAP), DOT, DOT);
|
||||
this.dots.fill(this.lit ? Theme.colors.text : Theme.colors.textMuted);
|
||||
this.dots.alpha = this.lit ? 1 : IDLE_ALPHA;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* What a sortable column shows before anything is dragged (#310): dots on every grip, and under the
|
||||
* pointer the grip lit and the block that would go with it backed.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { CanvasTextMetrics, FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||
import { Grip, HStack, Label, sortBlocks, Theme, VStack } from '../../src/ui';
|
||||
|
||||
vi.mock('../../src/ui/svg', () => ({ drawSvg: () => undefined, tintSvg: (svg: string) => svg }));
|
||||
|
||||
beforeAll(() => {
|
||||
CanvasTextMetrics.measureText = ((text: string) => ({
|
||||
width: text.length * 7,
|
||||
height: 14,
|
||||
lines: [text],
|
||||
lineWidths: [text.length * 7],
|
||||
})) as never;
|
||||
});
|
||||
|
||||
/** Three blocks: one row, two rows, one row. */
|
||||
function column(sortable = true) {
|
||||
const body = new VStack();
|
||||
const blocks = [1, 2, 1].map((size, index) => {
|
||||
const grip = new Grip(new Label(String(index), { width: 28 }));
|
||||
const rows = Array.from({ length: size }, (_, i) => {
|
||||
const row = new HStack();
|
||||
row.minHeight = Theme.rowHeight;
|
||||
if (i === 0) row.add(grip);
|
||||
return body.add(row);
|
||||
});
|
||||
return { grip, rows };
|
||||
});
|
||||
body.resize(200, 400);
|
||||
if (sortable) sortBlocks(blocks, () => {});
|
||||
return { body, blocks };
|
||||
}
|
||||
|
||||
const pointer = (type: string) => Object.assign(new FederatedPointerEvent(null as never), { type });
|
||||
const backs = (body: VStack) => body.children.filter((c) => c instanceof Graphics);
|
||||
|
||||
describe('the grips of a sortable column', () => {
|
||||
it('show their dots, and only where the rows may be dragged', () => {
|
||||
expect(column().blocks.map((b) => b.grip.dotsShown)).toEqual(['idle', 'idle', 'idle']);
|
||||
expect(column(false).blocks.map((b) => b.grip.dotsShown)).toEqual(['none', 'none', 'none']);
|
||||
});
|
||||
|
||||
it('light up under the pointer and back the whole block, behind its rows', () => {
|
||||
const { body, blocks } = column();
|
||||
const grip = blocks[1]!.grip;
|
||||
grip.emit('pointerover', pointer('pointerover'));
|
||||
expect(grip.dotsShown).toBe('lit');
|
||||
expect(blocks[0]!.grip.dotsShown).toBe('idle');
|
||||
const [back] = backs(body);
|
||||
expect(body.children.indexOf(back!)).toBe(0);
|
||||
const bounds = (back as Graphics).bounds;
|
||||
expect([bounds.minY, bounds.maxY]).toEqual([Theme.rowHeight, 3 * Theme.rowHeight]);
|
||||
});
|
||||
|
||||
it('go back to rest when the pointer leaves', () => {
|
||||
const { body, blocks } = column();
|
||||
const grip = blocks[1]!.grip;
|
||||
grip.emit('pointerover', pointer('pointerover'));
|
||||
grip.emit('pointerout', pointer('pointerout'));
|
||||
expect(grip.dotsShown).toBe('idle');
|
||||
expect(backs(body)).toEqual([]);
|
||||
});
|
||||
|
||||
it('let the pointer pass by while a block is dragged, and answer it again after', () => {
|
||||
const { body, blocks } = column();
|
||||
const [dragged, , passed] = blocks.map((b) => b.grip);
|
||||
const at = (type: string, y: number, button = 0) =>
|
||||
Object.assign(pointer(type), { button, global: { x: 0, y } });
|
||||
dragged!.emit('pointerdown', at('pointerdown', 0));
|
||||
dragged!.emit('globalpointermove', at('globalpointermove', 10));
|
||||
passed!.emit('pointerover', pointer('pointerover'));
|
||||
expect(blocks.map((b) => b.grip.dotsShown)).toEqual(['lit', 'idle', 'idle']);
|
||||
// The drop line is a Graphics too; a backing would sit behind the rows.
|
||||
expect(body.children[0]).not.toBeInstanceOf(Graphics);
|
||||
expect(blocks.map((b) => b.grip.cursor)).toEqual(['grabbing', 'grabbing', 'grabbing']);
|
||||
dragged!.emit('pointerupoutside', at('pointerupoutside', 10));
|
||||
expect(blocks.map((b) => b.grip.dotsShown)).toEqual(['idle', 'idle', 'lit']);
|
||||
expect(backs(body)).toHaveLength(1);
|
||||
expect(blocks.map((b) => b.grip.cursor)).toEqual(['grab', 'grab', 'grab']);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export async function structVariable(t) {
|
||||
click,
|
||||
clickText,
|
||||
drag,
|
||||
mouse,
|
||||
press,
|
||||
typeText,
|
||||
mutate,
|
||||
@@ -232,6 +233,16 @@ export async function structVariable(t) {
|
||||
const target = await json(`__gm.inspectorMark(${JSON.stringify(onto)})`);
|
||||
await drag(grip.x, grip.y, target.x, target.y - 6);
|
||||
};
|
||||
const count = await json('__gm.inspectorMark("count")');
|
||||
await mouse('mouseMoved', count.x, count.y);
|
||||
await mouse('mouseMoved', count.x + 1, count.y);
|
||||
await sleep(50);
|
||||
const lit = await json('__gm.inspectorMark("count")');
|
||||
check(
|
||||
'a mark that drags shows its dots, lit under the pointer',
|
||||
count.dots === 'idle' && lit.dots === 'lit',
|
||||
`${count.dots} -> ${lit.dots}`,
|
||||
);
|
||||
await dragMark('count', 'piece');
|
||||
check(
|
||||
'dragging a variable by its mark puts it in front of the struct, fields and all',
|
||||
|
||||
Reference in new issue
Block a user