techglass-mint / studio.py
EHS-Studio's picture
Taskbar page with styles, position, auto-hide, markers and dock magnify, plus a Windows page
ff0bc2d verified
Raw History Blame Contribute Delete
51.9 kB
#!/usr/bin/env python3
"""TechGlass Studio, a GTK3 editor for the TechGlass Mint desktop."""
import colorsys
import copy
import math
import random
import re
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path
import cairo
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
import tgcore # noqa: E402
CACHE = Path.home() / ".cache" / "techglass-mint"
ICON = Path(__file__).resolve().parent / "icon.svg"
# settings whose change means the wallpaper preview must be re-rendered
WALL_KEYS = {"base", "accent", "accent2", "highlight", "text", "wallpaper", "blur"}
STUDIO_CSS = b"""
.tg-section { font-weight: 700; font-size: 1.05em; margin-top: 10px; }
.tg-hint { opacity: 0.6; font-size: 0.9em; }
.tg-preset { padding: 6px; border-radius: 14px; }
.tg-status { opacity: 0.75; }
"""
# ---------------------------------------------------------------- drawing helpers
def css_color(s):
"""'#rrggbb' or 'rgba(r, g, b, a)' -> (r, g, b, a) floats."""
if s.startswith("#"):
r, g, b = tgcore.rgb(s)
return r / 255, g / 255, b / 255, 1.0
m = re.match(r"rgba\((\d+), (\d+), (\d+), ([\d.]+)\)", s)
r, g, b, a = m.groups()
return int(r) / 255, int(g) / 255, int(b) / 255, float(a)
def with_alpha(c, a):
return c[0], c[1], c[2], a
def rounded(cr, x, y, w, h, r):
r = max(0, min(r, w / 2, h / 2))
cr.new_sub_path()
cr.arc(x + w - r, y + r, r, -math.pi / 2, 0)
cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2)
cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi)
cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2)
cr.close_path()
def vgrad(y0, y1, top, bot):
g = cairo.LinearGradient(0, y0, 0, y1)
g.add_color_stop_rgba(0, *top)
g.add_color_stop_rgba(1, *bot)
return g
def glass(cr, x, y, w, h, r, top, bot, border):
rounded(cr, x, y, w, h, r)
cr.set_source(vgrad(y, y + h, top, bot))
cr.fill_preserve()
cr.set_source_rgba(*border)
cr.set_line_width(1)
cr.stroke()
def shadow(cr, x, y, w, h, r, strength=0.35, spread=10):
for i in range(spread, 0, -2):
rounded(cr, x - i / 2, y - i / 3 + i * 0.6, w + i, h + i, r + i / 2)
cr.set_source_rgba(0, 0, 0, strength / spread * 1.2)
cr.fill()
def bar(cr, x, y, w, h, col):
rounded(cr, x, y, w, h, h / 2)
cr.set_source_rgba(*col)
cr.fill()
def hexagon(cr, cx, cy, r):
for i, a in enumerate(range(-90, 270, 60)):
px, py = cx + r * math.cos(math.radians(a)), cy + r * math.sin(math.radians(a))
cr.line_to(px, py) if i else cr.move_to(px, py)
cr.close_path()
SAMPLE_APPS = [(0.95, 0.55, 0.25), (0.65, 0.70, 0.75), (0.12, 0.84, 0.38), (0.45, 0.48, 0.52),
(0.35, 0.80, 0.30), (0.40, 0.45, 0.95), (0.20, 0.45, 0.80), (0.95, 0.40, 0.30),
(0.98, 0.45, 0.20)]
# ---------------------------------------------------------------- preview
class Preview(Gtk.DrawingArea):
def __init__(self, studio):
super().__init__()
self.studio = studio
self.pixbuf = self.blurred = None
self._scaled = self._scaled_blur = None
self.set_size_request(560, 315)
self.connect("draw", self.on_draw)
def set_wallpaper(self, path, blur_path=None):
try:
self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(str(path))
self.blurred = GdkPixbuf.Pixbuf.new_from_file(str(blur_path)) if blur_path else None
except GLib.Error:
self.pixbuf = self.blurred = None
self._scaled = self._scaled_blur = None
self.queue_draw()
def frost(self, cr, x, y, w, h, r, W, H):
"""Paint the blurred wallpaper inside a rounded rect, like Blur Cinnamon does."""
if not self.blurred:
return
if not self._scaled_blur or self._scaled_blur.get_width() != int(W):
self._scaled_blur = self.blurred.scale_simple(int(W), int(H), GdkPixbuf.InterpType.BILINEAR)
cr.save()
rounded(cr, x, y, w, h, r)
cr.clip()
Gdk.cairo_set_source_pixbuf(cr, self._scaled_blur, 0, 0)
cr.paint()
cr.restore()
def on_draw(self, _w, cr):
aw, ah = self.get_allocated_width(), self.get_allocated_height()
W = min(aw, ah * 16 / 9)
H = W * 9 / 16
ox, oy = (aw - W) / 2, (ah - H) / 2
cr.translate(ox, oy)
rounded(cr, 0, 0, W, H, 12)
cr.clip()
cfg = self.studio.cfg
p = tgcore.palette(cfg)
base = css_color(cfg["base"])
if self.pixbuf:
if not self._scaled or self._scaled.get_width() != int(W):
self._scaled = self.pixbuf.scale_simple(int(W), int(H), GdkPixbuf.InterpType.BILINEAR)
Gdk.cairo_set_source_pixbuf(cr, self._scaled, 0, 0)
else:
cr.set_source_rgba(*base)
cr.paint()
u = W / 1100
text = css_color(p["text"])
acc = css_color(p["accent"])
deep = css_color(p["accent_deep"])
border = css_color(p["border"])
gt, gb = css_color(p["glass_top"]), css_color(p["glass_bot"])
mt, mb = css_color(p["menu_top"]), css_color(p["menu_bot"])
cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
# -- taskbar
tb = cfg["taskbar"]
top = tb["position"] == "top"
style = tb["style"]
ph, gap, margin = cfg["panel_height"] * u, cfg["panel_gap"] * u, cfg["panel_margin"] * u
rp = p["r_panel"] * u
y0 = 0 if top else H - ph
pt, pbm = css_color(p["panel_top"]), css_color(p["panel_bot"])
pedge = css_color(p["panel_border"])
icon = cfg["icon_size"] * u
iw = (cfg["icon_size"] + 6) * u
sp = tb["spacing"] * u
n = len(SAMPLE_APPS)
cw = n * iw + (n - 1) * sp + 12 * u
cx0 = (W - cw) / 2
label = tb["menu_label"].strip() or ("Menu" if tb["menu_icon"] == "none" else "")
cr.set_font_size(13 * u)
label_w = cr.text_extents(label).x_advance if label else 0
has_icon = tb["menu_icon"] != "none"
lw = (icon if has_icon else 0) + (label_w + (6 * u if has_icon else 0) if label else 0) + 22 * u
rw = 250 * u
if style == "bar":
py, pH = y0, ph
cr.rectangle(0, y0, W, ph)
cr.set_source(vgrad(y0, y0 + ph, pt, pbm))
cr.fill()
edge = y0 + ph - 0.5 if top else y0 + 0.5
cr.set_source_rgba(*pedge)
cr.move_to(0, edge)
cr.line_to(W, edge)
cr.stroke()
else:
py, pH = y0 + gap, ph - 2 * gap
glass(cr, cx0, py, cw, pH, rp, pt, pbm, pedge)
if style == "pills":
glass(cr, margin, py, lw, pH, rp, pt, pbm, pedge)
glass(cr, W - margin - rw, py, rw, pH, rp, pt, pbm, pedge)
mid = py + pH / 2
# menu button
mx0 = margin + 11 * u
if tb["menu_icon"] == "techglass":
hexagon(cr, mx0 + icon / 2, mid, icon * 0.46)
g = cairo.LinearGradient(0, mid - icon / 2, 0, mid + icon / 2)
g.add_color_stop_rgba(0, *css_color(cfg["highlight"]))
g.add_color_stop_rgba(1, *css_color(cfg["accent2"]))
cr.set_source(g)
cr.fill()
elif tb["menu_icon"] == "mint":
cr.arc(mx0 + icon / 2, mid, icon * 0.44, 0, 2 * math.pi)
cr.set_source_rgba(*with_alpha(text, 0.9))
cr.set_line_width(2.2 * u)
cr.stroke()
cr.set_line_width(1)
if label:
cr.move_to(mx0 + (icon + 6 * u if has_icon else 0), mid + 4.5 * u)
cr.set_source_rgba(*text)
cr.show_text(label)
# window list
inset = (cfg["panel_gap"] + 3) * u
ind_y = y0 + inset + 3.5 * u if top else y0 + ph - inset - 3.5 * u
ri = p["r_item"] * u
def indicator(x, kind, col, wide=False):
if kind == "none":
return
if kind == "line" or (kind == "bar" and wide):
bar(cr, x + ri, ind_y - 1 * u, iw - 2 * ri, 2 * u, col)
elif kind == "bar" or wide:
bar(cr, x + iw / 2 - 4.5 * u, ind_y - 1.3 * u, 9 * u, 2.6 * u, col)
else:
cr.arc(x + iw / 2, ind_y, 1.7 * u, 0, 2 * math.pi)
cr.set_source_rgba(*col)
cr.fill()
kind = tb["indicator"]
focus_kind = kind if kind != "none" else "bar"
hover_x = cx0 + 6 * u + 2 * (iw + sp) + iw / 2 # pretend the pointer rests on the third app
for i, col in enumerate(SAMPLE_APPS):
x = cx0 + 6 * u + i * (iw + sp)
if i == 6:
if tb["focus"] == "underline":
bar(cr, x + ri - 1 * u, ind_y - 1.5 * u, iw - 2 * ri + 2 * u, 3 * u, acc)
else:
rounded(cr, x, y0 + inset, iw, ph - 2 * inset, ri)
if tb["focus"] == "accent":
cr.set_source_rgba(*with_alpha(acc, 0.55))
else:
cr.set_source(vgrad(y0 + inset, y0 + ph - inset, with_alpha(acc, 0.42), with_alpha(acc, 0.2)))
cr.fill_preserve()
cr.set_source_rgba(*with_alpha(acc, 0.55))
cr.stroke()
indicator(x, focus_kind, (1, 1, 1, 1) if tb["focus"] == "accent" else with_alpha(acc, 1), True)
elif i in (1, 4, 8):
indicator(x, kind, with_alpha(acc, 0.9))
k = max(0.0, 1 - abs(x + iw / 2 - hover_x) / (iw * 2.6))
sc = 1 + tb["magnify"] * k * k
size = icon * sc
iy = (mid - icon / 2) if top else (mid + icon / 2 - size)
rounded(cr, x + iw / 2 - size / 2, iy, size, size, size * 0.28)
cr.set_source_rgb(*col)
cr.fill()
# tray + clock
tx = W - margin - rw + 16 * u
for i in range(5):
cr.arc(tx + i * 24 * u, mid, 6 * u, 0, 2 * math.pi)
cr.set_source_rgba(*with_alpha(text, 0.85))
cr.fill()
fmt = tgcore.CLOCK_FORMATS.get(tb["clock"], "%H:%M")
clock = time.strftime(fmt).strip()
if tb["battery"]:
clock = "87% " + clock
cr.set_font_size(12 * u)
cw_text = cr.text_extents(clock).x_advance
cr.move_to(W - margin - 14 * u - cw_text, mid + 4.5 * u)
cr.set_source_rgba(*text)
cr.show_text(clock)
# -- application menu
rm = p["r_menu"] * u
mx, mw, mh = margin, 420 * u, 360 * u
my = y0 + ph + 8 * u if top else y0 - 8 * u - mh
shadow(cr, mx, my, mw, mh, rm)
blur = cfg["blur"]["enabled"] and tgcore.blur_available()
if blur and cfg["blur"]["popups"]:
self.frost(cr, mx, my, mw, mh, rm, W, H)
glass(cr, mx, my, mw, mh, rm, mt, mb, border)
rounded(cr, mx, my, 56 * u, mh, rm)
cr.set_source_rgba(1, 1, 1, 0.035)
cr.fill()
for i in range(5):
rounded(cr, mx + 14 * u, my + (22 + i * 40) * u, 28 * u, 28 * u, 8 * u)
cr.set_source_rgba(*SAMPLE_APPS[i], 0.9)
cr.fill()
for i, col in enumerate(((1, 1, 1, 0.10), (1, 1, 1, 0.10), (0.94, 0.32, 0.41, 0.75))):
cr.arc(mx + 28 * u, my + mh - (30 + i * 38) * u, 13 * u, 0, 2 * math.pi)
cr.set_source_rgba(*col)
cr.fill()
ex, ew = mx + 70 * u, mw - 84 * u
rounded(cr, ex, my + 16 * u, ew, 30 * u, p["r_small"] * u)
cr.set_source_rgba(1, 1, 1, 0.06)
cr.fill_preserve()
cr.set_source_rgba(*with_alpha(acc, 0.85))
cr.stroke()
bar(cr, ex + 12 * u, my + 28 * u, 90 * u, 6 * u, with_alpha(text, 0.35))
ri = p["r_item"] * u
for i in range(8):
y = my + (62 + i * 34) * u
if i == 1:
rounded(cr, ex, y - 6 * u, 120 * u, 28 * u, ri)
g = cairo.LinearGradient(ex, 0, ex + 120 * u, 0)
g.add_color_stop_rgba(0, *with_alpha(acc, 0.34))
g.add_color_stop_rgba(1, *with_alpha(acc, 0.10))
cr.set_source(g)
cr.fill()
bar(cr, ex + 10 * u, y + 5 * u, (70 + (i * 23) % 40) * u, 6 * u, with_alpha(text, 0.75))
for i in range(6):
y = my + (62 + i * 44) * u
ax = ex + 136 * u
if i == 2:
rounded(cr, ax - 6 * u, y - 6 * u, ew - 130 * u, 38 * u, ri)
cr.set_source_rgba(*with_alpha(acc, 0.22))
cr.fill()
rounded(cr, ax, y, 26 * u, 26 * u, 7 * u)
cr.set_source_rgb(*SAMPLE_APPS[(i + 3) % n])
cr.fill()
bar(cr, ax + 36 * u, y + 4 * u, (90 + (i * 31) % 60) * u, 6 * u, with_alpha(text, 0.85))
bar(cr, ax + 36 * u, y + 16 * u, (120 + (i * 17) % 50) * u, 5 * u, with_alpha(text, 0.4))
# -- notification
nw, nh = 300 * u, 72 * u
nx, ny = W - 24 * u - nw, (ph if top else 0) + 24 * u
shadow(cr, nx, ny, nw, nh, rm, 0.25)
if blur and cfg["blur"]["notifications"]:
self.frost(cr, nx, ny, nw, nh, rm, W, H)
glass(cr, nx, ny, nw, nh, rm, mt, mb, border)
cr.arc(nx + 34 * u, ny + nh / 2, 16 * u, 0, 2 * math.pi)
cr.set_source_rgba(*acc)
cr.fill()
bar(cr, nx + 62 * u, ny + 24 * u, 120 * u, 7 * u, with_alpha(text, 0.9))
bar(cr, nx + 62 * u, ny + 40 * u, 190 * u, 6 * u, with_alpha(text, 0.45))
# -- GTK window
wx, wy, ww, wh = W * 0.43, 112 * u, 420 * u, 290 * u
s0, s1, s2 = (css_color(p[k]) for k in ("surface_0", "surface_1", "surface_2"))
shadow(cr, wx, wy, ww, wh, 10 * u, 0.4)
rounded(cr, wx, wy, ww, wh, 10 * u)
cr.save()
cr.clip()
cr.set_source_rgba(*s2)
cr.paint()
cr.rectangle(wx, wy, ww, 40 * u)
cr.set_source_rgba(*s1)
cr.fill()
cr.rectangle(wx, wy + 40 * u, 120 * u, wh)
cr.set_source_rgba(*s0)
cr.fill()
for i, c in enumerate(((0.85, 0.85, 0.85, 0.35),) * 2 + ((0.95, 0.35, 0.35, 0.9),)):
cr.arc(wx + ww - (18 + (2 - i) * 22) * u, wy + 20 * u, 6.5 * u, 0, 2 * math.pi)
cr.set_source_rgba(*c)
cr.fill()
bar(cr, wx + 140 * u, wy + 16 * u, 110 * u, 8 * u, with_alpha(text, 0.8))
for i in range(6):
y = wy + (56 + i * 30) * u
if i == 0:
rounded(cr, wx + 8 * u, y - 6 * u, 104 * u, 26 * u, 6 * u)
cr.set_source_rgba(*with_alpha(deep, 0.85))
cr.fill()
bar(cr, wx + 20 * u, y + 4 * u, (56 + i * 7) * u, 6 * u, with_alpha(text, 0.7))
for i in range(4):
bar(cr, wx + 140 * u, wy + (64 + i * 30) * u, (200 - i * 25) * u, 7 * u, with_alpha(text, 0.55))
rounded(cr, wx + ww - 110 * u, wy + wh - 48 * u, 92 * u, 30 * u, 8 * u)
cr.set_source_rgba(*deep)
cr.fill()
bar(cr, wx + ww - 88 * u, wy + wh - 36 * u, 48 * u, 6 * u, (1, 1, 1, 0.95))
rounded(cr, wx + 140 * u, wy + wh - 44 * u, 44 * u, 22 * u, 11 * u)
cr.set_source_rgba(*deep)
cr.fill()
cr.arc(wx + 173 * u, wy + wh - 33 * u, 8 * u, 0, 2 * math.pi)
cr.set_source_rgb(1, 1, 1)
cr.fill()
cr.restore()
# -- kitty
if cfg["kitty"]["enabled"]:
kw, kh = 330 * u, 140 * u
kx, ky = W - 24 * u - kw, (H if top else y0) - 14 * u - kh
bg = tgcore.lighten(tgcore.rgb(cfg["base"]), 0.02)
rounded(cr, kx, ky, kw, kh, 8 * u)
cr.set_source_rgba(bg[0] / 255, bg[1] / 255, bg[2] / 255, cfg["kitty"]["opacity"])
cr.fill_preserve()
cr.set_source_rgba(*border)
cr.stroke()
cr.select_font_face("Monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
cr.set_font_size(12 * u)
lines = [((acc, "you@mint"), (text, " ~ $ neofetch")),
((css_color(cfg["accent2"]), "OS: "), (text, "Linux Mint 22.3")),
((css_color(cfg["accent2"]), "DE: "), (text, "Cinnamon · TechGlass")),
((acc, "you@mint"), (text, " ~ $ _"))]
for i, parts in enumerate(lines):
cr.move_to(kx + 14 * u, ky + (28 + i * 26) * u)
for col, s in parts:
cr.set_source_rgba(*col)
cr.show_text(s)
class Swatch(Gtk.DrawingArea):
"""Small card that sketches a preset."""
def __init__(self, preset):
super().__init__()
self.p = preset
self.set_size_request(150, 84)
self.connect("draw", self.on_draw)
def on_draw(self, _w, cr):
w, h = self.get_allocated_width(), self.get_allocated_height()
p = self.p
rounded(cr, 0, 0, w, h, 12)
cr.clip()
cr.set_source(vgrad(0, h, css_color(p["base"]),
css_color(tgcore.hexc(tgcore.lighten(tgcore.rgb(p["base"]), 0.10)))))
cr.paint()
for k, a in ((0, 0.9), (6, 0.4), (12, 0.2)):
cr.move_to(0, h * 0.55 + k)
cr.curve_to(w * 0.3, h * 0.2 + k, w * 0.6, h * 0.9 + k, w, h * 0.45 + k)
g = cairo.LinearGradient(0, 0, w, 0)
g.add_color_stop_rgba(0, *with_alpha(css_color(p["accent2"]), a))
g.add_color_stop_rgba(0.5, *with_alpha(css_color(p["accent"]), a))
g.add_color_stop_rgba(1, *with_alpha(css_color(p["highlight"]), a))
cr.set_source(g)
cr.set_line_width(2)
cr.stroke()
base = tgcore.rgb(p["base"])
top = tgcore.mix(tgcore.lighten(base, 0.16), tgcore.rgb(p["accent"]), 0.12)
glass(cr, w * 0.2, h - 22, w * 0.6, 16, 8, (top[0] / 255, top[1] / 255, top[2] / 255, 0.6),
(base[0] / 255, base[1] / 255, base[2] / 255, 0.7), (1, 1, 1, 0.18))
cr.arc(w * 0.2 + 12, h - 14, 4, 0, 2 * math.pi)
cr.set_source_rgba(*css_color(p["accent"]))
cr.fill()
# ---------------------------------------------------------------- main window
class Studio(Gtk.Window):
def __init__(self):
super().__init__(title="TechGlass Studio")
self.cfg = tgcore.load_config()
self.refreshers = []
self._switches = {}
self._wall_timer = None
self._wall_gen = 0
self.busy = False
CACHE.mkdir(parents=True, exist_ok=True)
self.set_default_size(1240, 760)
if ICON.exists():
self.set_icon_from_file(str(ICON))
css = Gtk.CssProvider()
css.load_from_data(STUDIO_CSS)
Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), css,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
hb = Gtk.HeaderBar(show_close_button=True, title="TechGlass Studio", subtitle="TechGlass Mint")
self.set_titlebar(hb)
self.apply_btn = Gtk.Button(label="Apply to desktop")
self.apply_btn.get_style_context().add_class("suggested-action")
self.apply_btn.connect("clicked", self.on_apply)
hb.pack_end(self.apply_btn)
menu_btn = Gtk.MenuButton(image=Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON))
menu = Gtk.Menu()
for label, cb in (("Reset to TechGlass defaults", self.on_reset),
("Revert to my original desktop…", self.on_revert)):
item = Gtk.MenuItem(label=label)
item.connect("activate", cb)
menu.append(item)
menu.show_all()
menu_btn.set_popup(menu)
hb.pack_end(menu_btn)
paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
self.add(paned)
left = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
sidebar = Gtk.StackSidebar(stack=self.stack)
sidebar.set_size_request(150, -1)
left.pack_start(sidebar, False, False, 0)
left.pack_start(self.stack, True, True, 0)
paned.pack1(left, resize=False, shrink=False)
right = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=14)
self.preview = Preview(self)
right.pack_start(self.preview, True, True, 0)
status_row = Gtk.Box(spacing=8)
self.spinner = Gtk.Spinner()
self.status = Gtk.Label(label="Changes show in the preview. Apply to put them on your desktop.",
xalign=0)
self.status.get_style_context().add_class("tg-status")
status_row.pack_start(self.spinner, False, False, 0)
status_row.pack_start(self.status, True, True, 0)
right.pack_start(status_row, False, False, 0)
paned.pack2(right, resize=True, shrink=False)
paned.set_position(560)
self.build_presets()
self.build_colors()
self.build_glass()
self.build_panel()
self.build_windows()
self.build_wallpaper()
self.build_live()
self.build_fonts()
self.build_terminal()
self.build_blur()
self.build_ripple()
self.build_animations()
self.build_size()
self.schedule_wallpaper(0)
# ---------- generic page/row helpers
def page(self, name, title):
grid = Gtk.Grid(column_spacing=16, row_spacing=10, margin=18)
grid.set_hexpand(True)
sw = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
sw.add(grid)
self.stack.add_titled(sw, name, title)
grid._row = 0
return grid
def section(self, grid, title, hint=None):
lbl = Gtk.Label(label=title, xalign=0)
lbl.get_style_context().add_class("tg-section")
grid.attach(lbl, 0, grid._row, 2, 1)
grid._row += 1
if hint:
h = Gtk.Label(label=hint, xalign=0, wrap=True, max_width_chars=48)
h.get_style_context().add_class("tg-hint")
grid.attach(h, 0, grid._row, 2, 1)
grid._row += 1
def row(self, grid, label, widget):
grid.attach(Gtk.Label(label=label, xalign=0), 0, grid._row, 1, 1)
widget.set_hexpand(True)
grid.attach(widget, 1, grid._row, 1, 1)
grid._row += 1
def get(self, path):
v = self.cfg
for k in path:
v = v[k]
return v
def set(self, path, value):
d = self.cfg
for k in path[:-1]:
d = d[k]
if d.get(path[-1]) == value:
return
d[path[-1]] = value
if path[0] in tgcore.PRESETS["TechGlass Mint"] and path[0] != "tint":
self.cfg["preset"] = "Custom"
self.changed(path)
def changed(self, path):
self.preview.queue_draw()
if path[0] in WALL_KEYS:
self.schedule_wallpaper()
def scale(self, grid, label, path, lo, hi, step, digits=0):
adj = Gtk.Adjustment(value=self.get(path), lower=lo, upper=hi, step_increment=step)
sc = Gtk.Scale(adjustment=adj, digits=digits, value_pos=Gtk.PositionType.RIGHT)
sc.connect("value-changed",
lambda s: self.set(path, round(s.get_value(), digits) if digits else int(s.get_value())))
self.refreshers.append(lambda: adj.set_value(self.get(path)))
self.row(grid, label, sc)
return sc
def switch(self, grid, label, path):
sw = Gtk.Switch(active=self.get(path), halign=Gtk.Align.START)
twins = self._switches.setdefault(path, []) # the same setting can appear on two pages
twins.append(sw)
def toggled(s, _p):
self.set(path, s.get_active())
for other in twins:
if other is not s and other.get_active() != s.get_active():
other.set_active(s.get_active())
sw.connect("notify::active", toggled)
self.refreshers.append(lambda: sw.set_active(self.get(path)))
self.row(grid, label, sw)
return sw
def color(self, grid, label, path):
btn = Gtk.ColorButton(use_alpha=False, halign=Gtk.Align.START)
btn.set_size_request(120, -1)
def load():
c = Gdk.RGBA()
c.parse(self.get(path))
btn.set_rgba(c)
def on_set(b):
c = b.get_rgba()
self.set(path, tgcore.hexc((c.red * 255, c.green * 255, c.blue * 255)))
load()
btn.connect("color-set", on_set)
self.refreshers.append(load)
self.row(grid, label, btn)
def combo(self, grid, label, path, options, cast=str):
cb = Gtk.ComboBoxText()
for value, text in options:
cb.append(str(value), text)
def load():
cur = str(self.get(path))
if not cb.set_active_id(cur):
cb.append(cur, cur)
cb.set_active_id(cur)
load()
cb.connect("changed", lambda c: c.get_active_id() and self.set(path, cast(c.get_active_id())))
self.refreshers.append(load)
self.row(grid, label, cb)
def refresh_all(self):
for f in self.refreshers:
f()
self.preview.queue_draw()
self.schedule_wallpaper(0)
# ---------- pages
def build_presets(self):
g = self.page("presets", "Presets")
self.section(g, "Start from a preset",
"Each preset sets the colours. Glass, panel and wallpaper settings stay as they are.")
flow = Gtk.FlowBox(min_children_per_line=2, max_children_per_line=3, homogeneous=True, selection_mode=Gtk.SelectionMode.NONE,
column_spacing=10, row_spacing=10)
for name, preset in tgcore.PRESETS.items():
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
box.pack_start(Swatch(preset), False, False, 0)
box.pack_start(Gtk.Label(label=name), False, False, 0)
btn = Gtk.Button()
btn.add(box)
btn.get_style_context().add_class("tg-preset")
btn.connect("clicked", lambda _b, n=name: self.use_preset(n))
flow.add(btn)
g.attach(flow, 0, g._row, 2, 1)
g._row += 1
self.section(g, "Feeling lucky?", "Rolls a new dark green palette.")
shuffle = Gtk.Button(label="Shuffle green palette", halign=Gtk.Align.START)
shuffle.connect("clicked", self.on_shuffle)
g.attach(shuffle, 0, g._row, 2, 1)
g._row += 1
def use_preset(self, name):
self.cfg.update(copy.deepcopy(tgcore.PRESETS[name]))
self.cfg["preset"] = name
self.refresh_all()
self.status.set_text(f"Preset “{name}” loaded. Apply to use it.")
def on_shuffle(self, _b):
h = random.uniform(0.33, 0.47) # green to teal
def c(hh, l, s):
return tgcore.hexc(tuple(v * 255 for v in colorsys.hls_to_rgb(hh % 1, l, s)))
self.cfg.update(base=c(h, random.uniform(0.04, 0.07), random.uniform(0.35, 0.6)),
accent=c(h + random.uniform(-0.03, 0.03), random.uniform(0.5, 0.62), random.uniform(0.65, 0.9)),
accent2=c(h + random.uniform(0.03, 0.09), random.uniform(0.38, 0.5), random.uniform(0.6, 0.85)),
highlight=c(h - random.uniform(0.05, 0.1), random.uniform(0.62, 0.72), random.uniform(0.7, 0.9)),
text=c(h, 0.93, 0.35), tint=round(random.uniform(0.15, 0.32), 2), preset="Custom")
self.refresh_all()
def build_colors(self):
g = self.page("colors", "Colours")
self.section(g, "Palette")
self.color(g, "Background tone", ("base",))
self.color(g, "Accent", ("accent",))
self.color(g, "Secondary accent", ("accent2",))
self.color(g, "Highlight", ("highlight",))
self.color(g, "Text", ("text",))
self.section(g, "Window tint", "How strongly app windows and menus pick up the background tone.")
self.scale(g, "Tint strength", ("tint",), 0, 0.6, 0.01, 2)
def build_glass(self):
g = self.page("glass", "Glass")
self.section(g, "Transparency",
"Cinnamon can't blur what's behind the glass, so menus stay fairly solid to keep text readable.")
self.scale(g, "Panel glass opacity", ("panel_opacity",), 0.1, 1.0, 0.01, 2)
self.scale(g, "Menu & popup opacity", ("menu_opacity",), 0.5, 1.0, 0.01, 2)
self.scale(g, "Edge highlight", ("border_alpha",), 0.0, 0.4, 0.01, 2)
self.section(g, "Shape")
self.scale(g, "Corner radius", ("radius",), 0, 20, 1)
def build_panel(self):
g = self.page("panel", "Taskbar")
self.section(g, "Layout")
self.combo(g, "Position", ("taskbar", "position"), (("bottom", "Bottom"), ("top", "Top")))
self.combo(g, "Style", ("taskbar", "style"),
(("pills", "Floating pills"), ("dock", "Centre dock (only the apps float)"),
("bar", "Full-width glass bar")))
self.combo(g, "Hide it", ("taskbar", "autohide"),
(("never", "Never"), ("smart", "When a window touches it"), ("always", "Always, show on hover")))
self.scale(g, "Height", ("panel_height",), 36, 84, 1)
self.scale(g, "Gap around pills", ("panel_gap",), 2, 10, 1)
self.scale(g, "Side margin", ("panel_margin",), 0, 40, 1)
self.section(g, "Look")
self.combo(g, "Shape", ("taskbar", "shape"),
(("rounded", "Rounded (follows the theme)"), ("capsule", "Capsule"), ("square", "Nearly square")))
self.combo(g, "Glass tint", ("taskbar", "tint"),
(("theme", "Same as the theme"), ("accent", "Accent tinted"), ("dark", "Darker"),
("clear", "Clear glass")))
self.switch(g, "Accent outline", ("taskbar", "accent_edge"))
self.section(g, "Apps", "Magnify grows the icons under the pointer like a dock. 0 turns it off.")
self.combo(g, "Running apps", ("taskbar", "indicator"),
(("dot", "Dot"), ("bar", "Short bar"), ("line", "Line"), ("none", "No marker")))
self.combo(g, "Focused app", ("taskbar", "focus"),
(("glass", "Glass highlight"), ("accent", "Accent fill"), ("underline", "Underline")))
self.scale(g, "App icon size", ("icon_size",), 16, 48, 1)
self.scale(g, "Icon spacing", ("taskbar", "spacing"), 0, 16, 1)
self.scale(g, "Magnify on hover", ("taskbar", "magnify"), 0, 1.0, 0.05, 2)
self.combo(g, "Titles", ("taskbar", "titles"),
(("none", "Icons only"), ("app", "App names"), ("focused", "Title of the focused window")))
self.switch(g, "Group windows of an app", ("taskbar", "group"))
self.switch(g, "Window previews on hover", ("taskbar", "previews"))
self.section(g, "Menu button")
self.combo(g, "Icon", ("taskbar", "menu_icon"),
(("techglass", "TechGlass logo"), ("mint", "Linux Mint logo"), ("none", "No icon")))
self.entry(g, "Label", ("taskbar", "menu_label"), "No label")
self.section(g, "Tray and clock")
self.scale(g, "Tray icon size", ("tray_icon_size",), 12, 32, 1)
self.combo(g, "Clock", ("taskbar", "clock"),
(("time", "14:23"), ("date", "Tue 28 Oct 14:23"), ("seconds", "14:23:05"),
("12h", "2:23 PM"), ("system", "Cinnamon's own format")))
self.switch(g, "Battery percentage", ("taskbar", "battery"))
def build_windows(self):
g = self.page("windows", "Windows")
self.section(g, "Title bar", "Works for Cinnamon's title bars and for apps that draw their own.")
self.combo(g, "Buttons", ("windows", "buttons"),
(("right", "Minimize, maximize, close on the right"), ("minclose", "Minimize and close"),
("close", "Close only"), ("left", "On the left, like macOS")))
self.combo(g, "Double-click", ("windows", "double_click"),
(("toggle-maximize", "Maximize"), ("toggle-shade", "Roll up"), ("minimize", "Minimize"),
("none", "Nothing")))
self.section(g, "Behaviour")
self.switch(g, "Snap to screen edges", ("windows", "edge_tiling"))
self.combo(g, "Focus", ("windows", "focus"),
(("click", "Click to focus"), ("sloppy", "Focus follows the mouse")))
def entry(self, grid, label, path, placeholder=""):
e = Gtk.Entry(text=self.get(path), placeholder_text=placeholder)
e.connect("changed", lambda w: self.set(path, w.get_text()))
self.refreshers.append(lambda: e.get_text() != self.get(path) and e.set_text(self.get(path)))
self.row(grid, label, e)
def build_wallpaper(self):
g = self.page("wallpaper", "Wallpaper")
self.section(g, "Source")
self.switch(g, "Live wallpaper", ("live", "enabled"))
self.combo(g, "Wallpaper", ("wallpaper", "mode"),
(("generated", "Generate TechGlass art"), ("image", "Use my own image")))
chooser = Gtk.FileChooserButton(title="Choose a wallpaper", action=Gtk.FileChooserAction.OPEN)
flt = Gtk.FileFilter()
flt.set_name("Images")
flt.add_mime_type("image/*")
chooser.add_filter(flt)
if self.cfg["wallpaper"]["image"]:
chooser.set_filename(self.cfg["wallpaper"]["image"])
chooser.connect("file-set", lambda c: (self.set(("wallpaper", "image"), c.get_filename() or ""),
self.set(("wallpaper", "mode"), "image")))
self.row(g, "Own image", chooser)
self.section(g, "Generated art", "Uses your palette colours.")
self.scale(g, "Glow", ("wallpaper", "glow"), 0, 1, 0.01, 2)
self.switch(g, "Silk strands", ("wallpaper", "strands"))
self.scale(g, "Strand count", ("wallpaper", "strand_count"), 4, 30, 1)
self.switch(g, "Circuit traces", ("wallpaper", "circuits"))
self.scale(g, "Circuit density", ("wallpaper", "circuit_density"), 0.05, 1, 0.01, 2)
self.switch(g, "Dot grid", ("wallpaper", "grid"))
self.combo(g, "Glass shapes", ("wallpaper", "shapes"),
(("hexagons", "Frosted hexagons"), ("circles", "Frosted discs"), ("none", "None")))
self.switch(g, "Film grain", ("wallpaper", "grain"))
seed_row = Gtk.Box(spacing=8)
adj = Gtk.Adjustment(value=self.cfg["wallpaper"]["seed"], lower=0, upper=99999, step_increment=1)
spin = Gtk.SpinButton(adjustment=adj)
spin.connect("value-changed", lambda s: self.set(("wallpaper", "seed"), int(s.get_value())))
self.refreshers.append(lambda: adj.set_value(self.cfg["wallpaper"]["seed"]))
roll = Gtk.Button(label="Shuffle layout")
roll.connect("clicked", lambda _b: adj.set_value(random.randint(0, 99999)))
seed_row.pack_start(spin, False, False, 0)
seed_row.pack_start(roll, False, False, 0)
self.row(g, "Layout seed", seed_row)
def build_fonts(self):
g = self.page("fonts", "Fonts & icons")
self.section(g, "Fonts")
for label, key in (("Interface font", "font"), ("Title bar font", "titlebar_font")):
fb = Gtk.FontButton(font=self.cfg[key], halign=Gtk.Align.START)
fb.connect("font-set", lambda b, k=key: self.set((k,), b.get_font()))
self.refreshers.append(lambda b=fb, k=key: b.set_font(self.cfg[k]))
self.row(g, label, fb)
self.section(g, "Themes")
icons, cursors = scan_icon_themes()
self.combo(g, "Icon theme", ("icon_theme",), [(n, n) for n in icons])
self.combo(g, "Cursor", ("cursor_theme",), [(n, n) for n in cursors])
def build_terminal(self):
g = self.page("terminal", "Terminal")
self.section(g, "kitty", "Writes a matching colour scheme to ~/.config/kitty/techglass.conf.")
self.switch(g, "Theme kitty", ("kitty", "enabled"))
self.scale(g, "Background opacity", ("kitty", "opacity"), 0.4, 1.0, 0.01, 2)
def build_blur(self):
g = self.page("blur", "Blur")
if tgcore.blur_available():
self.section(g, "Real blur", "Uses the Blur Cinnamon extension to frost whatever is behind "
"menus and popups, windows included.")
else:
self.section(g, "Real blur", "Blur Cinnamon isn't installed. Get it from System Settings → "
"Extensions → Download, then reopen TechGlass Studio.")
self.switch(g, "Enable blur", ("blur", "enabled"))
self.scale(g, "Blur strength", ("blur", "intensity"), 4, 60, 1)
self.scale(g, "Extra tint", ("blur", "dim"), 0, 60, 1)
self.scale(g, "Glass opacity while blurred", ("blur", "glass_opacity"), 0.3, 0.95, 0.01, 2)
self.section(g, "Blur these", "Floating taskbars aren't blurred: the extension would frost the whole "
"strip behind the pills. Pick the full-width bar on the Taskbar page to blur it.")
for label, key in (("Menus", "popups"), ("Notifications", "notifications"), ("Tooltips", "tooltips"),
("Volume & brightness pop-ups", "osd"), ("Alt-Tab switcher", "appswitcher"),
("Overview & Expo backdrop", "overview"), ("Panel bar (non-floating only)", "panel")):
self.switch(g, label, ("blur", key))
def build_live(self):
g = self.page("live", "Live")
self.section(g, "Live wallpaper", "Animates the wallpaper on the GPU. It only draws while you can "
"see the desktop, so it costs nothing behind maximized windows.")
self.switch(g, "Enable live wallpaper", ("live", "enabled"))
flow = Gtk.FlowBox(min_children_per_line=3, max_children_per_line=5, homogeneous=True,
selection_mode=Gtk.SelectionMode.NONE, column_spacing=6, row_spacing=6)
for name in tgcore.LIVE_PRESETS:
b = Gtk.Button(label=name)
b.connect("clicked", lambda _b, n=name: self.use_live_preset(n))
flow.add(b)
self.row(g, "Start from", flow)
self.section(g, "Effects", "Pulses, shimmer and glints follow the generated art. Fireflies and "
"aurora also work over your own picture.")
for label, key in (("Data pulses", "pulses"), ("Silk shimmer", "shimmer"), ("Glass glints", "glints"),
("Fireflies", "fireflies"), ("Breathing light", "breathing"),
("Aurora drift", "aurora")):
self.scale(g, label, ("live", key), 0, 1.5, 0.05, 2)
self.section(g, "Motion")
self.scale(g, "Speed", ("live", "speed"), 0.3, 2.5, 0.05, 2)
self.combo(g, "Frame rate", ("live", "fps"),
((15, "15 fps (lightest)"), (24, "24 fps"), (30, "30 fps"), (60, "60 fps (smoothest)")),
cast=int)
self.section(g, "Saving power")
self.switch(g, "Pause behind maximized windows", ("live", "pause_covered"))
self.switch(g, "Pause on battery", ("live", "pause_battery"))
peek = Gtk.Button(label="Peek at the desktop (8 s)", halign=Gtk.Align.START)
peek.connect("clicked", self.on_peek_live)
g.attach(peek, 0, g._row, 2, 1)
g._row += 1
hint = Gtk.Label(label="Hides your windows for a moment so you can watch it, using the settings on "
"this page before you Apply. Colour or layout changes need Apply first.",
xalign=0, wrap=True, max_width_chars=48)
hint.get_style_context().add_class("tg-hint")
g.attach(hint, 0, g._row, 2, 1)
g._row += 1
def use_live_preset(self, name):
self.cfg["live"].update(tgcore.LIVE_PRESETS[name])
self.cfg["live"]["enabled"] = True
self.refresh_all()
self.status.set_text(f"Live preset “{name}” loaded. Peek to see it, Apply to keep it.")
def on_peek_live(self, _b):
if not self.fx_ready():
return
tgcore.fx_trigger({"preview": {"live": dict(self.cfg["live"], enabled=True, pause_covered=False)},
"preview_seconds": 12})
if shutil.which("wmctrl"):
subprocess.Popen(["wmctrl", "-k", "on"])
GLib.timeout_add(8000, lambda: (subprocess.Popen(["wmctrl", "-k", "off"]), False)[1])
self.status.set_text("Showing the desktop for 8 seconds…")
else:
self.status.set_text("Preview running for 12 seconds. Minimize your windows to see it.")
def build_ripple(self):
g = self.page("ripple", "Ripple")
self.section(g, "Water ripple", "Clicking the desktop sends a wave through the wallpaper. "
"Over windows you only get the glass ring.")
self.switch(g, "Enable ripples", ("ripple", "enabled"))
self.section(g, "Where and when")
self.combo(g, "Where", ("ripple", "where"),
(("desktop", "Desktop only"), ("everywhere", "Desktop and over windows")))
self.combo(g, "Mouse button", ("ripple", "button"),
(("left", "Left click"), ("right", "Right click"), ("middle", "Middle click"),
("any", "Any button")))
self.switch(g, "Only on double-click", ("ripple", "double"))
self.combo(g, "Hold key", ("ripple", "modifier"),
(("none", "No key needed"), ("super", "Super"), ("ctrl", "Ctrl"), ("alt", "Alt"),
("shift", "Shift")))
self.section(g, "Look")
self.scale(g, "Wave strength", ("ripple", "strength"), 0.2, 2.5, 0.05, 2)
self.scale(g, "Wave size", ("ripple", "size"), 0.5, 2.0, 0.05, 2)
self.scale(g, "Speed", ("ripple", "speed"), 0.4, 2.0, 0.05, 2)
self.switch(g, "Glass ring", ("ripple", "ring"))
test = Gtk.Button(label="Try it (25 s)", halign=Gtk.Align.START)
test.connect("clicked", self.on_test_ripple)
g.attach(test, 0, g._row, 2, 1)
g._row += 1
hint = Gtk.Label(label="Fires a ripple in the middle of the screen and lets you click the desktop "
"with these settings for 25 seconds, before you Apply.", xalign=0, wrap=True,
max_width_chars=48)
hint.get_style_context().add_class("tg-hint")
g.attach(hint, 0, g._row, 2, 1)
g._row += 1
def build_animations(self):
g = self.page("animations", "Animations")
self.section(g, "Window animations", "Genie and Whirlpool bend the window itself; the rest "
"move, scale or fade it.")
self.switch(g, "Use TechGlass animations", ("animations", "enabled"))
labels = {
"zoom": "Zoom", "fade": "Fade", "pop": "Pop (springy)", "slide": "Slide", "drop": "Drop & bounce",
"unfold": "Unfold", "flip": "3D flip", "genie": "Genie (macOS style)", "whirl": "Whirlpool",
"fall": "Fall away", "fold": "Fold", "icon": "Shrink / grow at its panel icon",
"glide": "Glide", "spring": "Spring", "bounce": "Bounce", "snap": "Snap (fast)",
"default": "Cinnamon default", "none": "None",
}
for kind, title in (("open", "Open"), ("close", "Close"), ("minimize", "Minimize"),
("unminimize", "Restore"), ("maximize", "Maximize")):
self.combo(g, title, ("animations", kind), [(m, labels[m]) for m in tgcore.ANIMATIONS[kind]])
self.scale(g, "Duration", ("animations", "speed"), 0.5, 2.0, 0.05, 2)
test = Gtk.Button(label="Play a demo window", halign=Gtk.Align.START)
test.connect("clicked", self.on_test_animations)
g.attach(test, 0, g._row, 2, 1)
g._row += 1
def build_size(self):
g = self.page("size", "Size")
self.section(g, "Scale everything", "Sets text, cursor, panel, icons and menu together. "
"Fine-tune them below or on the Panel page afterwards.")
self._master = Gtk.Adjustment(value=100, lower=80, upper=200, step_increment=5)
master = Gtk.Scale(adjustment=self._master, digits=0, value_pos=Gtk.PositionType.RIGHT)
master.connect("format-value", lambda _s, v: f"{int(v)} %")
self.row(g, "Overall size", master)
go = Gtk.Button(label="Use this size for everything", halign=Gtk.Align.START)
go.connect("clicked", self.on_scale_everything)
g.attach(go, 0, g._row, 2, 1)
g._row += 1
self.section(g, "Individual sizes")
self.scale(g, "Text size", ("size", "text_scale"), 0.8, 2.0, 0.05, 2)
self.combo(g, "Cursor size", ("size", "cursor_size"),
[(n, f"{n} px") for n in (24, 32, 48, 64, 96)], cast=int)
self.scale(g, "Menu size", ("size", "menu_scale"), 0.8, 1.5, 0.05, 2)
self.combo(g, "Interface scale", ("size", "ui_scale"),
((1, "Normal (100 %)"), (2, "Double (200 %, for HiDPI)")), cast=int)
hint = Gtk.Label(label="Interface scale doubles everything, including apps. Text size is usually "
"the better way to make things easier to read.", xalign=0, wrap=True,
max_width_chars=48)
hint.get_style_context().add_class("tg-hint")
g.attach(hint, 0, g._row, 2, 1)
g._row += 1
def on_scale_everything(self, _b):
f = self._master.get_value() / 100
sz = self.cfg["size"]
sz["text_scale"] = round(f, 2)
sz["cursor_size"] = min((24, 32, 48, 64, 96), key=lambda n: abs(n - 24 * f))
sz["menu_scale"] = round(min(1.5, max(0.8, f)), 2)
self.cfg["panel_height"] = int(min(84, max(36, round(50 * f))))
self.cfg["icon_size"] = int(min(48, max(16, round(24 * f))))
self.cfg["tray_icon_size"] = int(min(32, max(12, round(16 * f))))
self.refresh_all()
self.status.set_text(f"Everything set to {int(f * 100)} %. Apply to use it.")
def fx_ready(self):
if tgcore.FX_DIR.exists():
return True
self.status.set_text("Press Apply once to install TechGlass FX, then try again.")
return False
def on_test_ripple(self, _b):
if self.fx_ready():
tgcore.fx_trigger({"preview": {"ripple": dict(self.cfg["ripple"], enabled=True)},
"preview_seconds": 25, "ripple": "center"})
self.status.set_text("Ripple sent. Click the desktop to try these settings for 25 seconds.")
def on_test_animations(self, _b):
if not self.fx_ready():
return
tgcore.fx_trigger({"preview": {"animations": dict(self.cfg["animations"], enabled=True)},
"preview_seconds": 20})
demo = Gtk.Window(title="TechGlass animation demo")
demo.set_default_size(560, 360)
demo.set_transient_for(None)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, margin=36)
lbl = Gtk.Label()
lbl.set_markup("<span size='xx-large' weight='bold'>TechGlass FX</span>\n"
"open · minimize · restore · maximize · close")
box.pack_start(lbl, True, True, 0)
demo.add(box)
step = int(1300 * self.cfg["animations"]["speed"]) + 300
plan = [lambda: demo.show_all(), lambda: demo.iconify(), lambda: (demo.deiconify(), demo.present()),
lambda: demo.maximize(), lambda: demo.unmaximize(), lambda: demo.destroy()]
for i, fn in enumerate(plan):
GLib.timeout_add(i * step, lambda fn=fn: (fn(), False)[1])
self.status.set_text("Playing the demo with the settings on this page…")
# ---------- wallpaper preview
def schedule_wallpaper(self, delay=350):
if self._wall_timer:
GLib.source_remove(self._wall_timer)
self._wall_timer = GLib.timeout_add(delay, self._render_wallpaper)
def _render_wallpaper(self):
self._wall_timer = None
self._wall_gen += 1
gen, cfg = self._wall_gen, copy.deepcopy(self.cfg)
wp = cfg["wallpaper"]
out = CACHE / f"preview-{gen % 2}.png"
out_blur = CACHE / f"preview-blur-{gen % 2}.png"
def work():
try:
if wp["mode"] == "image" and wp["image"]:
tgcore.Image.open(wp["image"]).convert("RGB").resize((640, 360)).save(out)
else:
tgcore.render_wallpaper(cfg, out, 640, 360)
# preview is a third of screen size, so scale the blur radius to match
radius = max(1, cfg["blur"]["intensity"] / 3)
tgcore.Image.open(out).filter(tgcore.ImageFilter.GaussianBlur(radius)).save(out_blur)
except Exception as e: # keep the UI alive on a bad setting
GLib.idle_add(self.status.set_text, f"Preview failed: {e}")
return
GLib.idle_add(lambda: gen == self._wall_gen and self.preview.set_wallpaper(out, out_blur))
threading.Thread(target=work, daemon=True).start()
return False
# ---------- actions
def run_task(self, label, fn):
if self.busy:
return
self.busy = True
self.apply_btn.set_sensitive(False)
self.spinner.start()
self.status.set_text(label)
def progress(msg):
GLib.idle_add(self.status.set_text, msg)
def work():
try:
fn(progress)
except Exception as e:
progress(f"Something went wrong: {e}")
GLib.idle_add(self._task_done)
threading.Thread(target=work, daemon=True).start()
def _task_done(self):
self.busy = False
self.apply_btn.set_sensitive(True)
self.spinner.stop()
def on_apply(self, _b):
cfg = copy.deepcopy(self.cfg)
self.run_task("Applying…", lambda progress: tgcore.apply(cfg, progress))
def on_reset(self, _item):
self.cfg = copy.deepcopy(tgcore.DEFAULTS)
self.refresh_all()
self.status.set_text("Reset to TechGlass Mint defaults. Apply to use them.")
def on_revert(self, _item):
dlg = Gtk.MessageDialog(transient_for=self, modal=True, message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.OK_CANCEL,
text="Revert to your original desktop?")
dlg.format_secondary_text("Restores the theme, wallpaper, panel, fonts, menu icon and kitty config "
"you had before TechGlass. Your TechGlass settings stay saved.")
ok = dlg.run() == Gtk.ResponseType.OK
dlg.destroy()
if ok:
self.run_task("Reverting…", tgcore.revert)
def scan_icon_themes():
icons, cursors = set(), set()
for root in (Path("/usr/share/icons"), Path.home() / ".icons", Path.home() / ".local/share/icons"):
if not root.is_dir():
continue
for d in root.iterdir():
if (d / "cursors").is_dir():
cursors.add(d.name)
idx = d / "index.theme"
if idx.exists():
try:
txt = idx.read_text(errors="ignore")
except OSError:
continue
if "Directories=" in txt and d.name not in ("default", "hicolor", "locolor"):
icons.add(d.name)
return sorted(icons, key=str.lower), sorted(cursors, key=str.lower)
def main():
win = Studio()
win.connect("destroy", Gtk.main_quit)
win.show_all()
Gtk.main()
if __name__ == "__main__":
main()