Spaces:
Running
Running
Taskbar page with styles, position, auto-hide, markers and dock magnify, plus a Windows page
ff0bc2d verified Download studio.py from EHS-Studio/techglass-mint: direct link, hf CLI and curl.
- Browser
- Download file 51.9 kB
-
https://huggingface.co/spaces/EHS-Studio/techglass-mint/resolve/main/studio.py
- Command line
-
hf download hf://spaces/EHS-Studio/techglass-mint/studio.py
-
curl -L -o studio.py https://huggingface.co/spaces/EHS-Studio/techglass-mint/resolve/main/studio.py
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() | |