diff --git a/extension/README.md b/extension/README.md
index 2ea03fa..5d7d787 100644
--- a/extension/README.md
+++ b/extension/README.md
@@ -63,9 +63,18 @@ src/
media/
src/ one TypeScript entry point per page, shared code in src/lib
css/ one stylesheet, all colours from --vscode-* variables
- icons/ container and panel icons
+ icons/ container and panel icons, plus vscodeos-icons.woff
```
+`vscodeos-icons.woff` exists because **none of codicon's 753 icons is a power
+symbol** — the nearest is `circle-slash`, a "no entry" sign — so the tray's power
+button draws IEC 5009 from a one-glyph font of our own, registered under
+`contributes.icons` and used as `$(vscodeos-power)`. It is generated by
+`media/icons/build-font.py` to codicon's metrics (300 units per em, 19-unit
+stroke) so it sits level with the `$(plug)` and `$(volume)` glyphs beside it. The
+font is committed; the generator needs `fonttools` and only runs if the glyph
+changes.
+
`src/webview/protocol.ts` is imported by both sides, so a change to a message
shape is a compile error in the webview that consumes it.
diff --git a/extension/media/icons/build-font.py b/extension/media/icons/build-font.py
new file mode 100644
index 0000000..5f27435
--- /dev/null
+++ b/extension/media/icons/build-font.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+"""Generate vscodeos-icons.woff, the shell's own icon font.
+
+VS Code's codicon set has no power symbol - the closest thing in all 753 names
+is `circle-slash`, a "no entry" sign - so the tray's power button draws its
+glyph from this font instead, registered through `contributes.icons` in
+package.json. VS Code accepts woff, woff2 or ttf there.
+
+The glyph is IEC 5009: a ring broken at the top with a vertical bar rising
+through the gap. It is drawn to codicon's own metrics so it sits level with the
+`$(plug)` and `$(volume)` glyphs beside it in the status bar:
+
+ units per em 300 (a 16 px icon box, so 18.75 units = 1 px)
+ ascender 300, descender 0
+ advance width 300
+ stroke 19 units, ~1 px, matching codicon's outlines
+ bounding box x 24..276, y 14..286, optically centred on (150, 150)
+
+Arcs are approximated with quadratic Beziers in 15 degree steps, which puts the
+worst-case radial error at 0.005 units - four thousandths of a pixel.
+
+Run after changing anything above; the woff is committed, so a normal build
+never needs this script or Python at all:
+
+ pip install fonttools brotli
+ python3 extension/media/icons/build-font.py
+"""
+
+from __future__ import annotations
+
+import math
+from pathlib import Path
+
+from fontTools.fontBuilder import FontBuilder
+from fontTools.pens.ttGlyphPen import TTGlyphPen
+
+UPM = 300
+CENTER_X = 150.0
+RING_CENTER_Y = 140.0
+STROKE = 19.0
+R_OUT = 126.0
+R_IN = R_OUT - STROKE
+R_MID = (R_OUT + R_IN) / 2
+CAP = STROKE / 2
+
+# Half-width of the gap at 12 o'clock. The bar subtends about 5 degrees at the
+# inner radius, so 21 leaves a clear 1 px of daylight on either side of it.
+GAP_HALF_DEG = 21.0
+
+BAR_BOTTOM = 145.0 # centreline; the round cap reaches 9.5 further down
+BAR_TOP = 276.5 # ...and 9.5 further up, to 286, clear of the ring at 266
+
+STEP_DEG = 15.0 # maximum sweep of a single quadratic segment
+
+# 2024-01-01, in the seconds-since-1904 that TrueType's head table counts in.
+EPOCH = 1704067200 + 2082844800
+
+
+def arc(pen_points: list, cx: float, cy: float, radius: float,
+ start_deg: float, end_deg: float) -> None:
+ """Append a circular arc as (off-curve, on-curve) quadratic segments.
+
+ The control point of a segment sits where the two end tangents meet, at
+ radius / cos(half the sweep) - the standard quadratic arc approximation.
+ """
+ sweep = end_deg - start_deg
+ steps = max(1, math.ceil(abs(sweep) / STEP_DEG))
+ delta = sweep / steps
+ half = math.radians(delta / 2)
+ control_radius = radius / math.cos(half)
+
+ for i in range(steps):
+ a0 = math.radians(start_deg + delta * i)
+ a1 = math.radians(start_deg + delta * (i + 1))
+ mid = (a0 + a1) / 2
+ pen_points.append(((cx + control_radius * math.cos(mid),
+ cy + control_radius * math.sin(mid)), False))
+ pen_points.append(((cx + radius * math.cos(a1),
+ cy + radius * math.sin(a1)), True))
+
+
+def broken_ring() -> list:
+ """The ring: outer edge round, a round cap, the inner edge back, a cap."""
+ start = 90 + GAP_HALF_DEG
+ end = 90 - GAP_HALF_DEG + 360
+
+ points: list = []
+ # Outer edge, anticlockwise from one side of the gap to the other.
+ points.append(((CENTER_X + R_OUT * math.cos(math.radians(start)),
+ RING_CENTER_Y + R_OUT * math.sin(math.radians(start))), True))
+ arc(points, CENTER_X, RING_CENTER_Y, R_OUT, start, end)
+
+ # Round the end: a half turn about the point on the centreline, taking the
+ # outline from the outer edge round to the inner one.
+ end_x = CENTER_X + R_MID * math.cos(math.radians(end))
+ end_y = RING_CENTER_Y + R_MID * math.sin(math.radians(end))
+ arc(points, end_x, end_y, CAP, end, end + 180)
+
+ # Inner edge, back the way we came.
+ arc(points, CENTER_X, RING_CENTER_Y, R_IN, end, start)
+
+ # ...and round the start the same way.
+ start_x = CENTER_X + R_MID * math.cos(math.radians(start))
+ start_y = RING_CENTER_Y + R_MID * math.sin(math.radians(start))
+ arc(points, start_x, start_y, CAP, start + 180, start + 360)
+
+ return points
+
+
+def bar() -> list:
+ """The vertical stroke: a stadium, drawn anticlockwise like the ring."""
+ points: list = [((CENTER_X + CAP, BAR_BOTTOM), True),
+ ((CENTER_X + CAP, BAR_TOP), True)]
+ arc(points, CENTER_X, BAR_TOP, CAP, 0, 180)
+ points.append(((CENTER_X - CAP, BAR_BOTTOM), True))
+ arc(points, CENTER_X, BAR_BOTTOM, CAP, 180, 360)
+ return points
+
+
+def draw(pen: TTGlyphPen, contour: list) -> None:
+ """Feed one closed contour of (point, on_curve) pairs to a TrueType pen."""
+ pen.moveTo(contour[0][0])
+ segment: list = []
+ for point, on_curve in contour[1:]:
+ segment.append(point)
+ if on_curve:
+ if len(segment) == 1:
+ pen.lineTo(segment[0])
+ else:
+ pen.qCurveTo(*segment)
+ segment = []
+ if segment:
+ pen.qCurveTo(*segment, None)
+ pen.closePath()
+
+
+def main() -> None:
+ pen = TTGlyphPen(None)
+ draw(pen, broken_ring())
+ draw(pen, bar())
+ power = pen.glyph()
+
+ blank = TTGlyphPen(None).glyph()
+
+ builder = FontBuilder(UPM, isTTF=True)
+ builder.setupGlyphOrder(['.notdef', 'power'])
+ builder.setupCharacterMap({0xE000: 'power'})
+ builder.setupGlyf({'.notdef': blank, 'power': power})
+
+ # A glyph's left side bearing has to be its own xMin: rasterisers phase the
+ # outline by the difference between the two, so getting this wrong slides
+ # the icon sideways rather than failing outright.
+ glyf = builder.font['glyf']
+ builder.setupHorizontalMetrics({
+ name: (UPM, getattr(glyf[name], 'xMin', 0)) for name in ('.notdef', 'power')
+ })
+ builder.setupHorizontalHeader(ascent=UPM, descent=0, lineGap=0)
+ builder.setupNameTable({
+ 'familyName': 'VS Code OS Icons',
+ 'styleName': 'Regular',
+ 'psName': 'VSCodeOSIcons-Regular',
+ 'version': '1.0',
+ 'copyright': 'MIT licensed, part of VS Code OS',
+ })
+ builder.setupOS2(sTypoAscender=UPM, sTypoDescender=0, sTypoLineGap=0,
+ usWinAscent=UPM, usWinDescent=0, achVendID='VSOS')
+ # Format 2.0, so the file names its own glyph and stays inspectable.
+ builder.setupPost(keepGlyphNames=True)
+
+ # head stamps the build time by default, which would make every run produce
+ # a different binary for the same glyph. Pin it, so re-running this script
+ # after an unrelated edit shows up as no diff at all.
+ head = builder.font['head']
+ head.created = head.modified = EPOCH
+
+ builder.font.flavor = 'woff'
+ out = Path(__file__).with_name('vscodeos-icons.woff')
+ builder.save(out)
+ print(f'{out} ({out.stat().st_size} bytes)')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/extension/media/icons/vscodeos-icons.woff b/extension/media/icons/vscodeos-icons.woff
new file mode 100644
index 0000000..92124cb
Binary files /dev/null and b/extension/media/icons/vscodeos-icons.woff differ
diff --git a/extension/package.json b/extension/package.json
index 7eaaeaa..1e71729 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -31,6 +31,15 @@
}
},
"contributes": {
+ "icons": {
+ "vscodeos-power": {
+ "description": "Power (IEC 5009)",
+ "default": {
+ "fontPath": "./media/icons/vscodeos-icons.woff",
+ "fontCharacter": "\\E000"
+ }
+ }
+ },
"viewsContainers": {
"activitybar": [
{
@@ -70,7 +79,7 @@
"command": "vscodeos.power.menu",
"title": "Power…",
"category": "VS Code OS",
- "icon": "$(circle-slash)"
+ "icon": "$(vscodeos-power)"
},
{
"command": "vscodeos.power.shutdown",
diff --git a/extension/src/statusbar/index.ts b/extension/src/statusbar/index.ts
index de67773..a3375cc 100644
--- a/extension/src/statusbar/index.ts
+++ b/extension/src/statusbar/index.ts
@@ -54,9 +54,11 @@ export class StatusBar implements vscode.Disposable {
this.clock = this.create('clock', PRIORITY.clock, 'vscodeos.calendar.show', 'Date and time');
this.power = this.create('power', PRIORITY.power, 'vscodeos.power.menu', 'Power');
- // No codicon is a power symbol; circle-slash is the closest that is
- // guaranteed to exist in every VS Code build.
- this.power.text = '$(circle-slash)';
+ // No codicon is a power symbol - the nearest of the 753 is circle-slash,
+ // which is a "no entry" sign - so this one comes from the extension's own
+ // icon font, registered under contributes.icons in package.json. It is
+ // drawn to codicon's metrics, so it sits level with its neighbours.
+ this.power.text = '$(vscodeos-power)';
this.power.tooltip = new vscode.MarkdownString('**Power** — sleep, restart or shut down');
this.music.on('change', (state: NowPlaying | undefined) => this.renderMedia(state));
diff --git a/rootfs-common/etc/skel/.config/openbox/rc.xml b/rootfs-common/etc/skel/.config/openbox/rc.xml
index aa9c996..f9e9cc7 100644
--- a/rootfs-common/etc/skel/.config/openbox/rc.xml
+++ b/rootfs-common/etc/skel/.config/openbox/rc.xml
@@ -100,14 +100,31 @@
8
200
0
-
-
-
-
-
-
-
+
+
diff --git a/scripts/build-extension.sh b/scripts/build-extension.sh
index 865daf8..031a804 100755
--- a/scripts/build-extension.sh
+++ b/scripts/build-extension.sh
@@ -117,7 +117,11 @@ install -m 0644 "${SRC_DIR}"/dist/*.js "${TARGET}/dist/"
install -d -m 0755 "${TARGET}/media/dist" "${TARGET}/media/css" "${TARGET}/media/icons"
install -m 0644 "${SRC_DIR}"/media/dist/*.js "${TARGET}/media/dist/"
install -m 0644 "${SRC_DIR}"/media/css/*.css "${TARGET}/media/css/"
+# The svg files are container/panel icons; the woff is the icon font behind
+# $(vscodeos-power). build-font.py, which generates it, is deliberately not
+# shipped - the font is committed and a build never regenerates it.
install -m 0644 "${SRC_DIR}"/media/icons/*.svg "${TARGET}/media/icons/"
+install -m 0644 "${SRC_DIR}"/media/icons/*.woff "${TARGET}/media/icons/"
version="$(node -p "require('${TARGET}/package.json').version")"
msg "VsCodeOsCore ${version} staged in ${TARGET} ($(du -sh "${TARGET}" | cut -f1))"