OXYGENHUB

HaikuDepot icons for oxy and oh (HVIF and PNGs) and the script that draws them (refs #157) 16aa9e03d85b

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e762beeaa0bb
When
· 2026-10-29 17:58 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
16aa9e03d85bcc1285bb49bf98460ad57077aadefdfdb6fd3cc85b13e0965a2c
Depends on
045340293dc7 8fd78eb52ad2 a42f9fb47e37 ed931d1d1102
Depended on by
135bdcadec4a 2f0b4b0651ea c397a8c36a9f 6f4226bfb87f 91334b7fa574
In bookmarks
main
In tags
v0.10.0 v0.11.0
Edits
13 atoms, 12 tree operations, 13 files

Browse the tree at this change

added packaging/haikuports/icons/oh-16.png +0 −0

No line changes.

added packaging/haikuports/icons/oh-32.png +0 −0

No line changes.

added packaging/haikuports/icons/oh-64.png +0 −0

No line changes.

added packaging/haikuports/icons/oh.hvif +0 −0

No line changes.

added packaging/haikuports/icons/oh.svg +17 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2 +
  <defs>
3 +
    <linearGradient id="g" gradientUnits="userSpaceOnUse" x1="-22" y1="-11" x2="22" y2="11">
4 +
      <stop offset="0%" stop-color="#00F0FF"/>
5 +
      <stop offset="100%" stop-color="#0072FF"/>
6 +
    </linearGradient>
7 +
  </defs>
8 +
  <rect width="64" height="64" rx="14" fill="#070c18"/>
9 +
  <g transform="translate(32 32)">
10 +
    <path d="M -11 -6 L 11 -6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
11 +
    <path d="M -11 6 L 11 6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
12 +
    <circle cx="-11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
13 +
    <circle cx="-11" cy="0" r="3.5" fill="#00F0FF"/>
14 +
    <circle cx="11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
15 +
    <circle cx="11" cy="0" r="3.5" fill="#FFFFFF"/>
16 +
  </g>
17 +
</svg>
added packaging/haikuports/icons/oxy-16.png +0 −0

No line changes.

added packaging/haikuports/icons/oxy-32.png +0 −0

No line changes.

added packaging/haikuports/icons/oxy-64.png +0 −0

No line changes.

added packaging/haikuports/icons/oxy.hvif +0 −0

No line changes.

added packaging/haikuports/icons/oxy.svg +17 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2 +
  <defs>
3 +
    <linearGradient id="g" gradientUnits="userSpaceOnUse" x1="-22" y1="-11" x2="22" y2="11">
4 +
      <stop offset="0%" stop-color="#00F0FF"/>
5 +
      <stop offset="100%" stop-color="#0072FF"/>
6 +
    </linearGradient>
7 +
  </defs>
8 +
  <rect width="64" height="64" rx="14" fill="#C9DCF5"/><rect x="1.5" y="1.5" width="61" height="61" rx="12.5" fill="#F2F7FF"/>
9 +
  <g transform="translate(32 32)">
10 +
    <path d="M -11 -6 L 11 -6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
11 +
    <path d="M -11 6 L 11 6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
12 +
    <circle cx="-11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
13 +
    <circle cx="-11" cy="0" r="3.5" fill="#00F0FF"/>
14 +
    <circle cx="11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
15 +
    <circle cx="11" cy="0" r="3.5" fill="#0072FF"/>
16 +
  </g>
17 +
</svg>
added packaging/haikuports/make-icons.py +163 −0
1 +
#!/usr/bin/env python3
2 +
"""HaikuDepot icons for oxy and oh (oxygen#157): the Oxygen mark as Haiku's
3 +
vector icon format (HVIF), and as SVG to render the 16, 32 and 64 px PNGs.
4 +
5 +
A family of two (Andy, 2026-10-29): Oxygen (oxy, the Oxygen VCS extension)
6 +
is the mark on a pale rounded tile with a blue right dot; OxygenHub (oh, the
7 +
OxygenHub extension) is the mark on the hub's dark tile with a white one, as
8 +
oxygenhub/assets/icons/oxygenhub.svg. Each keeps its edge on light and dark
9 +
backgrounds alike.
10 +
11 +
    make-icons.py OUTDIR    writes oxy.hvif, oh.hvif, oxy.svg, oh.svg
12 +
13 +
HVIF as Haiku's FlatIconImporter reads it: "ncif", then styles, paths and
14 +
shapes, each a count byte and its items; coordinates in a 64-unit canvas.
15 +
"""
16 +
import math
17 +
import struct
18 +
import sys
19 +
20 +
CYAN, BLUE, WHITE, TILE = (0x00, 0xF0, 0xFF), (0x00, 0x72, 0xFF), (0xFF, 0xFF, 0xFF), (0x07, 0x0C, 0x18)
21 +
LIGHT, EDGE = (0xF2, 0xF7, 0xFF), (0xC9, 0xDC, 0xF5)
22 +
K = 0.5522847498  # a circle's quarter as one cubic Bézier
23 +
24 +
25 +
def coord(v):
26 +
    if v == int(v) and -32 <= v <= 95:
27 +
        return bytes([int(v) + 32])
28 +
    n = int(round((v + 128.0) * 102.0))
29 +
    return bytes([(n >> 8) | 0x80, n & 0xFF])
30 +
31 +
32 +
def float24(v):
33 +
    if v == 0:
34 +
        return b"\0\0\0"
35 +
    bits = struct.unpack(">I", struct.pack(">f", v))[0]
36 +
    sign, exp, man = bits >> 31, ((bits >> 23) & 0xFF) - 127, bits & 0x7FFFFF
37 +
    if not -32 <= exp < 32:
38 +
        raise ValueError(f"{v} does not fit a float24")
39 +
    n = (sign << 23) | ((exp + 32) << 17) | (man >> 6)
40 +
    return n.to_bytes(3, "big")
41 +
42 +
43 +
def solid(rgb):
44 +
    return bytes([3, *rgb])  # STYLE_TYPE_SOLID_COLOR_NO_ALPHA
45 +
46 +
47 +
def linear(p1, p2, stops):
48 +
    """A linear gradient from p1 to p2: gradient space runs x from -64 to 64."""
49 +
    dx, dy = (p2[0] - p1[0]) / 128.0, (p2[1] - p1[1]) / 128.0
50 +
    mx, my = (p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2
51 +
    out = bytes([2, 0, (1 << 1) | (1 << 2), len(stops)])  # gradient, linear, transform | no alpha
52 +
    for v in (dx, dy, -dy, dx, mx, my):
53 +
        out += float24(v)
54 +
    for off, rgb in stops:
55 +
        out += bytes([off, *rgb])
56 +
    return out
57 +
58 +
59 +
def line(a, b):
60 +
    return bytes([1 << 3, 2]) + coord(a[0]) + coord(a[1]) + coord(b[0]) + coord(b[1])  # no curves, open
61 +
62 +
63 +
def curve_path(points, closed=True):
64 +
    """points: (x, y, in_x, in_y, out_x, out_y)."""
65 +
    out = bytes([(1 << 1) if closed else 0, len(points)])
66 +
    for p in points:
67 +
        for v in p:
68 +
            out += coord(v)
69 +
    return out
70 +
71 +
72 +
def circle(cx, cy, r):
73 +
    k = r * K
74 +
    pts = [(cx, cy - r, cx - k, cy - r, cx + k, cy - r), (cx + r, cy, cx + r, cy - k, cx + r, cy + k),
75 +
           (cx, cy + r, cx + k, cy + r, cx - k, cy + r), (cx - r, cy, cx - r, cy + k, cx - r, cy - k)]
76 +
    return curve_path(pts)
77 +
78 +
79 +
def rounded(x, y, w, h, r):
80 +
    k = r * (1 - K)
81 +
    x2, y2 = x + w, y + h
82 +
    pts = [
83 +
        (x + r, y, x + k, y, x + r, y), (x2 - r, y, x2 - r, y, x2 - k, y),
84 +
        (x2, y + r, x2, y + k, x2, y + r), (x2, y2 - r, x2, y2 - r, x2, y2 - k),
85 +
        (x2 - r, y2, x2 - k, y2, x2 - r, y2), (x + r, y2, x + r, y2, x + k, y2),
86 +
        (x, y2 - r, x, y2 - k, x, y2 - r), (x, y + r, x, y + r, x, y + k),
87 +
    ]
88 +
    return curve_path(pts)
89 +
90 +
91 +
def shape(style, paths, stroke=None, cap=0):
92 +
    out = bytes([10, style, len(paths), *paths])
93 +
    if stroke is None:
94 +
        return out + bytes([0])
95 +
    # has transformers: one stroke (width + 128, cap << 4 | round join, miter limit)
96 +
    return out + bytes([1 << 4, 1, 23, int(round(stroke)) + 128, (cap << 4) | 2, 4])
97 +
98 +
99 +
def mark(scale, ox, oy):
100 +
    """The mark's geometry, scaled about its centre and moved to (ox, oy)."""
101 +
    p = lambda x, y: (ox + x * scale, oy + y * scale)
102 +
    return {
103 +
        "top": (p(-11, -6), p(11, -6)), "bottom": (p(-11, 6), p(11, 6)),
104 +
        "left": (p(-11, 0), 10 * scale), "right": (p(11, 0), 10 * scale),
105 +
        "dot": 3.5 * scale, "stroke": 3 * scale, "grad": (p(-22, -11), p(22, 11)),
106 +
    }
107 +
108 +
109 +
def hvif(dark):
110 +
    m = mark(1.0, 32, 32)
111 +
    styles = [linear(*m["grad"], [(0, CYAN), (255, BLUE)]), solid(CYAN), solid(WHITE if dark else BLUE)]
112 +
    paths = [line(*m["top"]), line(*m["bottom"]), circle(*m["left"][0], m["left"][1]),
113 +
             circle(*m["right"][0], m["right"][1]), circle(*m["left"][0], m["dot"]), circle(*m["right"][0], m["dot"])]
114 +
    if dark:
115 +
        styles.append(solid(TILE))
116 +
        paths.append(rounded(0, 0, 64, 64, 14))
117 +
        tile = [shape(3, [6])]
118 +
    else:
119 +
        # A pale tile with a thin edge: the edge's colour, then the tile inset by it.
120 +
        styles += [solid(EDGE), solid(LIGHT)]
121 +
        paths += [rounded(0, 0, 64, 64, 14), rounded(1.5, 1.5, 61, 61, 12.5)]
122 +
        tile = [shape(3, [6]), shape(4, [7])]
123 +
    shapes = tile + [
124 +
        shape(0, [0], m["stroke"], cap=2), shape(0, [1], m["stroke"], cap=2),
125 +
        shape(0, [2], m["stroke"]), shape(0, [3], m["stroke"]),
126 +
        shape(1, [4]), shape(2, [5]),
127 +
    ]
128 +
    out = b"ncif"
129 +
    for section in (styles, paths, shapes):
130 +
        out += bytes([len(section)]) + b"".join(section)
131 +
    return out
132 +
133 +
134 +
def svg(dark):
135 +
    if dark:
136 +
        bg, dot = '<rect width="64" height="64" rx="14" fill="#070c18"/>', "#FFFFFF"
137 +
    else:
138 +
        bg = '<rect width="64" height="64" rx="14" fill="#C9DCF5"/><rect x="1.5" y="1.5" width="61" height="61" rx="12.5" fill="#F2F7FF"/>'
139 +
        dot = "#0072FF"
140 +
    return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
141 +
  <defs>
142 +
    <linearGradient id="g" gradientUnits="userSpaceOnUse" x1="-22" y1="-11" x2="22" y2="11">
143 +
      <stop offset="0%" stop-color="#00F0FF"/>
144 +
      <stop offset="100%" stop-color="#0072FF"/>
145 +
    </linearGradient>
146 +
  </defs>
147 +
  {bg}
148 +
  <g transform="translate(32 32)">
149 +
    <path d="M -11 -6 L 11 -6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
150 +
    <path d="M -11 6 L 11 6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
151 +
    <circle cx="-11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
152 +
    <circle cx="-11" cy="0" r="3.5" fill="#00F0FF"/>
153 +
    <circle cx="11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
154 +
    <circle cx="11" cy="0" r="3.5" fill="{dot}"/>
155 +
  </g>
156 +
</svg>
157 +
'''
158 +
159 +
160 +
out = sys.argv[1]
161 +
for name, dark in (("oxy", False), ("oh", True)):
162 +
    open(f"{out}/{name}.hvif", "wb").write(hvif(dark))
163 +
    open(f"{out}/{name}.svg", "w").write(svg(dark))
modified CHANGELOG.md +4 −0
1 1
# Changelog
2 2
3 +
## Unreleased
4 +
5 +
- **Icons for HaikuDepot (#157).** `packaging/haikuports/icons/` has oxy's and oh's icons as HVIF (Haiku's vector format) with 16, 32 and 64 px PNGs, written by `make-icons.py`: the Oxygen mark on a pale tile for oxy, on OxygenHub's dark tile for oh, the same family as the VS Code extensions.
6 +
3 7
## 0.9.0 (2026-10-29)
4 8
5 9
- **Releases carry their source (#157).** Each release now has `oxy-VERSION-src.tar.gz`, the source as released, reproducible (sorted, fixed times and owners, gzip without a timestamp), listed in SHA256SUMS, for packagers to fetch at a fixed checksum. `packaging/haikuports/make-recipe.py oxy|oh TARBALL VERSION` writes the haikuports recipe from it (oh's from OxygenHub's source tarball); the 0.8.0 recipe, which used the on-demand archive, is gone.
…
304 unchanged lines
modified packaging/haikuports/README.md +10 −0
…
27 unchanged lines
28 28
zstd's hidden symbols break the link), which the recipe sets. oh's tests
29 29
pull in the hub, whose pipeline parser needs a newer Rust than HaikuPorts'
30 30
1.94, so its recipe tests `hub-api` only.
31 +
32 +
## Icons
33 +
34 +
HaikuDepot takes a package's icon from HaikuDepotServer, not from the
35 +
recipe. `icons/` holds them, written by `make-icons.py icons/` (the PNGs
36 +
rendered from the SVGs): Oxygen (`oxy`) is the mark on a pale tile,
37 +
OxygenHub (`oh`) the mark on the hub's dark tile, as the VS Code
38 +
extensions' icons are. Once the packages are listed on
39 +
depot.haiku-os.org, upload `NAME.hvif` with `NAME-16.png`, `-32` and `-64`
40 +
as each package's icon.