Files
2026-07-11 07:25:15 -05:00

399 lines
17 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
NexusOS network status icons generator.
Generates flat symbolic-style SVGs for nm-tray and any other Qt/GTK app
that requests network-* status icons. Output goes to
scalable/status/ of the NexusOS-icons theme.
Design language:
* 16×16 viewBox, scalable SVG (no rasterization needed — Qt5 + GTK3
both handle SVG fine in scalable/ dirs)
* Light off-white stroke for base shape (wifi fan, ethernet plug,
globe) — readable on the dark NexusOS surfaces
* Lime brand accent for connected / active states
* Purple lock for encryption
* Red for error, orange for acquiring / no-route
Run: python3 build_status.py
"""
from pathlib import Path
import math
ROOT = Path(__file__).resolve().parent
OUT = ROOT.parent / "NexusOS-icons" / "scalable" / "status"
# Palette — mirrors gtk-3.0/colors.css.
BASE = "#f2f2f2" # text_primary
MUTED = "#6e7173" # text_disabled
GOOD = "#8cc63f" # brand_green
LOCK = "#f2f2f2" # text_primary (white) — neutral attribute indicator
# while green is reserved for active signal strength.
ERROR = "#da4453" # error_color
WARN = "#f67400" # warning_color
SW = 1.6
# Wifi fan arc geometry: half-angle from vertical. Smaller half-angle ⇒
# narrower sweep but allows bigger radius (taller fan) before endpoints
# run off the sides at 16x16. 40° (80° total sweep) is a sweet spot.
ARC_HALF_ANGLE = 40
S_ARC = math.sin(math.radians(ARC_HALF_ANGLE)) # x offset coefficient
C_ARC = math.cos(math.radians(ARC_HALF_ANGLE)) # y offset coefficient
def svg(body: str) -> str:
return (
'<svg xmlns="http://www.w3.org/2000/svg" '
'viewBox="0 0 16 16" width="16" height="16">\n'
+ body.rstrip()
+ "\n</svg>\n"
)
# ── wifi fan ────────────────────────────────────────────────────────
# Fan sits low in the viewBox with arcs reaching almost the top edge so
# the rendered icon matches the height of neighboring tray icons (shield,
# bluetooth, etc.). Outermost r=11.5 with the narrower 80° arc sweep
# keeps endpoints inside x=[0.61, 15.39] — fits with stroke-cap margin.
WIFI_CX, WIFI_CY = 8, 13.0
WIFI_R = [3.0, 5.5, 8.5, 11.5]
DOT_R = 1.3
def _arc(r: float, color: str, opacity: float = 1.0) -> str:
x0 = WIFI_CX - r * S_ARC
x1 = WIFI_CX + r * S_ARC
y = WIFI_CY - r * C_ARC
return (
f' <path d="M {x0:.2f} {y:.2f} A {r} {r} 0 0 1 {x1:.2f} {y:.2f}" '
f'fill="none" stroke="{color}" stroke-width="{SW}" '
f'stroke-linecap="round" opacity="{opacity}"/>'
)
def wifi_fan(level: int, active_color: str = GOOD) -> str:
"""level 0 (none) .. 4 (excellent). Filled arcs use brand_green by
default so signal strength reads as the primary visual element."""
parts = []
for i, r in enumerate(WIFI_R):
if i < level:
parts.append(_arc(r, active_color, 1.0))
else:
parts.append(_arc(r, MUTED, 0.35))
if level > 0:
parts.append(
f' <circle cx="{WIFI_CX}" cy="{WIFI_CY}" r="{DOT_R}" '
f'fill="{active_color}"/>'
)
else:
parts.append(
f' <circle cx="{WIFI_CX}" cy="{WIFI_CY}" r="{DOT_R}" '
f'fill="{MUTED}" opacity="0.45"/>'
)
return "\n".join(parts)
# ── ethernet plug (RJ45) ────────────────────────────────────────────
# Sized to fill ~75% of the viewBox vertically (y=2→14) and horizontally
# (x=2→14) so the rendered icon matches the height of neighboring tray
# icons (shield, notification, bluetooth) instead of looking like a tiny
# crate in the middle of the slot.
def ethernet_plug(color: str = GOOD, opacity: float = 1.0) -> str:
return (
f' <g stroke="{color}" stroke-width="{SW}" stroke-linecap="round" '
f'stroke-linejoin="round" fill="none" opacity="{opacity}">\n'
# Pentagonal RJ45 outline
' <path d="M 2 2 L 14 2 L 14 10.5 L 12 14 L 4 14 L 2 10.5 Z"/>\n'
# Three pins, spread wider to match the bigger body
' <line x1="5" y1="4.5" x2="5" y2="8.5"/>\n'
' <line x1="8" y1="4.5" x2="8" y2="8.5"/>\n'
' <line x1="11" y1="4.5" x2="11" y2="8.5"/>\n'
" </g>"
)
# ── globe (generic network) ─────────────────────────────────────────
def globe(color: str = BASE, opacity: float = 1.0) -> str:
return (
f' <g stroke="{color}" stroke-width="{SW}" stroke-linecap="round" '
f'stroke-linejoin="round" fill="none" opacity="{opacity}">\n'
' <circle cx="8" cy="8" r="5.5"/>\n'
' <ellipse cx="8" cy="8" rx="2.5" ry="5.5"/>\n'
' <line x1="2.5" y1="8" x2="13.5" y2="8"/>\n'
" </g>"
)
# ── key (vpn) ───────────────────────────────────────────────────────
def vpn_key(color: str = GOOD, opacity: float = 1.0) -> str:
"""Horizontal key: round bow on the left, shaft to the right with
two teeth hanging down. Stroke-only so it doesn't visually dominate
the way the filled globe did."""
return (
f' <g stroke="{color}" stroke-width="1.7" stroke-linecap="round" '
f'stroke-linejoin="round" fill="none" opacity="{opacity}">\n'
# Bow (handle) — outline circle on the left
' <circle cx="4.6" cy="7" r="2.7"/>\n'
# Shaft
' <path d="M 7.3 7 H 14"/>\n'
# Two teeth pointing down
' <path d="M 11.7 7 V 9.6 M 13.6 7 V 8.7"/>\n'
" </g>"
)
# ── state overlays / badges ─────────────────────────────────────────
# nm-tray's MultiIconDelegate stacks state icons on top of the signal-
# level base, so each overlay icon is *just the badge* — no wifi fan,
# no globe. That way stacking signal-good + encrypted gives fan + lock
# instead of fan + fan + lock, and adding -disconnected on top of an
# unsaved network adds a single X instead of another fan.
def standalone_lock(color: str = LOCK) -> str:
"""Centered padlock filling most of the 16x16 box. This is the icon
nm-tray actually loads for encryption — it requests `security-high`
/ `security-high-symbolic` (NOT `network-wireless-encrypted`) and
overlays it on top of a `network-wireless-signal-*` base via its
MultiIconDelegate, scaling our full-size lock into a corner badge.
Keyhole detail intentionally omitted: at the corner-badge render
size nm-tray uses, it's invisible anyway, and a dark keyhole on a
white body reads as visual noise rather than as a lock detail."""
return (
# Body: 8.5w x 6h, centered horizontally, sitting low
f' <rect x="3.75" y="7.5" width="8.5" height="6.5" rx="1.0" '
f'fill="{color}"/>\n'
# Shackle: pronounced U above the body
f' <path d="M 5.5 7.5 V 4.5 a 2.5 2.5 0 0 1 5 0 V 7.5" '
f'fill="none" stroke="{color}" stroke-width="1.8" '
f'stroke-linecap="round" stroke-linejoin="round"/>'
)
def badge_lock() -> str:
"""Lock badge: body 4.4w × 3.8h + shackle 3w × 2h on top, lower-right
corner. Made deliberately larger and with a thicker shackle stroke
than a typical badge — at 16-24px render, a small thin-stroke lock
blurs into a shield-like rounded shape. The big-shackle proportions
here keep the lock recognizable down to ~18px icon size."""
return (
' <g transform="translate(9.5 9.0)">\n'
f' <rect x="0" y="3.0" width="4.4" height="3.8" rx="0.45" '
f'fill="{LOCK}"/>\n'
f' <path d="M 0.7 3.0 V 1.1 a 1.5 1.5 0 0 1 3.0 0 V 3.0" '
f'fill="none" stroke="{LOCK}" stroke-width="1.15" '
f'stroke-linecap="round" stroke-linejoin="round"/>\n'
" </g>"
)
def badge_x(color: str = ERROR) -> str:
return (
f' <g stroke="{color}" stroke-width="1.4" stroke-linecap="round" '
'transform="translate(10 10)">\n'
' <line x1="0" y1="0" x2="4.5" y2="4.5"/>\n'
' <line x1="4.5" y1="0" x2="0" y2="4.5"/>\n'
" </g>"
)
def badge_dots(color: str = WARN) -> str:
return (
f' <g fill="{color}">\n'
' <circle cx="10.5" cy="13" r="0.9"/>\n'
' <circle cx="13" cy="13" r="0.9"/>\n'
' <circle cx="15.5" cy="13" r="0.9"/>\n'
" </g>"
)
def badge_excl(color: str = ERROR) -> str:
return (
f' <g fill="{color}" stroke="none">\n'
' <rect x="11.4" y="9" width="1.4" height="3" rx="0.5"/>\n'
' <circle cx="12.1" cy="13.5" r="0.85"/>\n'
" </g>"
)
def badge_slash(color: str = MUTED) -> str:
return (
f' <line x1="2.5" y1="13.5" x2="13.5" y2="2.5" '
f'stroke="{color}" stroke-width="1.6" stroke-linecap="round"/>'
)
def badge_arrow_up(color: str = GOOD) -> str:
return (
f' <path d="M 12.5 14 V 9 M 10.5 11 L 12.5 9 L 14.5 11" '
f'fill="none" stroke="{color}" stroke-width="1.4" '
'stroke-linecap="round" stroke-linejoin="round"/>'
)
def badge_arrow_down(color: str = GOOD) -> str:
return (
f' <path d="M 3.5 9 V 14 M 1.5 12 L 3.5 14 L 5.5 12" '
f'fill="none" stroke="{color}" stroke-width="1.4" '
'stroke-linecap="round" stroke-linejoin="round"/>'
)
# ── icon catalog ────────────────────────────────────────────────────
def cell_tech(label: str) -> str:
"""Wifi fan + tiny generation-tech label (2G/3G/4G/5G/E/G/H/U/1x)."""
return svg(
wifi_fan(3) + "\n"
f' <text x="11" y="13" font-family="sans-serif" font-size="4.5" '
f'font-weight="bold" fill="{BASE}">{label}</text>'
)
ICONS = {
# wireless signal levels
"network-wireless-signal-none": svg(wifi_fan(0)),
"network-wireless-signal-weak": svg(wifi_fan(1)),
"network-wireless-signal-ok": svg(wifi_fan(2)),
"network-wireless-signal-good": svg(wifi_fan(3)),
"network-wireless-signal-excellent": svg(wifi_fan(4)),
# wireless — nm-tray picks one icon per row, so each name is a
# self-contained composite (signal-level fan + state badge if any).
"network-wireless": svg(wifi_fan(3)),
"network-wireless-connected": svg(wifi_fan(4, GOOD)),
# Disconnected: faded-empty fan. NO X badge — nm-tray overlays this
# on top of saved-network rows, so an X here would visually pile up
# with the encrypted-lock overlay. The dim level-0 fan already reads
# as "no signal/disconnected" without the extra badge.
"network-wireless-disconnected": svg(wifi_fan(0)),
"network-wireless-disabled": svg(wifi_fan(0) + "\n" + badge_slash()),
"network-wireless-acquiring": svg(wifi_fan(2) + "\n" + badge_dots()),
"network-wireless-error": svg(wifi_fan(2) + "\n" + badge_excl()),
"network-wireless-no-route": svg(wifi_fan(3) + "\n" + badge_excl(WARN)),
"network-wireless-offline": svg(wifi_fan(0)),
"network-wireless-encrypted": svg(wifi_fan(3) + "\n" + badge_lock()),
"network-wireless-hotspot": svg(wifi_fan(4, GOOD) + "\n" + badge_dots(GOOD)),
"network-wireless-hardware-disabled": svg(wifi_fan(0) + "\n" + badge_slash(ERROR)),
# wired
"network-wired": svg(ethernet_plug()),
"network-wired-disconnected": svg(ethernet_plug(MUTED, 0.55) + "\n" + badge_x()),
"network-wired-acquiring": svg(ethernet_plug() + "\n" + badge_dots()),
"network-wired-error": svg(ethernet_plug() + "\n" + badge_excl()),
"network-wired-no-route": svg(ethernet_plug() + "\n" + badge_excl(WARN)),
"network-wired-offline": svg(ethernet_plug(MUTED, 0.55)),
# vpn — green key. Stroke-only so it doesn't dominate the menu row.
"network-vpn": svg(vpn_key()),
"network-vpn-connected": svg(vpn_key()),
"network-vpn-acquiring": svg(vpn_key() + "\n" + badge_dots()),
"network-vpn-disconnected": svg(vpn_key(MUTED, 0.55) + "\n" + badge_x()),
"network-vpn-error": svg(vpn_key(ERROR)),
"network-vpn-no-route": svg(vpn_key() + "\n" + badge_excl(WARN)),
"network-vpn-offline": svg(vpn_key(MUTED, 0.55)),
# generic
"network-error": svg(globe() + "\n" + badge_excl()),
"network-idle": svg(globe()),
"network-offline": svg(globe(MUTED, 0.55) + "\n" + badge_slash()),
"network-no-route": svg(globe() + "\n" + badge_excl(WARN)),
"network-acquiring": svg(globe() + "\n" + badge_dots()),
"network-transmit": svg(globe() + "\n" + badge_arrow_up()),
"network-receive": svg(globe() + "\n" + badge_arrow_down()),
"network-transmit-receive": svg(globe() + "\n" + badge_arrow_up() + "\n" + badge_arrow_down()),
# cellular — same fan, with state badges
"network-cellular-signal-none": svg(wifi_fan(0)),
"network-cellular-signal-weak": svg(wifi_fan(1)),
"network-cellular-signal-ok": svg(wifi_fan(2)),
"network-cellular-signal-good": svg(wifi_fan(3)),
"network-cellular-signal-excellent": svg(wifi_fan(4)),
"network-cellular-acquiring": svg(wifi_fan(2) + "\n" + badge_dots()),
"network-cellular-connected": svg(wifi_fan(4, GOOD)),
"network-cellular-disabled": svg(wifi_fan(0) + "\n" + badge_slash()),
"network-cellular-disconnected": svg(wifi_fan(0) + "\n" + badge_x()),
"network-cellular-error": svg(wifi_fan(2) + "\n" + badge_excl()),
"network-cellular-hardware-disabled": svg(wifi_fan(0) + "\n" + badge_slash(ERROR)),
"network-cellular-no-route": svg(wifi_fan(3) + "\n" + badge_excl(WARN)),
"network-cellular-offline": svg(wifi_fan(0)),
# security indicators — nm-tray's actual encryption overlay icon
"security-high": svg(standalone_lock()),
"security-high-symbolic": svg(standalone_lock()),
"security-medium": svg(standalone_lock(WARN)),
"security-medium-symbolic": svg(standalone_lock(WARN)),
"security-low": svg(standalone_lock(ERROR)),
"security-low-symbolic": svg(standalone_lock(ERROR)),
# cellular generation-tech labels
"network-cellular-2g": cell_tech("2G"),
"network-cellular-3g": cell_tech("3G"),
"network-cellular-4g": cell_tech("4G"),
"network-cellular-5g": cell_tech("5G"),
"network-cellular-edge": cell_tech("E"),
"network-cellular-gprs": cell_tech("G"),
"network-cellular-cdma-1x": cell_tech("1x"),
"network-cellular-hspa": cell_tech("H"),
"network-cellular-umts": cell_tech("U"),
}
# ── genmon panel applet PNGs ─────────────────────────────────────────
# bin/panel/network-applet.sh (the XFCE genmon network applet) loads PNGs
# directly from assets/panel-icons/ by path — it does NOT go through the
# icon theme. Rasterize the relevant status icons there too so the applet
# stays in sync with the canonical SVG design above (otherwise the PNGs
# drift into a different look, e.g. solid filled cones vs. arc bars).
PANEL_PNG_DIR = ROOT.parent.parent / "panel-icons" # assets/panel-icons
PANEL_PNG_SIZE = 22
PANEL_PNG_NAMES = [
"network-wireless-signal-none",
"network-wireless-signal-weak",
"network-wireless-signal-ok",
"network-wireless-signal-good",
"network-wireless-signal-excellent",
"network-wired",
"network-offline",
]
def _rasterize_panel_pngs() -> int:
import subprocess
if not PANEL_PNG_DIR.is_dir():
return 0
n = 0
for name in PANEL_PNG_NAMES:
svg_path = OUT / f"{name}.svg"
if not svg_path.exists():
continue
subprocess.run(
["inkscape", str(svg_path), "--export-type=png",
f"--export-filename={PANEL_PNG_DIR / (name + '.png')}",
f"--export-width={PANEL_PNG_SIZE}",
f"--export-height={PANEL_PNG_SIZE}",
"--export-background-opacity=0"],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
n += 1
return n
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
# Sweep any leftover Papirus fallback symlinks from the same dir.
cleaned = 0
for entry in OUT.iterdir():
if entry.is_symlink():
entry.unlink()
cleaned += 1
for name, body in ICONS.items():
(OUT / f"{name}.svg").write_text(body)
print(f"Cleared {cleaned} symlinks, wrote {len(ICONS)} SVGs to {OUT}")
n = _rasterize_panel_pngs()
print(f"Rasterized {n} genmon panel PNGs to {PANEL_PNG_DIR}")
if __name__ == "__main__":
main()