Add system tray, APK Installer, fix package visibility, new themes/fonts; bump to v1.3.0
- System tray (Linux): pure Go D-Bus StatusNotifierItem, no cgo/libappindicator. Close now offers Minimize to Tray vs Quit instead of always quitting. - New APK Installer module: batch-install from a folder or hand-picked files, live per-file progress, select all/none. - Fix Packages/App Inspector silently hiding packages disabled/uninstalled by Canta/Shizuku (pm list packages needs -u to see them). Added a state filter and per-row Restore/Enable/Disable/Uninstall actions. - 6 new theme palettes (Nord, Tokyo Night, Solarized Dark/Light, Rosé Pine, Everforest - 26 total), custom text colour, 8 bundled display fonts, adjustable font size. - Horizontal sidebar now shrinks label text progressively instead of scrolling when the window narrows; fixed a touchpad stuck-scrollbar-drag bug. - Updated README and fixed stale Go 1.23 -> 1.25 build instructions.
This commit is contained in:
parent
ed97bc2c14
commit
cf3fb26bd2
52 changed files with 2642 additions and 98 deletions
298
scripts/gen_tray_icon.py
Normal file
298
scripts/gen_tray_icon.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generates the ATK tray icon assets from a hand-specified vector path (no
|
||||
image libraries available offline - this rasterizes and encodes PNG/ICO from
|
||||
scratch using only the stdlib). Re-run after editing PATH_D or COLORS.
|
||||
|
||||
Outputs into assets/:
|
||||
tray_icon.png 32x32 RGBA, ATK green - Windows/Linux tray icon
|
||||
tray_icon_template.png 32x32 RGBA, black silhouette - macOS template icon
|
||||
tray_icon.ico 16 + 32px, ATK green - Windows .ico container
|
||||
tray_symbolic.svg vector, single-fill - Linux icon-theme install
|
||||
"""
|
||||
import re
|
||||
import struct
|
||||
import zlib
|
||||
import os
|
||||
|
||||
# Material Design "alternate_email" (@) glyph, 24x24 viewBox. Single source of
|
||||
# truth for every rasterized/vector output below.
|
||||
PATH_D = (
|
||||
"M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10c1.28,0 2.5,-0.2 3.68,-0.68l-0.36,-1.9"
|
||||
"C14.34,19.79 13.19,20 12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8s8,3.59 8,8v0.9"
|
||||
"c0,0.61 -0.6,1.32 -1.5,1.32s-1.5,-0.71 -1.5,-1.32V8h-2v0.68"
|
||||
"C14.42,8.24 13.51,8 12.5,8C10.02,8 8,10.02 8,12.5s2.02,4.5 4.5,4.5"
|
||||
"c1.14,0 2.17,-0.42 2.96,-1.11c0.55,0.66 1.42,1.11 2.29,1.11"
|
||||
"c1.66,0 3.25,-1.34 3.25,-3.32V12C21,6.48 16.52,2 12,2z"
|
||||
"M12.5,15c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5"
|
||||
"S13.88,15 12.5,15z"
|
||||
)
|
||||
VIEWBOX = 24
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ASSETS = os.path.join(ROOT, "assets")
|
||||
|
||||
|
||||
def tokenize(d):
|
||||
return re.findall(r"[MmLlHhVvCcSsZz]|-?\d*\.?\d+(?:[eE]-?\d+)?", d)
|
||||
|
||||
|
||||
def flatten_cubic(p0, p1, p2, p3, segs=20):
|
||||
pts = []
|
||||
for i in range(1, segs + 1):
|
||||
t = i / segs
|
||||
mt = 1 - t
|
||||
x = mt**3 * p0[0] + 3 * mt**2 * t * p1[0] + 3 * mt * t**2 * p2[0] + t**3 * p3[0]
|
||||
y = mt**3 * p0[1] + 3 * mt**2 * t * p1[1] + 3 * mt * t**2 * p2[1] + t**3 * p3[1]
|
||||
pts.append((x, y))
|
||||
return pts
|
||||
|
||||
|
||||
def parse_path(d):
|
||||
"""Returns a flat list of (x1,y1,x2,y2) edges (line segments), flattening
|
||||
beziers, across every subpath - enough for a nonzero-winding scanline fill."""
|
||||
tokens = tokenize(d)
|
||||
i = 0
|
||||
cmd = None
|
||||
cx = cy = 0.0
|
||||
start_x = start_y = 0.0
|
||||
last_ctrl = None
|
||||
edges = []
|
||||
|
||||
def line_to(nx, ny):
|
||||
edges.append((cx, cy, nx, ny))
|
||||
|
||||
while i < len(tokens):
|
||||
tok = tokens[i]
|
||||
if tok in "MmLlHhVvCcSsZz":
|
||||
cmd = tok
|
||||
i += 1
|
||||
|
||||
if cmd in ("M", "m"):
|
||||
x, y = float(tokens[i]), float(tokens[i + 1])
|
||||
i += 2
|
||||
if cmd == "m":
|
||||
x += cx
|
||||
y += cy
|
||||
cx, cy = x, y
|
||||
start_x, start_y = x, y
|
||||
cmd = "L" if cmd == "M" else "l" # subsequent pairs are implicit lineto
|
||||
elif cmd in ("L", "l"):
|
||||
x, y = float(tokens[i]), float(tokens[i + 1])
|
||||
i += 2
|
||||
if cmd == "l":
|
||||
x += cx
|
||||
y += cy
|
||||
line_to(x, y)
|
||||
cx, cy = x, y
|
||||
elif cmd in ("H", "h"):
|
||||
x = float(tokens[i])
|
||||
i += 1
|
||||
if cmd == "h":
|
||||
x += cx
|
||||
line_to(x, cy)
|
||||
cx = x
|
||||
elif cmd in ("V", "v"):
|
||||
y = float(tokens[i])
|
||||
i += 1
|
||||
if cmd == "v":
|
||||
y += cy
|
||||
line_to(cx, y)
|
||||
cy = y
|
||||
elif cmd in ("C", "c"):
|
||||
x1, y1, x2, y2, x, y = (float(tokens[i + k]) for k in range(6))
|
||||
i += 6
|
||||
if cmd == "c":
|
||||
x1 += cx; y1 += cy; x2 += cx; y2 += cy; x += cx; y += cy
|
||||
for px, py in flatten_cubic((cx, cy), (x1, y1), (x2, y2), (x, y)):
|
||||
edges.append((cx, cy, px, py))
|
||||
cx, cy = px, py
|
||||
last_ctrl = (x2, y2)
|
||||
elif cmd in ("S", "s"):
|
||||
x2, y2, x, y = (float(tokens[i + k]) for k in range(4))
|
||||
i += 4
|
||||
if cmd == "s":
|
||||
x2 += cx; y2 += cy; x += cx; y += cy
|
||||
if last_ctrl:
|
||||
x1, y1 = 2 * cx - last_ctrl[0], 2 * cy - last_ctrl[1]
|
||||
else:
|
||||
x1, y1 = cx, cy
|
||||
for px, py in flatten_cubic((cx, cy), (x1, y1), (x2, y2), (x, y)):
|
||||
edges.append((cx, cy, px, py))
|
||||
cx, cy = px, py
|
||||
last_ctrl = (x2, y2)
|
||||
elif cmd in ("Z", "z"):
|
||||
line_to(start_x, start_y)
|
||||
cx, cy = start_x, start_y
|
||||
else:
|
||||
raise ValueError(f"unhandled path command {cmd!r}")
|
||||
return edges
|
||||
|
||||
|
||||
def winding_at(x, y, edges):
|
||||
w = 0
|
||||
for x1, y1, x2, y2 in edges:
|
||||
if y1 == y2:
|
||||
continue
|
||||
if (y1 <= y < y2) or (y2 <= y < y1):
|
||||
t = (y - y1) / (y2 - y1)
|
||||
xi = x1 + t * (x2 - x1)
|
||||
if xi > x:
|
||||
w += 1 if y2 > y1 else -1
|
||||
return w
|
||||
|
||||
|
||||
def rasterize(edges, size, supersample=4):
|
||||
"""Nonzero-winding scanline fill -> size x size alpha coverage (0..255),
|
||||
antialiased via `supersample`x supersampling + box downsample."""
|
||||
hi = size * supersample
|
||||
scale = hi / VIEWBOX
|
||||
scaled = [(x1 * scale, y1 * scale, x2 * scale, y2 * scale) for x1, y1, x2, y2 in edges]
|
||||
coverage = [[0] * hi for _ in range(hi)]
|
||||
for y in range(hi):
|
||||
fy = y + 0.5
|
||||
# crossings for this scanline, sorted, with winding deltas
|
||||
xs = []
|
||||
for x1, y1, x2, y2 in scaled:
|
||||
if y1 == y2:
|
||||
continue
|
||||
if (y1 <= fy < y2) or (y2 <= fy < y1):
|
||||
t = (fy - y1) / (y2 - y1)
|
||||
xi = x1 + t * (x2 - x1)
|
||||
xs.append((xi, 1 if y2 > y1 else -1))
|
||||
xs.sort()
|
||||
w = 0
|
||||
row = coverage[y]
|
||||
for idx in range(len(xs)):
|
||||
xi, d = xs[idx]
|
||||
prev_w = w
|
||||
w += d
|
||||
if prev_w == 0 and w != 0:
|
||||
span_start = xi
|
||||
elif prev_w != 0 and w == 0:
|
||||
x0 = max(0, int(round(span_start)))
|
||||
x1_ = min(hi, int(round(xi)))
|
||||
for px in range(x0, x1_):
|
||||
row[px] = 1
|
||||
# box downsample
|
||||
out = [[0] * size for _ in range(size)]
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
s = 0
|
||||
for dy in range(supersample):
|
||||
row = coverage[y * supersample + dy]
|
||||
for dx in range(supersample):
|
||||
s += row[x * supersample + dx]
|
||||
out[y][x] = round(255 * s / (supersample * supersample))
|
||||
return out
|
||||
|
||||
|
||||
def to_rgba(coverage, size, rgb):
|
||||
r, g, b = rgb
|
||||
buf = bytearray(size * size * 4)
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
a = coverage[y][x]
|
||||
o = (y * size + x) * 4
|
||||
buf[o] = r
|
||||
buf[o + 1] = g
|
||||
buf[o + 2] = b
|
||||
buf[o + 3] = a
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def write_png(path, size, rgba):
|
||||
def chunk(tag, data):
|
||||
return struct.pack(">I", len(data)) + tag + data + struct.pack(
|
||||
">I", zlib.crc32(tag + data) & 0xFFFFFFFF
|
||||
)
|
||||
|
||||
sig = b"\x89PNG\r\n\x1a\n"
|
||||
ihdr = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
|
||||
raw = bytearray()
|
||||
stride = size * 4
|
||||
for y in range(size):
|
||||
raw.append(0)
|
||||
raw.extend(rgba[y * stride:(y + 1) * stride])
|
||||
idat = zlib.compress(bytes(raw), 9)
|
||||
with open(path, "wb") as f:
|
||||
f.write(sig)
|
||||
f.write(chunk(b"IHDR", ihdr))
|
||||
f.write(chunk(b"IDAT", idat))
|
||||
f.write(chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def write_ico(path, frames):
|
||||
"""frames: list of (size, png_bytes)"""
|
||||
count = len(frames)
|
||||
header = struct.pack("<HHH", 0, 1, count)
|
||||
entries = b""
|
||||
blob = b""
|
||||
offset = 6 + count * 16
|
||||
for size, png_bytes in frames:
|
||||
wb = size if size < 256 else 0
|
||||
hb = size if size < 256 else 0
|
||||
entries += struct.pack("<BBBBHHII", wb, hb, 0, 0, 1, 32, len(png_bytes), offset)
|
||||
blob += png_bytes
|
||||
offset += len(png_bytes)
|
||||
with open(path, "wb") as f:
|
||||
f.write(header)
|
||||
f.write(entries)
|
||||
f.write(blob)
|
||||
|
||||
|
||||
def main():
|
||||
edges = parse_path(PATH_D)
|
||||
os.makedirs(ASSETS, exist_ok=True)
|
||||
|
||||
green = (0, 255, 136) # ATK accent green #00ff88
|
||||
black = (0, 0, 0)
|
||||
|
||||
sizes = [16, 32]
|
||||
green_pngs = {}
|
||||
for size in sizes:
|
||||
cov = rasterize(edges, size)
|
||||
rgba = to_rgba(cov, size, green)
|
||||
green_pngs[size] = rgba
|
||||
write_png(os.path.join(ASSETS, f"_tray_preview_{size}.png"), size, rgba)
|
||||
|
||||
# Primary tray icon (32px, colored) for Windows/Linux.
|
||||
write_png(os.path.join(ASSETS, "tray_icon.png"), 32, green_pngs[32])
|
||||
|
||||
# macOS template icon: solid black silhouette, alpha only. macOS recolors
|
||||
# this automatically for the light/dark menu bar.
|
||||
cov32 = rasterize(edges, 32)
|
||||
tmpl_rgba = to_rgba(cov32, 32, black)
|
||||
write_png(os.path.join(ASSETS, "tray_icon_template.png"), 32, tmpl_rgba)
|
||||
|
||||
# Windows .ico, 16 + 32px, PNG-compressed frames (Vista+ format).
|
||||
ico_frames = []
|
||||
for size in sizes:
|
||||
tmp_path = os.path.join(ASSETS, f"_ico_tmp_{size}.png")
|
||||
write_png(tmp_path, size, green_pngs[size])
|
||||
with open(tmp_path, "rb") as f:
|
||||
ico_frames.append((size, f.read()))
|
||||
os.remove(tmp_path)
|
||||
write_ico(os.path.join(ASSETS, "tray_icon.ico"), ico_frames)
|
||||
|
||||
# Linux tray icon: a StatusNotifierItem IconName resolved via an app-owned
|
||||
# IconThemePath (not the user's ~/.local/share/icons - no cache to
|
||||
# invalidate). Convention and fill colour (#bebebe) copied directly from
|
||||
# /opt/frequency/icons/frequency-tray-symbolic.svg, a working reference on
|
||||
# this exact machine: GNOME Shell recolors any such named symbolic icon to
|
||||
# monochrome at render time regardless of source colour, so the exact
|
||||
# shade doesn't matter - what matters is shipping a NAME the shell resolves
|
||||
# itself rather than raw pixmap bytes (which is what breaks on GNOME's
|
||||
# AppIndicator extension per Cascade's README).
|
||||
svg = (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" '
|
||||
f'viewBox="0 0 {VIEWBOX} {VIEWBOX}"><path d="{PATH_D}" fill="#bebebe"/></svg>\n'
|
||||
)
|
||||
with open(os.path.join(ASSETS, "atk-tray-symbolic.svg"), "w") as f:
|
||||
f.write(svg)
|
||||
|
||||
print("Wrote tray_icon.png, tray_icon_template.png, tray_icon.ico, atk-tray-symbolic.svg")
|
||||
print("Preview files: assets/_tray_preview_16.png, assets/_tray_preview_32.png (delete after checking)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue