Compare commits
5 commits
b6ff4bca21
...
ce04cbdc92
| Author | SHA1 | Date | |
|---|---|---|---|
| ce04cbdc92 | |||
| 944a56800e | |||
| 32b6713269 | |||
| 9e9d4dd75d | |||
| 96fc77dc3e |
7 changed files with 482 additions and 52 deletions
|
|
@ -414,7 +414,8 @@ tags = ["all", "games"]
|
|||
</style>
|
||||
```
|
||||
|
||||
::: games-minecraft-zen-boykisser-gallery
|
||||
{.games-minecraft-zen-boykisser-gallery}
|
||||
:::
|
||||
|
||||
![][pic:01JGSDKAQ870V71XV9SSNE50R8]
|
||||
obviously, there's one in my bedroom.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -133,10 +133,23 @@ It was a fight to the death over who can win the most friends online in a room a
|
|||
It was a matter of creating the best multiplayer painting app, and swaying our whole community over to it.
|
||||
|
||||
{.wide}
|
||||
:::: figure
|
||||
|
||||
![MultiPixel running in the browser. There's a toolbar listing a bunch of tools on the left, including brushes, a flood fill, an airbrush, and smears. Next to that is a brush settings window, with a colour palette, and sliders for Size, Flow, and Smoothing. On the canvas there's a couple scribbles, including an at "@" and a tilde "~".][pic:01K3XBA43PZMKRC6VGJE0DYSB5]
|
||||
|
||||
{.overlay-bottom-right}
|
||||
::: figcaption
|
||||
|
||||
This is what MultiPixel looks like today.
|
||||
|
||||
It's got plenty of drawing tools.
|
||||
|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
Ultimately, I think MultiPixel won the battle.
|
||||
It had a browser version, a flood fill tool, and undo/redo, and that was enough to make the drawing experience _miles_ better than what NetCanv could ever hope to offer.
|
||||
It had a browser version, a flood fill tool, and an undo feature, and that was enough to make the drawing experience _miles_ better than what NetCanv could ever hope to offer.
|
||||
|
||||
The one thing NetCanv was better at was performance, being a native app written in OpenGL, with client-side, GPU-powered rendering of brushes.
|
||||
But raw performance is never nearly enough to win people over.
|
||||
|
|
@ -204,9 +217,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 +549,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 +586,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 +634,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -236,7 +236,8 @@ tags = ["all", "programming", "graphics", "javascript"]
|
|||
id = "01HQ162WWAS502000K8QZWVBDW"
|
||||
- we can split this tileset up into 16 individual tiles, each one 8 × 8 pixels; people choose various resolutions, I chose a fairly low one to hide my lack of artistic skill.
|
||||
|
||||
::: horizontal-tile-strip
|
||||
{.horizontal-tile-strip}
|
||||
:::
|
||||
[]{.metal .x-0 .y-0}
|
||||
[]{.metal .x-1 .y-0}
|
||||
[]{.metal .x-2 .y-0}
|
||||
|
|
@ -260,7 +261,8 @@ tags = ["all", "programming", "graphics", "javascript"]
|
|||
- the keen eyed among you have probably noticed that this is very similar to the case we had before with drawing procedural borders -
|
||||
except that instead of determining which borders to draw based on a tile's neighbors, this time we'll determine which _whole tile_ to draw based on its neighbors!
|
||||
|
||||
::: horizontal-tile-strip
|
||||
{.horizontal-tile-strip}
|
||||
:::
|
||||
[[E]{.east} [S]{.south}]{.metal .x-0 .y-0}
|
||||
[[E]{.east} [S]{.south} [W]{.west}]{.metal .x-1 .y-0}
|
||||
[[S]{.south} [W]{.west}]{.metal .x-2 .y-0}
|
||||
|
|
@ -307,7 +309,8 @@ tags = ["all", "programming", "graphics", "javascript"]
|
|||
id = "01HQ162WWABANND0WGT933TBMV"
|
||||
- that means we'll need to arrange our tiles like so, where the leftmost tile is at index 0 (`0b0000`) and the rightmost tile is at index 15 (`0b1111`):
|
||||
|
||||
::: horizontal-tile-strip
|
||||
{.horizontal-tile-strip}
|
||||
:::
|
||||
[]{.metal .x-3 .y-3}
|
||||
[[E]{.east}]{.metal .x-0 .y-3}
|
||||
[[S]{.south}]{.metal .x-3 .y-0}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ use crate::dirs::Dirs;
|
|||
use crate::state::FileId;
|
||||
use crate::state::Treehouse;
|
||||
use crate::vfs;
|
||||
use crate::vfs::Dir;
|
||||
use crate::vfs::ImageSize;
|
||||
use crate::vfs::VPathBuf;
|
||||
|
||||
use super::highlight::highlight;
|
||||
|
||||
|
|
@ -171,7 +173,14 @@ impl<'a> Writer<'a> {
|
|||
Container::Table => out.push_str("<table"),
|
||||
Container::TableRow { .. } => out.push_str("<tr"),
|
||||
Container::Section { .. } => {}
|
||||
Container::Div { .. } => out.push_str("<div"),
|
||||
Container::Div { class } => {
|
||||
if !class.is_empty() {
|
||||
out.push('<');
|
||||
write_attr(class, out);
|
||||
} else {
|
||||
out.push_str("<div");
|
||||
}
|
||||
}
|
||||
Container::Paragraph => {
|
||||
if matches!(self.list_tightness.last(), Some(true)) {
|
||||
return Ok(());
|
||||
|
|
@ -266,9 +275,6 @@ impl<'a> Writer<'a> {
|
|||
}
|
||||
|
||||
if attrs.into_iter().any(|(a, _)| a == "class")
|
||||
|| matches!(
|
||||
c,
|
||||
Container::Div { class } if !class.is_empty())
|
||||
|| matches!(c, |Container::Math { .. }| Container::List {
|
||||
kind: ListKind::Task,
|
||||
..
|
||||
|
|
@ -301,15 +307,6 @@ impl<'a> Writer<'a> {
|
|||
first_written = true;
|
||||
class.parts().for_each(|part| write_attr(part, out));
|
||||
}
|
||||
// div class goes after classes from attrs
|
||||
if let Container::Div { class } = c
|
||||
&& !class.is_empty()
|
||||
{
|
||||
if first_written {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(class);
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
|
|
@ -433,7 +430,15 @@ impl<'a> Writer<'a> {
|
|||
Container::Table => out.push_str("</table>"),
|
||||
Container::TableRow { .. } => out.push_str("</tr>"),
|
||||
Container::Section { .. } => {}
|
||||
Container::Div { .. } => out.push_str("</div>"),
|
||||
Container::Div { class } => {
|
||||
if !class.is_empty() {
|
||||
out.push_str("</");
|
||||
write_attr(class, out);
|
||||
out.push('>');
|
||||
} else {
|
||||
out.push_str("</div>");
|
||||
}
|
||||
}
|
||||
Container::Paragraph => {
|
||||
if matches!(self.list_tightness.last(), Some(true)) {
|
||||
return Ok(());
|
||||
|
|
@ -476,9 +481,28 @@ impl<'a> Writer<'a> {
|
|||
Container::Image(src, link_type) => {
|
||||
if self.img_alt_text == 1 {
|
||||
if !src.is_empty() {
|
||||
out.push_str(r#"" src=""#);
|
||||
out.push_str(r#"" "#);
|
||||
if let SpanLinkType::Unresolved = link_type {
|
||||
// TODO: Image size.
|
||||
let resolved_image =
|
||||
resolve_image_link(self.renderer.config, src);
|
||||
let size = if let Some(ResolvedImageLink::VPath(vpath)) =
|
||||
&resolved_image
|
||||
{
|
||||
vfs::query::<ImageSize>(&self.renderer.dirs.pic, vpath)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(size) = size {
|
||||
write!(
|
||||
out,
|
||||
r#" width="{}" height="{}""#,
|
||||
size.width, size.height
|
||||
)?;
|
||||
}
|
||||
|
||||
out.push_str(r#" src=""#);
|
||||
if let Some(resolved) = resolve_link(
|
||||
self.renderer.config,
|
||||
self.renderer.treehouse,
|
||||
|
|
@ -489,11 +513,14 @@ impl<'a> Writer<'a> {
|
|||
} else {
|
||||
write_attr(src, out);
|
||||
}
|
||||
out.push('"');
|
||||
} else {
|
||||
out.push_str(r#" src=""#);
|
||||
write_attr(src, out);
|
||||
out.push('"');
|
||||
}
|
||||
}
|
||||
out.push_str(r#"">"#);
|
||||
out.push('>');
|
||||
}
|
||||
self.img_alt_text -= 1;
|
||||
}
|
||||
|
|
@ -672,3 +699,30 @@ pub fn resolve_link(
|
|||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResolvedImageLink {
|
||||
VPath(VPathBuf),
|
||||
Url(String),
|
||||
}
|
||||
|
||||
impl ResolvedImageLink {
|
||||
pub fn into_url(self, config: &Config, pics_dir: &dyn Dir) -> Option<String> {
|
||||
match self {
|
||||
ResolvedImageLink::VPath(vpath) => vfs::url(&config.site, pics_dir, &vpath),
|
||||
ResolvedImageLink::Url(url) => Some(url),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_image_link(config: &Config, link: &str) -> Option<ResolvedImageLink> {
|
||||
link.split_once(':').and_then(|(kind, linked)| match kind {
|
||||
"def" => config.defs.get(linked).cloned().map(ResolvedImageLink::Url),
|
||||
"pic" => config
|
||||
.pics
|
||||
.get(linked)
|
||||
.cloned()
|
||||
.map(ResolvedImageLink::VPath),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@
|
|||
|
||||
:root {
|
||||
--transition-duration: 0.15s;
|
||||
|
||||
--ease-out-quintic: cubic-bezier(0.17, 0.84, 0.44, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
|
|
|||
|
|
@ -108,12 +108,49 @@ main.doc {
|
|||
}
|
||||
}
|
||||
|
||||
& p:has(img.pic) {
|
||||
text-align: center;
|
||||
& figure {
|
||||
margin: 0.5lh 0;
|
||||
|
||||
&.wide {
|
||||
grid-column: left-wide / right;
|
||||
position: relative;
|
||||
|
||||
& p:has(img.pic) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
& img {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& figcaption {
|
||||
padding: 0.25lh 0.5lh;
|
||||
text-align: center;
|
||||
|
||||
& p {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.overlay-bottom-right {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
background-color: var(--background-color);
|
||||
border-top-left-radius: 0.8rem;
|
||||
border-bottom-right-radius: 0.4rem;
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& p:has(img.pic),
|
||||
& figure p:has(img.pic) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
& .wide {
|
||||
grid-column: left-wide / right;
|
||||
}
|
||||
|
||||
& header {
|
||||
|
|
@ -171,8 +208,8 @@ main.doc {
|
|||
& .doc-text {
|
||||
--code-block-grid-space: 0;
|
||||
|
||||
& > pre,
|
||||
& > th-literate-program {
|
||||
& pre,
|
||||
& th-literate-program {
|
||||
/* Stretch to whole page.
|
||||
This way of doing it feels a bit brittle, though.
|
||||
It might be good to refactor this to CSS grid at some point. */
|
||||
|
|
@ -190,6 +227,17 @@ main.doc {
|
|||
tab-size: 2ch;
|
||||
}
|
||||
}
|
||||
|
||||
& figure figcaption {
|
||||
&.overlay-bottom-right {
|
||||
position: static;
|
||||
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -415,6 +415,39 @@ hr {
|
|||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
|
||||
button {
|
||||
padding: 0.2rem 1.2rem;
|
||||
|
||||
border: 1px solid var(--border-2);
|
||||
background-color: color-mix(in oklab, var(--background-color), white 25%);
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 1px 2px var(--border-1);
|
||||
|
||||
border-radius: 100px;
|
||||
|
||||
transition:
|
||||
background-color var(--transition-duration) var(--ease-out-quintic),
|
||||
box-shadow var(--transition-duration) var(--ease-out-quintic),
|
||||
transform var(--transition-duration) var(--ease-out-quintic);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 6px var(--border-1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--background-color),
|
||||
var(--shading-base) 5%
|
||||
);
|
||||
box-shadow: 0 0 2px var(--border-1);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Style the noscript box a little more prettily. */
|
||||
|
||||
.noscript {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue