netcanv: further updates, interactive UI example
This commit is contained in:
parent
9e9d4dd75d
commit
32b6713269
1 changed files with 301 additions and 25 deletions
|
@ -27,7 +27,7 @@ We switched to it as a more engaging alternative.
|
|||
Except, it was a nightmare.
|
||||
|
||||
Her laptop couldn't put up with it.
|
||||
She often complained that "her pen had inertia." Judging by the movements of her cursor, I predicted the app being terribly laggy on her side.
|
||||
She often complained that "her pen had inertia." Judging by the janky movements of her cursor, I predicted the app being terribly laggy on her side.
|
||||
|
||||
I knew there was no reason for it to be that slow.
|
||||
It's just a darn pixel canvas synchronising cursor events.
|
||||
|
@ -119,7 +119,7 @@ Another friend was keeping a client executable, but nobody had a working server,
|
|||
I was hoping that with NetCanv, I could create something similar.
|
||||
A little canvas that would bring people from our community together, except I wanted to improve on the original by making it infinite. ∞
|
||||
|
||||
When Aleksander heard the news, he decided he's gonna do a revival of MultiPixel.
|
||||
When Aleksander heard the news, he decided he was gonna do a revival of MultiPixel.
|
||||
This time persistent, in the browser, and with an infinite canvas.
|
||||
1000x technologically cooler than that shoddy prototype from years ago.
|
||||
|
||||
|
@ -204,9 +204,292 @@ Initially, rendering was handled by Skia, but I later rewrote it to OpenGL ES, t
|
|||
The layout is done in an immediate mode fashion.
|
||||
It is recalculated each frame based on a model of nesting rectangles. Content is rendered on top of the rectangles, and input is processed according to those rectangles' positions, to make up widgets such as buttons or text boxes.
|
||||
|
||||
State for certain widgets is kept explicitly by the user of the UI, and does not live inside the UI library (like it does in ImGui).
|
||||
Here is an interactive visualisation of how the immediate-mode layout system works.
|
||||
|
||||
Here's a skeleton of how you would build a UI using NetCanv's UI framework.
|
||||
``` =html
|
||||
<style>
|
||||
#netcanv\:ui-example {
|
||||
padding: 0.5lh 0;
|
||||
text-align: center;
|
||||
|
||||
position: relative;
|
||||
|
||||
&>svg {
|
||||
& g.stack {
|
||||
--ease: var(--ease-out-quintic);
|
||||
|
||||
& rect.bounds {
|
||||
transition:
|
||||
x var(--transition-duration) var(--ease),
|
||||
y var(--transition-duration) var(--ease),
|
||||
width var(--transition-duration) var(--ease),
|
||||
height var(--transition-duration) var(--ease);
|
||||
}
|
||||
|
||||
& circle.cursor {
|
||||
transition:
|
||||
cx var(--transition-duration) var(--ease),
|
||||
cy var(--transition-duration) var(--ease);
|
||||
}
|
||||
|
||||
& path.arrow {
|
||||
transition:
|
||||
transform var(--transition-duration) var(--ease);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&>.controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.8rem;
|
||||
|
||||
& hr {
|
||||
display: block;
|
||||
margin: 0;
|
||||
width: 0;
|
||||
align-self: stretch;
|
||||
border-right: 0.1rem solid var(--border-1);
|
||||
}
|
||||
}
|
||||
|
||||
&>.no-script {
|
||||
display: none;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background-color: color(from var(--background-color) srgb r g b / 0.75);
|
||||
border: 0.2rem solid var(--border-1);
|
||||
border-radius: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<noscript>
|
||||
<style>#netcanv\:ui-example > .no-script { display: flex; }</style>
|
||||
</noscript>
|
||||
|
||||
<div id="netcanv:ui-example">
|
||||
<svg width="320" height="240" xmlns="http://www.w3.org/2000/svg">
|
||||
</svg>
|
||||
|
||||
<div class="controls">
|
||||
<button name="reset">Reset</button>
|
||||
<hr>
|
||||
<button name="push-h">Push Horizontal</button>
|
||||
<button name="push-v">Push Vertical</button>
|
||||
<button name="pop">Pop</button>
|
||||
<hr>
|
||||
<button name="space">Space</button>
|
||||
<button name="pad">Pad</button>
|
||||
<button name="fit">Fit</button>
|
||||
<hr>
|
||||
<button name="fill">Fill</button>
|
||||
</div>
|
||||
|
||||
<div class="no-script">
|
||||
<p>You'll have to enable JavaScript to play with this example.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" async>
|
||||
let root = document.getElementById("netcanv:ui-example");
|
||||
let svg = root.querySelector("svg");
|
||||
let gDraw, gStack;
|
||||
|
||||
function createSvg(type) {
|
||||
return document.createElementNS("http://www.w3.org/2000/svg", type);
|
||||
}
|
||||
|
||||
let stack = [];
|
||||
|
||||
function getColor(i) {
|
||||
return [
|
||||
"var(--accent-red)",
|
||||
"var(--accent-yellow)",
|
||||
"var(--accent-green)",
|
||||
"var(--accent-blue)",
|
||||
"var(--accent-purple)",
|
||||
][i % 5];
|
||||
}
|
||||
|
||||
function renderStack() {
|
||||
for (let i in stack) {
|
||||
let group = stack[i];
|
||||
let color = getColor(i);
|
||||
|
||||
let g = gStack.childNodes[i] ?? gStack.appendChild(createSvg("g"));
|
||||
|
||||
let rect = g.querySelector("rect.bounds") ?? g.appendChild(createSvg("rect"));
|
||||
rect.classList.add("bounds");
|
||||
rect.style.x = `${group.x}px`;
|
||||
rect.style.y = `${group.y}px`;
|
||||
rect.style.width = `${group.width}px`;
|
||||
rect.style.height = `${group.height}px`;
|
||||
rect.setAttribute("fill", "none");
|
||||
rect.setAttribute("stroke", color);
|
||||
rect.setAttribute("stroke-width", "2");
|
||||
|
||||
let cursor = g.querySelector("circle.cursor") ?? g.appendChild(createSvg("circle"));
|
||||
cursor.classList.add("cursor");
|
||||
cursor.setAttribute("cx", `${group.x + group.cursorX}`);
|
||||
cursor.setAttribute("cy", `${group.y + group.cursorY}`);
|
||||
cursor.setAttribute("r", `4`);
|
||||
cursor.setAttribute("fill", color);
|
||||
|
||||
let arrow = g.querySelector("path.arrow") ?? g.appendChild(createSvg("path"));
|
||||
arrow.classList.add("arrow");
|
||||
arrow.setAttribute("d", "M-8,0 L8,0 M4,-4 l4,4 l-4,4");
|
||||
arrow.setAttribute("transform", `
|
||||
translate(${group.x + group.width / 2} ${group.y + group.height / 2})
|
||||
rotate(${group.direction == "h" ? 0 : 90})
|
||||
`);
|
||||
arrow.setAttribute("fill", "none");
|
||||
arrow.setAttribute("stroke", color);
|
||||
arrow.setAttribute("stroke-width", "2");
|
||||
}
|
||||
|
||||
while (gStack.childNodes.length > stack.length)
|
||||
gStack.removeChild(gStack.lastChild);
|
||||
}
|
||||
|
||||
function push(width, height, direction) {
|
||||
if (stack.length == 0) {
|
||||
stack.push({ x: 8, y: 8, width: 320 - 16, height: 240 - 16, cursorX: 0, cursorY: 0, direction });
|
||||
renderStack();
|
||||
return;
|
||||
}
|
||||
let parent = stack[stack.length - 1];
|
||||
stack.push({
|
||||
x: parent.x + parent.cursorX,
|
||||
y: parent.y + parent.cursorY,
|
||||
width,
|
||||
height,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
direction,
|
||||
});
|
||||
renderStack();
|
||||
}
|
||||
|
||||
function pop() {
|
||||
let group = stack.pop();
|
||||
let parent = stack[stack.length - 1];
|
||||
if (group && parent) {
|
||||
if (parent.direction == "h")
|
||||
parent.cursorX += group.width;
|
||||
else
|
||||
parent.cursorY += group.height;
|
||||
}
|
||||
renderStack();
|
||||
}
|
||||
|
||||
function space(amount) {
|
||||
let group = stack[stack.length - 1];
|
||||
if (group) {
|
||||
if (group.direction == "h")
|
||||
group.cursorX += amount;
|
||||
else
|
||||
group.cursorY += amount;
|
||||
}
|
||||
renderStack();
|
||||
}
|
||||
|
||||
function pad(amount) {
|
||||
let group = stack[stack.length - 1];
|
||||
if (group) {
|
||||
group.x += amount;
|
||||
group.y += amount;
|
||||
group.width -= amount * 2;
|
||||
group.height -= amount * 2;
|
||||
}
|
||||
renderStack();
|
||||
}
|
||||
|
||||
function fit() {
|
||||
let group = stack[stack.length - 1];
|
||||
if (group) {
|
||||
if (group.direction == "h") group.width = group.cursorX;
|
||||
if (group.direction == "v") group.height = group.cursorY;
|
||||
}
|
||||
renderStack();
|
||||
}
|
||||
|
||||
function fill() {
|
||||
let i = stack.length - 1;
|
||||
let group = stack[stack.length - 1];
|
||||
if (group) {
|
||||
let color = getColor(i);
|
||||
let rect = gDraw.appendChild(createSvg("rect"));
|
||||
rect.classList.add("fill");
|
||||
rect.style.x = `${group.x}px`;
|
||||
rect.style.y = `${group.y}px`;
|
||||
rect.style.width = `${group.width}px`;
|
||||
rect.style.height = `${group.height}px`;
|
||||
rect.setAttribute("fill", `color-mix(in oklab, ${color}, white 75%)`);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
svg.replaceChildren();
|
||||
gDraw = svg.appendChild(createSvg("g"));
|
||||
gStack = svg.appendChild(createSvg("g"));
|
||||
gStack.classList.add("stack");
|
||||
stack = [];
|
||||
push(320, 240, "v");
|
||||
}
|
||||
|
||||
reset();
|
||||
|
||||
root.querySelector("button[name='reset']").onclick = reset;
|
||||
root.querySelector("button[name='push-h']").onclick = () => push(32, 32, "h");
|
||||
root.querySelector("button[name='push-v']").onclick = () => push(32, 32, "v");
|
||||
root.querySelector("button[name='pop']").onclick = pop;
|
||||
root.querySelector("button[name='space']").onclick = () => space(8);
|
||||
root.querySelector("button[name='pad']").onclick = () => pad(8);
|
||||
root.querySelector("button[name='fit']").onclick = fit;
|
||||
root.querySelector("button[name='fill']").onclick = fill;
|
||||
</script>
|
||||
```
|
||||
|
||||
Try the following sequence of instructions:
|
||||
|
||||
1. Fill
|
||||
1. Pad
|
||||
1. Push Horizontal
|
||||
1. Push Horizontal
|
||||
1. Fill
|
||||
1. Pop
|
||||
1. Space
|
||||
1. Push Horizontal
|
||||
1. Fill
|
||||
1. Pop
|
||||
1. Pop
|
||||
|
||||
At any given moment, there is a stack of active rectangles.
|
||||
Each rectangle has either a horizontal or a vertical layout (→ or ↓ in the visualisation), which determines the direction in which elements are laid out inside it.
|
||||
|
||||
The position at which elements are placed is determined by a _cursor_ (indicated by a dot in the visualisation), which is a point relative to the rectangle's top-left corner.
|
||||
This cursor advances in the rectangle's direction whenever a child rectangle is popped off the stack, which is what enables laying out elements next to each other.
|
||||
|
||||
A notable thing is that the cursor can go _outside_ the current rectangle's bounds, and the rectangle's bounds can be expanded later (the *Fit* button).
|
||||
This sounds like it would be incredibly useful, but in practice it rarely is.
|
||||
Since there's no way to draw a background behind what was already drawn, it is limited to drawing overlays on top of existing content.
|
||||
|
||||
NetCanv's toolbars use buttons without a background---the hover state is drawn as an overlay over existing content, which makes it usable in that situation, but not much more beyond that.
|
||||
|
||||
Extra state for widgets that need it is kept explicitly by the user of the UI, and does not live inside the UI library (like it does in ImGui).
|
||||
|
||||
Here's a skeleton of how you would build a UI using NetCanv's framework.
|
||||
|
||||
```rust
|
||||
// This is a struct representing the app's state.
|
||||
|
@ -253,36 +536,27 @@ impl AppState for State {
|
|||
hint: None,
|
||||
};
|
||||
|
||||
// Layout is done by pushing and popping rectangles onto a stack.
|
||||
// Each rectangle has a "layout" property, which indicates the direction
|
||||
// in which child rectangles are laid out.
|
||||
// The current rectangle has a _cursor_, which is advanced in the layout
|
||||
// direction with each rectangle popped off the stack.
|
||||
// If you played around with the example above, you should find the
|
||||
// push(), space(), and pop() operations familiar.
|
||||
// TextField::with_label makes use of them internally, which is how it
|
||||
// ends up interacting with the rest of the layout.
|
||||
ui.push(
|
||||
(ui.width(), TextField::labelled_height(text_field.font)),
|
||||
Layout::Horizontal,
|
||||
);
|
||||
|
||||
// Widgets use that same rectangle stack internally, and thus end up
|
||||
// appending to the current rectangle.
|
||||
self.nickname_field.with_label(
|
||||
ui,
|
||||
input,
|
||||
&self.assets.sans, // font
|
||||
"Nickname", // text
|
||||
"Nickname", // label
|
||||
textfield, // args
|
||||
);
|
||||
|
||||
// Spacing is handled by advancing the cursor in the layout direction.
|
||||
// There's also a pad() function, which shrinks the current rectangle
|
||||
// around the edges.
|
||||
ui.space(16.0);
|
||||
|
||||
self.relay_field.with_label(
|
||||
ui,
|
||||
input,
|
||||
&self.assets.sans, // font
|
||||
"Relay server", // text
|
||||
"Relay server", // label
|
||||
textfield, // args
|
||||
);
|
||||
ui.pop();
|
||||
|
@ -299,7 +573,7 @@ impl AppState for State {
|
|||
Verbose, yeah.
|
||||
But I will admit, it _is_ a pretty simple UI framework internally.
|
||||
|
||||
In reality, it proved to be rather limiting, though.
|
||||
In reality though, it proved to be rather limiting.
|
||||
There are certain places where you _have_ to know the size of a widget ahead of time to lay things out correctly---including centering things on the screen---and this framework simply doesn't have that.
|
||||
It _can't_ have that, because it is drawing things as they're being laid out.
|
||||
|
||||
|
@ -347,17 +621,19 @@ I just calculate it manually. :hueh:
|
|||
|
||||
There's loads of other problems with this UI framework, though those mostly stem from just _not ever being done_ rather than being fundamental deficiencies.
|
||||
|
||||
There's a lack of screen reading support. (Though whether that's useful in a painting app is debatable.)
|
||||
While writing NetCanv, I didn't have a HiDPI screen on me, so the app naturally doesn't support HiDPI.
|
||||
This is why the screenshots are so horribly blurry---I now have a 4k display at home, and had to fiddle with running the app through Xwayland, as well as making KDE do the scaling by itself.
|
||||
|
||||
While writing NetCanv, I also didn't have a HiDPI screen on me, so the app naturally doesn't support HiDPI.
|
||||
How frustrating now that I have a 4k display at home, as well as a HiDPI laptop.
|
||||
Remember that NetCanv was made before I had a job though.
|
||||
I just couldn't afford that sort of hardware!
|
||||
|
||||
What I would do differently next time, is to decouple the layout step from the rendering step.
|
||||
There's also a lack of screen reading support. (Though whether that's useful in a painting app is debatable.)
|
||||
|
||||
What I would do differently next time with the overall basic framework, is to decouple the layout step from the rendering step.
|
||||
Layout _really_ ought to be done separately, if you want a UI framework that's internally simple, as well as easy to use.
|
||||
How I would do that in Rust, I'm not sure.
|
||||
|
||||
I chose the immediate mode paradigm mainly because it lended itself well to the borrow checker---and I think it was a good choice for that reason---but frankly its limitations were a bit frustrating at times.
|
||||
I chose the immediate mode paradigm mainly because it lent itself well to the borrow checker---and I think it was a good choice for that reason---but frankly its limitations were a bit frustrating at times.
|
||||
|
||||
Of course one could debate the borrow checker's helpfulness in this situation, but I don't want to get into it.
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue