diff --git a/window-frame-rules@muckylixx/CHANGELOG b/window-frame-rules@muckylixx/CHANGELOG new file mode 100644 index 000000000..46c69b7f7 --- /dev/null +++ b/window-frame-rules@muckylixx/CHANGELOG @@ -0,0 +1,13 @@ +# Changelog + +## 1.0.0 — 2026-06-14 + +- Initial public release. +- Configurable colored window frames for Cinnamon. +- Rule matching by title, class, instance and window type. +- Separate active and inactive colors. +- Adjustable frame width, corner radius, opacity, glow and edge offsets. +- Per-rule frame width and corner radius. +- Live configuration reload. +- Graphical GTK 3 configurator. +- Default rules for local terminals, SSH hosts and root shells. diff --git a/window-frame-rules@muckylixx/LICENSE b/window-frame-rules@muckylixx/LICENSE new file mode 100644 index 000000000..49d10846e --- /dev/null +++ b/window-frame-rules@muckylixx/LICENSE @@ -0,0 +1,5 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +This project is licensed under the GNU General Public License v3.0 or later. +The complete license text is available at https://www.gnu.org/licenses/gpl-3.0.txt diff --git a/window-frame-rules@muckylixx/README.md b/window-frame-rules@muckylixx/README.md new file mode 100644 index 000000000..40bb145ff --- /dev/null +++ b/window-frame-rules@muckylixx/README.md @@ -0,0 +1,92 @@ +# Window Frame Rules + +![Window Frame Rules screenshot](assets/screenshot.png) + +## Deutsch + +**Window Frame Rules** ist eine frei konfigurierbare Erweiterung für den Cinnamon-Desktop. Sie versieht Fenster mit deutlich sichtbaren farbigen Rahmen und erleichtert dadurch die schnelle visuelle Orientierung zwischen Anwendungen, Rechnern, SSH-Verbindungen und Arbeitsumgebungen. + +Die Regeln können Fenstertitel, Fensterklasse, Instanz und Fenstertyp auswerten. Dadurch kann beispielsweise ein lokales Terminal cyan, Linuxmaid grün, Blackmaid violett und eine Root-Shell orange markiert werden. Die erste passende Regel mit der höchsten Priorität gewinnt. Änderungen am Fenstertitel – etwa beim Wechsel in eine SSH-Sitzung – werden unmittelbar ausgewertet. + +### Funktionen + +- Frei definierbare und priorisierte Fensterregeln +- Erkennung über Titel, Fensterklasse, Instanz und Fenstertyp +- Getrennte Farben für aktive und inaktive Fenster +- Rahmenbreite frei in Pixeln einstellbar +- Eckenradius, Deckkraft und Leuchteffekt einstellbar +- Linker, rechter, oberer und unterer Versatz separat einstellbar +- Eigene Rahmenbreite und eigener Radius pro Regel möglich +- Maximierte und Vollbildfenster separat ein- oder ausschließbar +- Optionale Standardfarbe für Fenster ohne Regel +- Konfigurationsänderungen werden ohne Neustart übernommen +- Mitgelieferter grafischer Konfigurator + +Die Erweiterung verändert weder Anwendungen noch Terminalinhalte. Sie zeichnet den Rahmen auf Ebene des Cinnamon-Fenstermanagers. Das macht sie besonders nützlich für Nutzer, die gleichzeitig mit mehreren lokalen und entfernten Systemen arbeiten und sofort erkennen möchten, in welchem Fenster sie gerade tippen. + +### Installation + +```bash +unzip window-frame-rules-1.0.0.zip +cd window-frame-rules-1.0.0 +./install.sh +``` + +Danach **Systemeinstellungen → Erweiterungen → Window Frame Rules** aktivieren. Unter X11 kann Cinnamon mit `Alt+F2`, `r`, Enter neu geladen werden. + +Konfigurator starten: + +```bash +window-frame-rules-config +``` + +Die Konfiguration liegt in `~/.config/window-frame-rules/config.json`. Vorhandene Konfigurationen bleiben bei Neuinstallationen erhalten. + +--- + +## English + +**Window Frame Rules** is a fully configurable extension for the Cinnamon desktop. It adds clearly visible colored frames to windows, making it easier to distinguish applications, computers, SSH sessions and working environments at a glance. + +Rules can inspect the window title, window class, instance and window type. A local terminal can therefore use cyan, Linuxmaid green, Blackmaid violet and a root shell orange. The matching rule with the highest priority wins. Window-title changes, including changes caused by entering or leaving an SSH session, are evaluated immediately. + +### Features + +- Fully configurable prioritized window rules +- Matching by title, window class, instance and window type +- Separate active and inactive colors +- User-defined frame thickness in pixels +- Adjustable corner radius, opacity and glow +- Separate left, right, top and bottom offsets +- Optional per-rule frame width and corner radius +- Separate handling of maximized and fullscreen windows +- Optional fallback frame for unmatched windows +- Configuration changes are applied without restarting Cinnamon +- Included graphical configurator + +The extension does not modify applications, terminals or shells. It draws the frame at Cinnamon's window-manager level. It is particularly useful for users who work with several local and remote systems at the same time and need an immediate visual indication of where their keyboard input is going. + +### Installation + +```bash +unzip window-frame-rules-1.0.0.zip +cd window-frame-rules-1.0.0 +./install.sh +``` + +Then enable **Window Frame Rules** in **System Settings → Extensions**. On X11, Cinnamon can be reloaded with `Alt+F2`, `r`, Enter. + +Launch the configurator: + +```bash +window-frame-rules-config +``` + +Configuration is stored in `~/.config/window-frame-rules/config.json`. Existing user configuration is preserved during reinstallations. + +## Notes + +- Designed and tested for Cinnamon 6.4 on X11. +- The extension performs no network access and runs no shell commands. +- The configurator requires Python 3 and GTK 3 (`python3-gi`). +- License: GPL-3.0-or-later. diff --git a/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/configurator.py b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/configurator.py new file mode 100755 index 000000000..f36c2ba4f --- /dev/null +++ b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/configurator.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +import json +import shutil +from pathlib import Path +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +APP_NAME = "Window Frame Rules" +CONFIG_DIR = Path.home() / ".config" / "window-frame-rules" +CONFIG_FILE = CONFIG_DIR / "config.json" +DEFAULT_FILE = Path(__file__).resolve().parent / "default-config.json" + + +def load_config(): + source = CONFIG_FILE if CONFIG_FILE.exists() else DEFAULT_FILE + return json.loads(source.read_text(encoding="utf-8")) + + +def color_button(value): + button = Gtk.ColorButton() + rgba = button.get_rgba() + rgba.parse(value or "#00DDFF") + button.set_rgba(rgba) + button.set_use_alpha(False) + return button + + +def color_hex(button): + c = button.get_rgba() + return "#{:02X}{:02X}{:02X}".format(round(c.red*255), round(c.green*255), round(c.blue*255)) + + +class RuleEditor(Gtk.Frame): + def __init__(self, rule, remove_callback): + super().__init__() + self.set_shadow_type(Gtk.ShadowType.IN) + self.remove_callback = remove_callback + grid = Gtk.Grid(column_spacing=10, row_spacing=7, margin=10) + self.add(grid) + + self.enabled = Gtk.Switch(active=rule.get("enabled", True)) + self.name = Gtk.Entry(text=rule.get("name", "New rule")) + self.priority = Gtk.SpinButton.new_with_range(-9999, 9999, 10) + self.priority.set_value(rule.get("priority", 0)) + self.title = Gtk.Entry(text=rule.get("title_contains", "")) + self.wmclass = Gtk.Entry(text=rule.get("class_contains", "")) + self.instance = Gtk.Entry(text=rule.get("instance_contains", "")) + self.active = color_button(rule.get("active_color", "#00DDFF")) + self.inactive = color_button(rule.get("inactive_color", "#145363")) + self.width = Gtk.SpinButton.new_with_range(0, 40, 1) + self.width.set_value(rule.get("frame_width") or 0) + self.radius = Gtk.SpinButton.new_with_range(-1, 80, 1) + self.radius.set_value(rule.get("corner_radius") if rule.get("corner_radius") is not None else -1) + self.types = Gtk.Entry(text=",".join(rule.get("window_types", []))) + self.types.set_placeholder_text("normal,dialog,modal_dialog,utility — empty = all") + + fields = [ + ("Enabled", self.enabled), ("Name", self.name), ("Priority", self.priority), + ("Title contains", self.title), ("Window class contains", self.wmclass), + ("Instance contains", self.instance), ("Window types", self.types), + ("Active color", self.active), ("Inactive color", self.inactive), + ("Frame width (0 = global)", self.width), ("Corner radius (-1 = global)", self.radius) + ] + for row, (label, widget) in enumerate(fields): + lab = Gtk.Label(label=label, xalign=0) + grid.attach(lab, 0, row, 1, 1) + grid.attach(widget, 1, row, 1, 1) + + remove = Gtk.Button(label="Remove rule") + remove.connect("clicked", lambda _b: self.remove_callback(self)) + grid.attach(remove, 1, len(fields), 1, 1) + + def data(self): + types = [x.strip().lower() for x in self.types.get_text().split(",") if x.strip()] + result = { + "name": self.name.get_text().strip() or "Unnamed rule", + "enabled": self.enabled.get_active(), + "priority": int(self.priority.get_value()), + "title_contains": self.title.get_text(), + "class_contains": self.wmclass.get_text(), + "instance_contains": self.instance.get_text(), + "window_types": types, + "active_color": color_hex(self.active), + "inactive_color": color_hex(self.inactive) + } + width = int(self.width.get_value()) + radius = int(self.radius.get_value()) + if width > 0: result["frame_width"] = width + if radius >= 0: result["corner_radius"] = radius + return result + + +class App(Gtk.Window): + def __init__(self): + super().__init__(title=APP_NAME) + self.set_default_size(760, 820) + self.connect("destroy", Gtk.main_quit) + self.config = load_config() + self.rule_editors = [] + + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=10) + self.add(outer) + notebook = Gtk.Notebook() + outer.pack_start(notebook, True, True, 0) + + general_scroll = Gtk.ScrolledWindow() + general_grid = Gtk.Grid(column_spacing=12, row_spacing=8, margin=14) + general_scroll.add(general_grid) + notebook.append_page(general_scroll, Gtk.Label(label="General")) + + g = self.config.get("general", {}) + self.general = {} + specs = [ + ("frame_width", "Frame width", 1, 40, 1), + ("corner_radius", "Corner radius", 0, 80, 1), + ("offset_left", "Offset left", -50, 50, 1), + ("offset_right", "Offset right", -50, 50, 1), + ("offset_top", "Offset top", -50, 50, 1), + ("offset_bottom", "Offset bottom", -50, 50, 1), + ("active_opacity", "Active opacity (%)", 0, 100, 1), + ("inactive_opacity", "Inactive opacity (%)", 0, 100, 1), + ("glow_radius", "Glow radius", 0, 50, 1) + ] + for row, (key, label, minimum, maximum, step) in enumerate(specs): + w = Gtk.SpinButton.new_with_range(minimum, maximum, step) + w.set_value(g.get(key, 0)) + self.general[key] = w + general_grid.attach(Gtk.Label(label=label, xalign=0), 0, row, 1, 1) + general_grid.attach(w, 1, row, 1, 1) + + for key, label in [("glow_enabled","Glow enabled"),("include_maximized","Include maximized windows"),("include_fullscreen","Include fullscreen windows"),("debug","Debug logging")]: + row = len(self.general) + w = Gtk.Switch(active=g.get(key, False)) + self.general[key] = w + general_grid.attach(Gtk.Label(label=label, xalign=0), 0, row, 1, 1) + general_grid.attach(w, 1, row, 1, 1) + + fallback = self.config.get("fallback", {}) + self.fallback_enabled = Gtk.Switch(active=fallback.get("enabled", False)) + self.fallback_active = color_button(fallback.get("active_color", "#FFE65C")) + self.fallback_inactive = color_button(fallback.get("inactive_color", "#5E5726")) + base = 20 + for i,(label,w) in enumerate([("Frame unmatched windows",self.fallback_enabled),("Fallback active color",self.fallback_active),("Fallback inactive color",self.fallback_inactive)]): + general_grid.attach(Gtk.Label(label=label, xalign=0),0,base+i,1,1) + general_grid.attach(w,1,base+i,1,1) + + rules_outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=8) + rules_scroll = Gtk.ScrolledWindow() + self.rules_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, margin=6) + rules_scroll.add(self.rules_box) + rules_outer.pack_start(rules_scroll, True, True, 0) + add = Gtk.Button(label="Add rule") + add.connect("clicked", lambda _b: self.add_rule({"name":"New rule","enabled":True,"priority":0,"active_color":"#00DDFF","inactive_color":"#145363"})) + rules_outer.pack_start(add, False, False, 0) + notebook.append_page(rules_outer, Gtk.Label(label="Rules")) + + for rule in self.config.get("rules", []): self.add_rule(rule) + + buttons = Gtk.ButtonBox(layout_style=Gtk.ButtonBoxStyle.END, spacing=8) + reset = Gtk.Button(label="Reset defaults") + save = Gtk.Button(label="Save") + close = Gtk.Button(label="Close") + reset.connect("clicked", self.reset_defaults) + save.connect("clicked", self.save) + close.connect("clicked", lambda _b: self.destroy()) + buttons.add(reset); buttons.add(save); buttons.add(close) + outer.pack_start(buttons, False, False, 0) + self.show_all() + + def add_rule(self, rule): + editor = RuleEditor(rule, self.remove_rule) + self.rule_editors.append(editor) + self.rules_box.pack_start(editor, False, False, 0) + editor.show_all() + + def remove_rule(self, editor): + self.rule_editors.remove(editor) + self.rules_box.remove(editor) + + def collect(self): + g = {} + for key, widget in self.general.items(): + g[key] = widget.get_active() if isinstance(widget, Gtk.Switch) else int(widget.get_value()) + return { + "general": g, + "fallback": { + "enabled": self.fallback_enabled.get_active(), + "active_color": color_hex(self.fallback_active), + "inactive_color": color_hex(self.fallback_inactive) + }, + "rules": [editor.data() for editor in self.rule_editors] + } + + def save(self, _button): + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + if CONFIG_FILE.exists(): + shutil.copy2(CONFIG_FILE, CONFIG_FILE.with_suffix(".json.bak")) + CONFIG_FILE.write_text(json.dumps(self.collect(), indent=4) + "\n", encoding="utf-8") + dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, "Configuration saved") + dialog.format_secondary_text("Open windows update automatically.") + dialog.run(); dialog.destroy() + + def reset_defaults(self, _button): + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + shutil.copy2(DEFAULT_FILE, CONFIG_FILE) + dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, "Defaults restored") + dialog.format_secondary_text("Close and reopen the configurator to edit the restored values.") + dialog.run(); dialog.destroy() + + +if __name__ == "__main__": + App() + Gtk.main() diff --git a/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/default-config.json b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/default-config.json new file mode 100644 index 000000000..daa14e28b --- /dev/null +++ b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/default-config.json @@ -0,0 +1,111 @@ +{ + "general": { + "frame_width": 4, + "corner_radius": 7, + "offset_left": 4, + "offset_right": 1, + "offset_top": 1, + "offset_bottom": 1, + "active_opacity": 100, + "inactive_opacity": 48, + "glow_enabled": true, + "glow_radius": 5, + "include_maximized": true, + "include_fullscreen": false, + "debug": false + }, + "fallback": { + "enabled": false, + "active_color": "#FFE65C", + "inactive_color": "#5E5726" + }, + "rules": [ + { + "name": "Root shell", + "enabled": true, + "priority": 1000, + "title_contains": "root@", + "class_contains": "", + "instance_contains": "", + "window_types": [], + "active_color": "#FF9D24", + "inactive_color": "#663E18" + }, + { + "name": "Linuxmaid", + "enabled": true, + "priority": 900, + "title_contains": "linuxmaid", + "class_contains": "", + "instance_contains": "", + "window_types": [], + "active_color": "#38F58A", + "inactive_color": "#174D2E" + }, + { + "name": "Blackmaid / Blackmate", + "enabled": true, + "priority": 900, + "title_contains": "blackma", + "class_contains": "", + "instance_contains": "", + "window_types": [], + "active_color": "#C15CFF", + "inactive_color": "#4D2863" + }, + { + "name": "Hitomi-Go", + "enabled": true, + "priority": 900, + "title_contains": "hitomi-go", + "class_contains": "", + "instance_contains": "", + "window_types": [], + "active_color": "#FF405C", + "inactive_color": "#63202B" + }, + { + "name": "Cube local terminal", + "enabled": true, + "priority": 500, + "title_contains": "@cube", + "class_contains": "", + "instance_contains": "", + "window_types": [], + "active_color": "#00DDFF", + "inactive_color": "#145363" + }, + { + "name": "GNOME Terminal", + "enabled": true, + "priority": 100, + "title_contains": "", + "class_contains": "gnome-terminal", + "instance_contains": "", + "window_types": [ + "normal", + "dialog", + "modal_dialog", + "utility" + ], + "active_color": "#00DDFF", + "inactive_color": "#145363" + }, + { + "name": "Other terminals", + "enabled": true, + "priority": 90, + "title_contains": "", + "class_contains": "terminal", + "instance_contains": "", + "window_types": [ + "normal", + "dialog", + "modal_dialog", + "utility" + ], + "active_color": "#00DDFF", + "inactive_color": "#145363" + } + ] +} diff --git a/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/extension.js b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/extension.js new file mode 100644 index 000000000..b32dc3000 --- /dev/null +++ b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/extension.js @@ -0,0 +1,337 @@ +const St = imports.gi.St; +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const Meta = imports.gi.Meta; + +const UUID = "window-frame-rules@muckylixx"; +const CONFIG_DIR = GLib.build_filenamev([GLib.get_user_config_dir(), "window-frame-rules"]); +const CONFIG_FILE = GLib.build_filenamev([CONFIG_DIR, "config.json"]); + +const DEFAULT_CONFIG = { + general: { + frame_width: 4, + corner_radius: 7, + offset_left: 4, + offset_right: 1, + offset_top: 1, + offset_bottom: 1, + active_opacity: 100, + inactive_opacity: 48, + glow_enabled: true, + glow_radius: 5, + include_maximized: true, + include_fullscreen: false, + debug: false + }, + fallback: { + enabled: false, + active_color: "#FFE65C", + inactive_color: "#5E5726" + }, + rules: [] +}; + +let config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); +let configMonitor = null; +let configMonitorId = 0; +let windowCreatedId = 0; +let focusChangedId = 0; +let trackedActors = new Map(); +let loadSerial = 0; + +function logDebug(message) { + if (config.general && config.general.debug) + global.log(`[Window Frame Rules] ${message}`); +} + +function clamp(value, minimum, maximum) { + const number = Number(value); + if (!Number.isFinite(number)) + return minimum; + return Math.max(minimum, Math.min(maximum, number)); +} + +function mergedConfig(candidate) { + const result = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); + if (!candidate || typeof candidate !== "object") + return result; + + if (candidate.general && typeof candidate.general === "object") + Object.assign(result.general, candidate.general); + if (candidate.fallback && typeof candidate.fallback === "object") + Object.assign(result.fallback, candidate.fallback); + if (Array.isArray(candidate.rules)) + result.rules = candidate.rules.filter(rule => rule && typeof rule === "object"); + + return result; +} + +function loadConfigAsync() { + const serial = ++loadSerial; + const file = Gio.File.new_for_path(CONFIG_FILE); + + file.load_contents_async(null, (source, result) => { + if (serial !== loadSerial) + return; + try { + const [, contents] = source.load_contents_finish(result); + const text = imports.byteArray.toString(contents); + config = mergedConfig(JSON.parse(text)); + logDebug(`Loaded ${config.rules.length} rules.`); + } catch (error) { + config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); + global.logError(`[Window Frame Rules] Could not load ${CONFIG_FILE}: ${error}`); + } + rescanWindows(); + }); +} + +function setupConfigMonitor() { + try { + const file = Gio.File.new_for_path(CONFIG_FILE); + configMonitor = file.monitor_file(Gio.FileMonitorFlags.NONE, null); + configMonitorId = configMonitor.connect("changed", (_monitor, _file, _other, eventType) => { + if (eventType === Gio.FileMonitorEvent.CHANGES_DONE_HINT || + eventType === Gio.FileMonitorEvent.CREATED || + eventType === Gio.FileMonitorEvent.MOVED_IN) + loadConfigAsync(); + }); + } catch (error) { + global.logError(`[Window Frame Rules] Config monitor failed: ${error}`); + } +} + +function windowTypeName(metaWindow) { + const type = metaWindow.get_window_type(); + if (type === Meta.WindowType.NORMAL) return "normal"; + if (type === Meta.WindowType.DIALOG) return "dialog"; + if (type === Meta.WindowType.MODAL_DIALOG) return "modal_dialog"; + if (type === Meta.WindowType.UTILITY) return "utility"; + if (type === Meta.WindowType.MENU) return "menu"; + if (type === Meta.WindowType.TOOLBAR) return "toolbar"; + if (type === Meta.WindowType.SPLASHSCREEN) return "splashscreen"; + return "other"; +} + +function contains(haystack, needle) { + if (!needle) + return true; + return String(haystack || "").toLowerCase().includes(String(needle).toLowerCase()); +} + +function exact(haystack, needle) { + if (!needle) + return true; + return String(haystack || "").toLowerCase() === String(needle).toLowerCase(); +} + +function ruleMatches(rule, metaWindow) { + if (rule.enabled === false) + return false; + + const title = metaWindow.get_title() || ""; + const wmClass = metaWindow.get_wm_class() || ""; + const wmInstance = metaWindow.get_wm_class_instance() || ""; + const typeName = windowTypeName(metaWindow); + + if (!contains(title, rule.title_contains)) return false; + if (!contains(wmClass, rule.class_contains)) return false; + if (!contains(wmInstance, rule.instance_contains)) return false; + if (!exact(title, rule.title_exact)) return false; + if (!exact(wmClass, rule.class_exact)) return false; + + if (Array.isArray(rule.window_types) && rule.window_types.length > 0 && + !rule.window_types.includes(typeName)) + return false; + + return true; +} + +function matchingRule(metaWindow) { + const rules = config.rules + .filter(rule => ruleMatches(rule, metaWindow)) + .sort((a, b) => Number(b.priority || 0) - Number(a.priority || 0)); + return rules.length > 0 ? rules[0] : null; +} + +function styleFor(metaWindow, focused) { + const rule = matchingRule(metaWindow); + if (!rule && !(config.fallback && config.fallback.enabled)) + return null; + + const source = rule || config.fallback; + const general = config.general || DEFAULT_CONFIG.general; + + const width = clamp(source.frame_width ?? general.frame_width, 1, 40); + const radius = clamp(source.corner_radius ?? general.corner_radius, 0, 80); + const color = focused + ? (source.active_color || "#00DDFF") + : (source.inactive_color || source.active_color || "#145363"); + const opacityPercent = focused + ? clamp(source.active_opacity ?? general.active_opacity, 0, 100) + : clamp(source.inactive_opacity ?? general.inactive_opacity, 0, 100); + const glowEnabled = source.glow_enabled ?? general.glow_enabled; + const glowRadius = clamp(source.glow_radius ?? general.glow_radius, 0, 50); + const glow = glowEnabled && glowRadius > 0 + ? `box-shadow: inset 0 0 ${glowRadius}px ${color};` + : "box-shadow: none;"; + + return { + width, + radius, + opacity: Math.round(opacityPercent * 2.55), + css: `border: ${width}px solid ${color}; border-radius: ${radius}px; background-color: transparent; ${glow}`, + rule + }; +} + +function offsetsFor(rule) { + const general = config.general || DEFAULT_CONFIG.general; + return { + left: clamp(rule?.offset_left ?? general.offset_left, -50, 50), + right: clamp(rule?.offset_right ?? general.offset_right, -50, 50), + top: clamp(rule?.offset_top ?? general.offset_top, -50, 50), + bottom: clamp(rule?.offset_bottom ?? general.offset_bottom, -50, 50) + }; +} + +function shouldSuppress(metaWindow) { + const general = config.general || DEFAULT_CONFIG.general; + if (!general.include_fullscreen && metaWindow.is_fullscreen()) + return true; + if (!general.include_maximized && metaWindow.get_maximized() !== Meta.MaximizeFlags.NONE) + return true; + return false; +} + +function updateFrame(actor) { + const tracked = trackedActors.get(actor); + if (!tracked) + return; + + const {frame, metaWindow} = tracked; + const focused = global.display.focus_window === metaWindow; + const style = styleFor(metaWindow, focused); + + if (!style || shouldSuppress(metaWindow)) { + frame.hide(); + return; + } + + const rect = metaWindow.get_frame_rect(); + const [actorX, actorY] = actor.get_position(); + const offsets = offsetsFor(style.rule); + + const x = rect.x - actorX - offsets.left; + const y = rect.y - actorY - offsets.top; + const width = Math.max(1, rect.width + offsets.left + offsets.right); + const height = Math.max(1, rect.height + offsets.top + offsets.bottom); + + frame.set_position(x, y); + frame.set_size(width, height); + frame.set_style(style.css); + frame.opacity = style.opacity; + frame.show(); +} + +function updateAllFrames() { + for (const actor of trackedActors.keys()) + updateFrame(actor); +} + +function removeFrame(actor) { + const tracked = trackedActors.get(actor); + if (!tracked) + return; + + for (const id of tracked.actorSignals) { + try { actor.disconnect(id); } catch (_error) {} + } + for (const id of tracked.windowSignals) { + try { tracked.metaWindow.disconnect(id); } catch (_error) {} + } + try { tracked.frame.destroy(); } catch (_error) {} + trackedActors.delete(actor); +} + +function addFrame(actor) { + if (!actor || trackedActors.has(actor)) + return; + + const metaWindow = actor.get_meta_window(); + if (!metaWindow) + return; + + const frame = new St.Widget({ + style_class: "window-frame-rules-frame", + reactive: false, + can_focus: false, + track_hover: false + }); + + actor.add_child(frame); + actor.set_child_above_sibling(frame, null); + + const actorSignals = [ + actor.connect("notify::width", () => updateFrame(actor)), + actor.connect("notify::height", () => updateFrame(actor)), + actor.connect("notify::x", () => updateFrame(actor)), + actor.connect("notify::y", () => updateFrame(actor)), + actor.connect("destroy", () => removeFrame(actor)) + ]; + + const windowSignals = [ + metaWindow.connect("notify::title", () => updateFrame(actor)), + metaWindow.connect("notify::maximized-horizontally", () => updateFrame(actor)), + metaWindow.connect("notify::maximized-vertically", () => updateFrame(actor)), + metaWindow.connect("notify::fullscreen", () => updateFrame(actor)) + ]; + + trackedActors.set(actor, {frame, metaWindow, actorSignals, windowSignals}); + updateFrame(actor); +} + +function rescanWindows() { + const actors = global.get_window_actors(); + for (const actor of actors) + addFrame(actor); + updateAllFrames(); +} + +function init(_metadata) {} + +function enable() { + loadConfigAsync(); + setupConfigMonitor(); + rescanWindows(); + + windowCreatedId = global.display.connect("window-created", (_display, metaWindow) => { + const actor = metaWindow.get_compositor_private(); + if (actor) + addFrame(actor); + }); + + focusChangedId = global.display.connect("notify::focus-window", updateAllFrames); +} + +function disable() { + if (windowCreatedId) { + global.display.disconnect(windowCreatedId); + windowCreatedId = 0; + } + if (focusChangedId) { + global.display.disconnect(focusChangedId); + focusChangedId = 0; + } + if (configMonitor && configMonitorId) { + try { configMonitor.disconnect(configMonitorId); } catch (_error) {} + configMonitorId = 0; + } + if (configMonitor) { + try { configMonitor.cancel(); } catch (_error) {} + configMonitor = null; + } + for (const actor of Array.from(trackedActors.keys())) + removeFrame(actor); + trackedActors.clear(); +} diff --git a/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/icon.png b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/icon.png new file mode 100644 index 000000000..2b85943bb Binary files /dev/null and b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/icon.png differ diff --git a/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/metadata.json b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/metadata.json new file mode 100644 index 000000000..7090a76f9 --- /dev/null +++ b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/metadata.json @@ -0,0 +1,11 @@ +{ + "uuid": "window-frame-rules@muckylixx", + "name": "Window Frame Rules", + "description": "Configurable colored window frames based on title, class, type and focus state.", + "version": "1.0.0", + "cinnamon-version": [ + "6.4" + ], + "external-configuration-app": "configurator.py", + "url": "https://github.com/Muckylixx/window-frame-rules" +} diff --git a/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/stylesheet.css b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/stylesheet.css new file mode 100644 index 000000000..d1285a33e --- /dev/null +++ b/window-frame-rules@muckylixx/files/window-frame-rules@muckylixx/stylesheet.css @@ -0,0 +1,6 @@ +/* Window Frame Rules + * Geometry and colors are applied dynamically by extension.js. + */ +.window-frame-rules-frame { + background-color: transparent; +} diff --git a/window-frame-rules@muckylixx/info.json b/window-frame-rules@muckylixx/info.json new file mode 100644 index 000000000..ad084a90d --- /dev/null +++ b/window-frame-rules@muckylixx/info.json @@ -0,0 +1,4 @@ +{ + "author": "Muckylixx", + "license": "GPLv3" +} diff --git a/window-frame-rules@muckylixx/screenshot.png b/window-frame-rules@muckylixx/screenshot.png new file mode 100644 index 000000000..b990131e4 Binary files /dev/null and b/window-frame-rules@muckylixx/screenshot.png differ