-
Notifications
You must be signed in to change notification settings - Fork 1
VisualEditor
🌐 This page in: English · Português
A first-party VS Code extension that turns the SDK into a visual development environment: a screen renders beside the file that produces it, from the buffer you are typing in, every rendered element knows which C# expression built it, and selecting one shows — and edits — that expression.
The preview is the real web realizer running the real compiled module. It is not a lookalike, and that is the point: a preview that renders is evidence the page renders. Nothing new draws pixels.
Status. The preview, the identity mechanism, selection, the property inspector and the structural gestures (insert, move, duplicate, remove, and dragging between containers) are in place and covered — by unit tests against a real project compilation, and by an integration suite that runs inside a real VS Code. It packages into an installable
.vsix; it is not on the Marketplace yet. Track E in the Roadmap has the milestones.
Since 0.2.0-preview.30
eqdesign is a long-lived process that holds the project's Roslyn compilation and compiles the
editor's buffer — not the file on disk. That distinction is the whole reason it exists: eqc
reads files and the hot-reload service watches the filesystem, so neither can show you the text you
are currently looking at, only the text you last saved.
It speaks newline-delimited JSON over stdio. No port to choose, nothing to authorise, nothing left listening if the window dies, and it works unchanged over Remote-SSH because the extension host runs on the remote machine anyway.
Measured on samples/DefaultUIDashboard (16 source files, 316 references):
| Step | Time |
|---|---|
| Build the compilation, once at activation | 271 ms |
Compile an edit (662-line PaymentsPage, warm) |
p50 293 ms |
| Diagnostics only (bind, no transpile) | 36 ms |
Hence two cadences: errors are re-checked after a 150 ms pause in typing, the preview recompiles after 400 ms. A C# error stops the compile before anything is emitted — Roslyn parses leniently, so a missing brace still yields a tree the transpiler would walk, producing a module that mounts and throws, which arrives on screen as a blank frame with nothing to explain it.
The host refuses to start if the reference list is empty, rather than compiling against an incomplete model. That failure is otherwise invisible: without the semantic model, named arguments are emitted in syntactic order, and the screen renders with its values in the wrong slots and no error anywhere.
A screen is rarely one file. A shell, a row, a data helper — so the extension sends every C# buffer the editor currently holds, not only the active one, and the host overlays all of them onto the compilation before it compiles. Otherwise the preview would show you the last saved version of everything except the file you happen to be typing in.
Since 0.2.0-preview.30
VisualNode carries an Origin — the source span of the expression that constructed it, or null,
which is what every shipped build is. A design-mode compilation fills it in:
var compiler = new ComponentCompiler { DesignMode = true };
// Every node construction is emitted wrapped, so the built node remembers where it came from:
// $eq.origin(UI.text(…), "…/PaymentsPage.cs|28:12|28:73")Both realizers carry it into the DOM as data-eq-origin, attached at their single dispatch:
<div data-eq-origin="/src/Screens/PaymentsPage.cs|27:8|34:10">
<span data-eq-origin="/src/Screens/PaymentsPage.cs|28:12|28:73">Count: 0</span>
</div>The format is path|startLine:startColumn|endLine:endColumn, zero-based — the editor's own
coordinates.
Why not source maps. The obvious answer does not work. The whole Build body is converted to one
flat string and emitted through a single call, so the finest position a V3 map can name is the start
of the method: one shipped module has a 942-character line. An origin is exact instead — and because
it is a plain string on the abstract node, the native (Photon) track inherits the same mechanism the
day it wants it.
Only constructions are stamped, never references. Stamping a variable where it is merely mentioned
would overwrite the construction's origin with the span of a use, and select the wrong line. A node
built inside a foreach carries the span of the one expression that builds it — which is the only
editable thing there anyway.
Design mode is off by default and never on in an SDK build: the wrapper is real emitted code, and
shipping it would put a design tool's concern in every user's bundle. A production render contains no
data-eq-origin at all, and the SSR/hydration fingerprint test pins that.
Since 0.2.0-preview.30
An origin says where a node came from. It does not say whether that place can be edited, and treating
the two as one question is how a visual editor corrupts a file. A row built five hundred times inside
a foreach has one source site and no separate existence; a node returned by a helper method is
written somewhere else entirely. "Delete this row" is wrong for both.
So every selected node is classified, by walking Roslyn's ancestors from the origin's position:
| Tier | Where it is written | What the canvas offers |
|---|---|---|
literal |
Unconditionally, in the Build being previewed |
everything, including structural edits |
derived |
Inside a loop, a conditional or a callback | select, inspect, edit properties |
foreign |
Another member, a local function, or another file | select, inspect, and jump to where it is written |
A local function counts as its own member here, because a node built there is written once and reached by a call — exactly like a helper method.
Every refusal explains itself before the affordance is drawn, never after it is clicked:
Built inside a loop — every repetition comes from this one expression, so there is no single row to move or delete.
Built by
PaymentRow(), not byBuild().
The distinction that matters in practice: understanding is not fenced, only writing is. The editor opens code that already exists, and real code splits itself across helper methods and files. Selecting, inspecting and editing a property work wherever the node was written, including in another file, which is why the host is sent every open buffer. Structural insertion is what stays inside the declarative form.
Since 0.2.0-preview.30
Selecting an element opens a docked panel, in the editor's own chrome, listing what the component takes: each property's type, the doc comment read off the source symbol, and how it is currently written — as an argument, in an object initializer, or not at all.
Edits are written back as C# source, because the file is C#. An enum offers its members as a list
rather than a text box; everything else is a value you type, and 12, "Save" and
Variant.Secondary are all just expressions.
Three things keep that honest:
- It is compiled before it is offered. A candidate edit is parsed and bound, and a refusal quotes the compiler: That would not compile: CS0117 ….
-
It goes through
WorkspaceEdit, never a file write. The change lands in the document's own undo stack, so one Ctrl+Z reverses the whole gesture and an unsaved buffer stays unsaved. -
It never rewrites the form the file is written in. A component's init-only members —
Button'sLoading,FlexNode'sWidth— are reachable only through an object initializer. On a factory call the honest answer is "not from here, and here is why": the factory surface exists precisely so nobody writesnew Button("Save") { … }, and turning one into the other behind the author's back is not an edit they asked for. A construction already written withnewhas chosen that form, so its braces are simply added when something needs to go in them.
A closed set is offered as a list, and the type is not the only thing that decides what is closed.
Half the framework's tokens are not enums: Space is a static class of const float, so a float gap
reads as a number. What is written there says otherwise — Space.S3 names a member of a static
class, and every sibling of the same type belongs in that slot, so the row opens as the scale it came
from. A plain gap: 7 stays a number, because nothing there names a scale.
A value is not a string. A BoxStyle would otherwise arrive as one long line of C# in a single
cell — the thing an author most wants to change, in the one place they cannot change it. A value
written as new T { … } reports its members as ordinary properties, and the panel renders them as a
small sheet under the row that carries them:
style BoxStyle 2 of 9 set
│ Padding EdgeInsets EdgeInsets.All(Space.S4) ×
│ Background ColorToken context.Theme.Surface ×
│ [ BorderWidth ▾ ] float + set
Keys on the left, values in the editor's own input, and a last row whose key is a select of
everything not set yet. Taking a member back out is a row's ×: a sheet you can only add rows to
leaks. Everything here is the same property machinery one level down — the same enum lists, the same
compile guard, the same refusal when the value is written without an initializer to set it in.
A Grid's columns is a collection expression whose elements have real spans, exactly like a
children — and so are a menu's items and a dialog's actions. All of them get what children got:
the panel lists them, and inserting or removing takes the list's name.
What differs is what goes in them. A GridTrack is data: it never renders, so nothing on the
canvas carries its span and there is nothing to click. Those lists are therefore addressed by position
from the panel, and their palette is the element type's own ways of being written —
GridTrack.Flex(), GridTrack.Auto, and target-typed new(…), since the framework gives value
records no factory on purpose. Both facts are read off the signature rather than written down as a
rule about grids.
Since 0.2.0-preview.30
Structural gestures are fenced to the declarative form — a children: [ … ] collection
expression:
Column(gap: Space.S4, children: [
Text("Total", TypeRole.LabelM, context.Theme.TextSecondary),
Text(total, TypeRole.TitleM, context.Theme.TextPrimary),
])That list is the only shape an insertion can be spliced into safely: its elements have real spans, so
a new one goes between two of them without touching anything else. A container built with statements
(var column = new Column(); column.Add(…);) is dataflow, not a list, and rearranging it is a
different problem. The panel asks first and says so rather than offering a control that refuses.
-
Insert comes from a palette derived from the factory surfaces themselves, so a new component
appears in it without anyone maintaining a list. That includes the app's own components: the
generator writes them an
AppUIsurface exactly like the framework's, and they are offered first, under their own heading. Every entry is proven to compile where it lands — a test inserts each one into a real list and fails on any that would not. - Move swaps two elements and leaves everything between them exactly where it is: the comma, the newline, the indentation. Rewriting the list from its element texts would be simpler and would silently discard a comment someone left between two children. One imperfection remains, and it is named rather than hidden: a comment written above a child stays with the position, not with the child.
- Remove takes the element and exactly one separator. The element alone leaves a stray comma; both leave a hole.
Neither move nor remove is offered where it means nothing: a node that is not an element of a list has no order to be moved within, so the controls are hidden rather than disabled — there is nothing to explain about an arrangement that does not exist.
- Duplicate copies a node's text and lands the copy immediately after it — and the copy is what stays selected, because it is the thing you just made.
Each gesture is a single WorkspaceEdit, so each is one undo.
The whole screen is also a tree, in the Explorer beside the Outline. Each row says where it is written
— PaymentsPage.cs:28 — which is the one thing a list can say that a picture cannot, and it reaches
what the canvas cannot: a container filled edge to edge by its own children has no pixel of its own.
Picking a row selects it on the canvas, through the same path a click takes, so the outline, the panel and the editor's cursor all follow. Selecting on the canvas marks the row.
Every gesture above is also a key, and they are the same gesture rather than a second implementation of it:
| Arrows | walk the tree — up to the parent, down to the first child, left and right along the siblings |
| Alt+Arrows | move the node instead of the selection |
| Delete | remove it |
| Cmd/Ctrl+D | duplicate it |
| Esc | let go — first of the selection, then of inspect mode |
They respect the same guards the pointer does: nothing structural while the tree is settling, and nothing at all while the pointer is over the panel, where those keys are someone typing.
The same three gestures are on the pointer, where the tree is legible:
- Drag a child to reorder it. A caret marks the gap it would land in, and the badge says where it is going — Text → 3 of 5. However far it travels, it is one edit and one undo.
-
Hover a child and a
+appears at each end of it — before this one, after this one — along the container's own axis. Which axis is read off the rendered result rather than the CSS: aRowis flex, aGridis grid and aStackis absolute, and on screen all three answer "are these side by side?" the same way. (With fewer than two children there is no result to read, and only there does the canvas ask the layout itself.) -
Hover a container's own space, including an empty one, and the gap nearest the pointer is marked
with a dashed line and a single
+. That mark is the answer to where could something go, which is a question a canvas should never make anyone guess at. The dash is deliberate: the caret marks a commitment, the dash marks a possibility.
A pile is not a row. A Stack's children overlap, and paint order is child order — so its list is
meaningful and its geometry is not. The canvas cannot tell (two overlapping children look exactly like
two nobody laid out), so the host says which lists are layered. There, the drop mark becomes a cover
over the child the node would come to rest in front of, the badge reads in front of Card rather than
3 of 5, and the panel's arrows keep their gesture while dropping the wrong name for it: Send
backward and Bring forward.
A drag is not confined to the list it started in: the drop target is recomputed from the pointer on
every move, so the caret follows into whatever container it is over. Leaving its own list is a remove
and an insert, which the host writes as one replacement spanning both — one edit, one Ctrl+Z. The
moved text is re-indented to its new depth and otherwise carried across verbatim, which is what makes
the compiler the fence: a node written against a local of the method it came from does not compile
in its new home, and the refusal quotes CS0103 rather than inventing a rule about what is portable.
Refusals are drawn before the gesture rather than after it. The screen and the C# list have to line up child for child — a child that renders as several elements, or as none, breaks that correspondence, and an index computed from pixels would then name a different element in the file, so the affordance is withheld rather than guessed. A node cannot be dropped into its own subtree, into a container that takes no list, or into a list in another file — that would edit two documents at once.
A move invalidates the span the panel was holding, so the host answers with where the node landed and the selection follows it there. Without that, asking about the old coordinates describes the sibling it was just swapped with.
The preview does not care which SDK the project ships through. A native (Photon) project — which
has no wwwroot and never runs eqc — previews all the same: the native SDK writes the reference list
for tooling, the extension carries the browser runtime the project legitimately lacks, and the same
abstract component tree renders through the web realizer. The toolbar says exactly that — native
project · web realizer — and opens in the phone shell, because that is the shape the app ships in. A
default, not a cage: once a hand touches the format selector, the project stops having an opinion.
A counter clicked to 7 stays at 7 through the next keystroke. Before each remount the page's own
fields are captured — told apart from the framework's by probing the page's own base class, not a
hand-written list — and handed back through the same __INITIAL_STATE__ door the SSR handoff uses.
Guarded by class name, so a renamed page starts fresh instead of inheriting a stranger's fields.
The cube button on the toolbar renders the current buffer through the native engine and shows the frame — real glyphs, real theme, at the format the selector has chosen. These are the pixels the GPU backends are pinned against: the frame comes from the Reference rasterizer, the engine's normative pixel source, and a standing parity test proves Metal draws the same display list to the same pixels.
It runs in a disposable child process, because a page's Build() is the author's code: one that
never returns is killed after ten seconds and comes back as a sentence, with the preview still alive
behind it. Services a page's constructor asks for are satisfied the way the DI container would,
minus the container — an implementation from the app's own assembly when there is one, a benign stub
otherwise — and a page that still throws renders the framework's own diagnostic surface, naming the
component and the exception.
A still, deliberately: interactions stay on the live preview, and a recompile dismisses a frame of the page it replaced.
Package it once and install the .vsix:
cd extensions/vscode && npm install && npm run packageThat compiles the extension, publishes the design host into it and writes equantic-ui.vsix
(10.7 MB) — the host travels with the extension, so an install needs no checkout. It is
framework-dependent: a self-contained publish would be ~70 MB per platform and three of them, and the
one dependency this assumes is the .NET runtime you are already building against.
Working ON the extension instead, npm run compile is enough — in development the repository's own
build of the host wins over a packaged one, so a .vsix built yesterday cannot quietly answer for
today's source.
Then open a C# page or component and run eQuantic UI: Open Preview. The project must have been
built once — the preview stands on an ordinary build's output (the reference list the SDK writes, and
wwwroot/_equantic/runtime.js), so "build the project once" is the honest instruction when a piece is
missing.
The preview's own controls live in the editor title bar, contributed as ordinary commands, so they inherit the theme and the icon set like any other editor action: the pointer starts and stops inspecting, and a restart is there for when the host has to be brought back.
- No dragging between files. A node can be dragged into any list in the file being edited, but a container written in another one is refused: that edit would touch two documents at once.
-
GridandStackneed their own gesture. A Grid's columns are tracks whose addition re-flows every child, and a Stack's list order is paint order — neither is the "insert a sibling" gesture the other containers share. -
No native preview. Identity is already solved on Photon (
LayoutNodecarries the node, absolute bounds and a stable path); only getting frames into a webview is missing. -
No
[ServerAction]calls. Action ids come from a startup assembly scan, so an ad-hoc preview class is never in the registry. - The baseline theme. An app selects its theme at startup, and reading that back means running the app's composition root. Shapes and layout are exact; a rebranded palette is not yet reflected.
- Debugging & Development Tools — the logger and error overlay the preview reuses.
- Compiler — how C# becomes the module the preview runs.
- Roadmap — Track E and what is ahead of it.
🌐 English · Português
🏁 Start here
📱 Write-once
- Write-Once Components
- Declarative Surface
- Photon Engine
- Design System
- Capabilities
- Storage
- Forms
- Code Editor
- Markdown
- Mermaid
- Email Rendering
🏗️ Architecture
⚙️ Compilation
- Compiler
- Compile-Time Evaluation
- Supported C# Features
- External Type Resolution
- Build Flow
- Diagnostics
⚡ Runtime
🔌 Server
🎨 Ecosystem
🚀 Development