"""Window Rules page — redesigned for usability.""" from __future__ import annotations from typing import NamedTuple import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, Gtk, GLib from nirimod.kdl_parser import KdlNode, KdlRawString from nirimod.pages.base import BasePage # ── Human-readable labels ──────────────────────────────────────────────────── SCREENCAST_BLOCK_KEY = "block-out-from" BOOL_MATCH_LABELS = { "is-active": "Is Active", "is-floating": "Is Floating", "is-focused": "Is Focused", "at-startup": "At Startup", } BOOL_ACTION_LABELS = { "open-maximized": "Open Maximized", "open-fullscreen": "Open Fullscreen", "open-floating": "Open Floating", SCREENCAST_BLOCK_KEY: "Block from Screencast", "draw-border-with-background": "Draw Border with Background", "clip-to-geometry": "Clip to Geometry", "prefer-no-csd": "Prefer No CSD", } NUM_ACTION_LABELS = { "opacity": ("Opacity", 0.0, 1.0, 0.05, 2), "geometry-corner-radius": ("Corner Radius (px)", 0, 40, 1, 0), "min-width": ("Min Width (px)", 0, 7680, 1, 0), "min-height": ("Min Height (px)", 0, 7680, 1, 0), "max-width": ("Max Width (px)", 0, 7680, 1, 0), "max-height": ("Max Height (px)", 0, 7680, 1, 0), } STR_ACTION_LABELS = { "open-on-workspace": "Open on Workspace", "open-on-output": "Open on Output", } LAYER_BOOL_ACTION_LABELS = { "place-within-backdrop": "Place Within Backdrop", SCREENCAST_BLOCK_KEY: "Block from Screencast", } FLOATING_POSITION_PRESETS = [ ("Top", "top"), ("Bottom", "bottom"), ("Left", "left"), ("Right", "right"), ] CUSTOM_FLOATING_POSITION_LABEL = "Custom" FLOATING_POSITION_LOCATION_LABELS = [ label for label, _ in FLOATING_POSITION_PRESETS ] + [CUSTOM_FLOATING_POSITION_LABEL] FLOATING_POSITION_CUSTOM_FIELD_LABELS = ["X Offset (px)", "Y Offset (px)"] CUSTOM_FLOATING_POSITION_INDEX = len(FLOATING_POSITION_PRESETS) DEFAULT_FLOATING_POSITION_RELATIVE_TO = "top" CUSTOM_FLOATING_POSITION_RELATIVE_TO = "top-left" class WindowSizeControlConfig(NamedTuple): title: str initial_percent: float fixed: int SIZE_PERCENT_PRESETS = [ ("25%", 0.25), ("33%", 0.33333), ("50%", 0.5), ("66%", 0.66667), ("75%", 0.75), ("100%", 1.0), ] SIZE_MODE_LABELS = [label for label, _ in SIZE_PERCENT_PRESETS] + [ "Custom %", "Fixed (px)", ] CUSTOM_SIZE_INDEX = len(SIZE_PERCENT_PRESETS) FIXED_SIZE_INDEX = CUSTOM_SIZE_INDEX + 1 WINDOW_SIZE_CONTROLS = { "default-column-width": WindowSizeControlConfig( title="Default Width", initial_percent=50.0, fixed=800, ), "default-window-height": WindowSizeControlConfig( title="Default Height", initial_percent=100.0, fixed=600, ), } def _bool_action_active(rule: KdlNode | None, key: str) -> bool: if rule is None: return False if key != SCREENCAST_BLOCK_KEY: return rule.get_child(key) is not None legacy = rule.get_child("block-out-from-screencast") if legacy is not None: return True node = rule.get_child(SCREENCAST_BLOCK_KEY) return node is not None and bool(node.args) and node.args[0] == "screencast" def _bool_action_node(key: str) -> KdlNode: if key == SCREENCAST_BLOCK_KEY: return KdlNode(SCREENCAST_BLOCK_KEY, args=["screencast"]) return KdlNode(key, args=[True]) def _floating_position_setting(rule: KdlNode | None) -> tuple[bool, int, int, str]: if rule is None: return (False, 0, 0, DEFAULT_FLOATING_POSITION_RELATIVE_TO) node = rule.get_child("default-floating-position") if node is None: return (False, 0, 0, DEFAULT_FLOATING_POSITION_RELATIVE_TO) x = int(node.props.get("x", 0)) y = int(node.props.get("y", 0)) relative_to = str( node.props.get("relative-to", DEFAULT_FLOATING_POSITION_RELATIVE_TO) ) return (True, x, y, relative_to) def _make_floating_position_node( enabled: bool, x: int, y: int, relative_to: str ) -> KdlNode | None: if not enabled: return None relative_to = relative_to.strip() if not relative_to: relative_to = DEFAULT_FLOATING_POSITION_RELATIVE_TO return KdlNode( "default-floating-position", props={"x": int(x), "y": int(y), "relative-to": relative_to}, ) def _floating_position_location_index(x: int, y: int, relative_to: str) -> int: if x != 0 or y != 0: return CUSTOM_FLOATING_POSITION_INDEX for index, (_, preset_relative_to) in enumerate(FLOATING_POSITION_PRESETS): if relative_to == preset_relative_to: return index return CUSTOM_FLOATING_POSITION_INDEX def _legacy_size_arg_setting(value) -> tuple[str, float | int | None]: if isinstance(value, str): text = value.strip().rstrip(";") if not text: return ("default", None) if text.endswith("%"): try: return ("proportion", round(float(text[:-1]) / 100.0, 5)) except ValueError: return ("default", None) parts = text.split() if len(parts) == 2 and parts[0] in {"proportion", "fixed"}: try: number = float(parts[1]) except ValueError: return ("default", None) if parts[0] == "proportion": return ("proportion", round(number, 5)) return ("fixed", int(number)) try: value = float(text) except ValueError: return ("default", None) if isinstance(value, bool) or value is None: return ("default", None) if isinstance(value, float) and 0 < value <= 1: return ("proportion", round(value, 5)) if isinstance(value, (int, float)) and value > 0: return ("fixed", int(value)) return ("default", None) def _window_size_setting( rule: KdlNode | None, key: str ) -> tuple[str, float | int | None]: if rule is None: return ("default", None) node = rule.get_child(key) if node is None: return ("default", None) proportion = node.get_child("proportion") if proportion is not None and proportion.args: return ("proportion", round(float(proportion.args[0]), 5)) fixed = node.get_child("fixed") if fixed is not None and fixed.args: return ("fixed", int(float(fixed.args[0]))) if node.args: return _legacy_size_arg_setting(node.args[0]) return ("default", None) def _make_size_node(key: str, kind: str, value: float | int | None) -> KdlNode | None: if kind == "default" or value is None: return None if kind not in {"proportion", "fixed"}: raise ValueError(f"Unsupported window size kind: {kind}") node = KdlNode(key) if kind == "proportion": node.children.append(KdlNode("proportion", args=[round(float(value), 5)])) else: node.children.append(KdlNode("fixed", args=[int(value)])) return node def _rule_summary(rule: KdlNode) -> tuple[str, str]: """Return (title, subtitle) for a window-rule row.""" matches = rule.get_children("match") if not matches: title = "Global Rule" else: parts = [] for m in matches: for k, v in m.props.items(): parts.append(f"{k}: {v}") for a in m.args: parts.append(str(a)) title = " • ".join(parts) if parts else "(any)" badges = [] for c in rule.children: if c.name == "match": continue if c.name == "opacity" and c.args: badges.append(f"opacity {c.args[0]}") elif c.name == "background-effect": badges.append("blur") elif c.name == "open-floating": badges.append("floating") elif c.name == "open-maximized": badges.append("maximized") elif c.name == "open-fullscreen": badges.append("fullscreen") elif c.name in ("clip-to-geometry", "geometry-corner-radius"): pass # skip noisy ones else: badges.append(c.name.replace("-", " ")) subtitle = ", ".join(badges[:5]) if badges else "no actions" return GLib.markup_escape_text(title), GLib.markup_escape_text(subtitle) def _layer_rule_summary(rule: KdlNode) -> tuple[str, str]: match_node = rule.get_child("match") ns = str(match_node.props.get("namespace", "")) if match_node else "" title = f"namespace: {ns}" if ns else "(any)" actions = [c.name.replace("-", " ") for c in rule.children if c.name != "match"] subtitle = ", ".join(actions) if actions else "no actions" return GLib.markup_escape_text(title), GLib.markup_escape_text(subtitle) # ── Page ───────────────────────────────────────────────────────────────────── class WindowRulesPage(BasePage): def build(self) -> Gtk.Widget: tb, header, _, content = self._make_toolbar_page("Window Rules") self._content = content add_win_btn = Gtk.Button(label="Add Window Rule") add_win_btn.add_css_class("flat") add_win_btn.set_tooltip_text("Add a new window rule") add_win_btn.connect("clicked", self._on_add) header.pack_end(add_win_btn) add_layer_btn = Gtk.Button(label="Add Layer Rule") add_layer_btn.add_css_class("flat") add_layer_btn.set_tooltip_text("Add a new layer-shell rule") add_layer_btn.connect("clicked", self._on_add_layer) header.pack_end(add_layer_btn) self._rules_grp = Adw.PreferencesGroup(title="Window Rules") content.append(self._rules_grp) self._layer_rules_grp = Adw.PreferencesGroup( title="Layer Rules", description="Rules for layer-shell surfaces (bars, overlays, wallpapers…)", ) content.append(self._layer_rules_grp) self.refresh() return tb def refresh(self): self._rebuild() self._rebuild_layer() # ── Window rules ───────────────────────────────────────────────────────── def _get_rules(self) -> list[KdlNode]: return [n for n in self._nodes if n.name == "window-rule"] def _rebuild(self): parent = self._rules_grp.get_parent() if parent is None: return rules = self._get_rules() new_grp = Adw.PreferencesGroup( title="Window Rules", description=f"{len(rules)} rule(s) — click a row to edit", ) for i, rule in enumerate(rules): new_grp.add(self._make_rule_row(rule, i)) parent.remove(self._rules_grp) parent.append(new_grp) self._rules_grp = new_grp def _make_rule_row(self, rule: KdlNode, idx: int) -> Adw.ActionRow: title, subtitle = _rule_summary(rule) row = Adw.ActionRow(title=title, subtitle=subtitle) row.set_activatable(True) row.set_subtitle_lines(1) row.add_css_class("monospace") # visual badge for blur / opacity has_blur = rule.get_child("background-effect") is not None op_node = rule.get_child("opacity") if has_blur: lbl = Gtk.Label(label="blur") lbl.add_css_class("tag") lbl.add_css_class("accent") lbl.set_valign(Gtk.Align.CENTER) row.add_suffix(lbl) if op_node and op_node.args: lbl2 = Gtk.Label(label=f"α {op_node.args[0]}") lbl2.add_css_class("tag") lbl2.set_valign(Gtk.Align.CENTER) row.add_suffix(lbl2) del_btn = Gtk.Button(icon_name="user-trash-symbolic") del_btn.set_valign(Gtk.Align.CENTER) del_btn.add_css_class("flat") del_btn.add_css_class("error") del_btn.set_tooltip_text("Delete rule") del_btn.connect("clicked", lambda *_, i=idx: self._on_delete(i)) row.add_suffix(del_btn) row.connect("activated", lambda *_, i=idx: self._on_edit(i)) return row def _on_add(self, *_): self._show_rule_dialog(None, -1) def _on_edit(self, idx: int): rules = self._get_rules() if 0 <= idx < len(rules): self._show_rule_dialog(rules[idx], idx) def _on_delete(self, idx: int): rules = self._get_rules() if not (0 <= idx < len(rules)): return removed = rules[idx] self._nodes.remove(removed) self._commit("remove window rule") self._rebuild() # show a quick-undo toast t = Adw.Toast(title="Window rule deleted", button_label="Undo", timeout=5) t.connect("button-clicked", lambda *_: self._win._do_undo()) self._win._toast_overlay.add_toast(t) def _add_floating_position_controls( self, group: Adw.PreferencesGroup, rule: KdlNode | None ) -> dict[str, Gtk.Widget | str]: enabled, x, y, relative_to = _floating_position_setting(rule) enabled_row = Adw.SwitchRow( title="Default Floating Position", subtitle="Set the initial position for matching floating windows", ) enabled_row.set_active(enabled) group.add(enabled_row) location_model = Gtk.StringList.new(FLOATING_POSITION_LOCATION_LABELS) location_row = Adw.ComboRow(title="Location", model=location_model) location_row.set_selected(_floating_position_location_index(x, y, relative_to)) group.add(location_row) x_adj = Gtk.Adjustment( value=x, lower=-7680, upper=7680, step_increment=10, page_increment=100, ) x_row = Adw.SpinRow( title=FLOATING_POSITION_CUSTOM_FIELD_LABELS[0], adjustment=x_adj, digits=0, ) group.add(x_row) y_adj = Gtk.Adjustment( value=y, lower=-7680, upper=7680, step_increment=10, page_increment=100, ) y_row = Adw.SpinRow( title=FLOATING_POSITION_CUSTOM_FIELD_LABELS[1], adjustment=y_adj, digits=0, ) group.add(y_row) def _update_visibility(*_): active = enabled_row.get_active() custom = location_row.get_selected() == CUSTOM_FLOATING_POSITION_INDEX location_row.set_visible(active) x_row.set_visible(active and custom) y_row.set_visible(active and custom) enabled_row.connect("notify::active", _update_visibility) location_row.connect("notify::selected", _update_visibility) _update_visibility() custom_relative_to = ( relative_to if location_row.get_selected() == CUSTOM_FLOATING_POSITION_INDEX else CUSTOM_FLOATING_POSITION_RELATIVE_TO ) return { "enabled": enabled_row, "location": location_row, "x": x_row, "y": y_row, "custom_relative_to": custom_relative_to, } def _floating_position_node_from_controls( self, controls: dict[str, Gtk.Widget | str] ) -> KdlNode | None: enabled_row = controls["enabled"] enabled = ( enabled_row.get_active() if isinstance(enabled_row, Adw.SwitchRow) else False ) location_row = controls["location"] selected = ( location_row.get_selected() if isinstance(location_row, Adw.ComboRow) else CUSTOM_FLOATING_POSITION_INDEX ) if selected < CUSTOM_FLOATING_POSITION_INDEX: _, relative_to = FLOATING_POSITION_PRESETS[selected] return _make_floating_position_node(enabled, 0, 0, relative_to) else: custom_relative_to = controls.get("custom_relative_to") relative_to = ( custom_relative_to if isinstance(custom_relative_to, str) else CUSTOM_FLOATING_POSITION_RELATIVE_TO ) x_row = controls["x"] y_row = controls["y"] x = int(x_row.get_value()) if isinstance(x_row, Adw.SpinRow) else 0 y = int(y_row.get_value()) if isinstance(y_row, Adw.SpinRow) else 0 return _make_floating_position_node(enabled, x, y, relative_to) def _size_mode_index(self, kind: str, value: float | int | None) -> int: if kind == "fixed": return FIXED_SIZE_INDEX if kind == "proportion" and value is not None: for i, (_, preset) in enumerate(SIZE_PERCENT_PRESETS): if abs(float(value) - preset) < 0.00001: return i return CUSTOM_SIZE_INDEX return CUSTOM_SIZE_INDEX def _add_size_controls( self, group: Adw.PreferencesGroup, rule: KdlNode | None, key: str ) -> dict[str, Gtk.Widget]: cfg = WINDOW_SIZE_CONTROLS[key] kind, value = _window_size_setting(rule, key) title = cfg.title override_row = Adw.SwitchRow( title=f"Override {title}", subtitle="Off writes no explicit size rule", ) override_row.set_active(kind != "default") group.add(override_row) mode_model = Gtk.StringList.new(SIZE_MODE_LABELS) mode_row = Adw.ComboRow(title=title, model=mode_model) mode_row.set_selected(self._size_mode_index(kind, value)) group.add(mode_row) custom_value = cfg.initial_percent if kind == "proportion" and value is not None: custom_value = round(float(value) * 100.0, 2) custom_adj = Gtk.Adjustment( value=custom_value, lower=1.0, upper=100.0, step_increment=1.0, page_increment=5.0, ) custom_row = Adw.SpinRow( title=f"Custom {title} (%)", adjustment=custom_adj, digits=2 ) group.add(custom_row) fixed_value = cfg.fixed if kind == "fixed" and value is not None: fixed_value = int(value) fixed_adj = Gtk.Adjustment( value=fixed_value, lower=1, upper=7680, step_increment=10, page_increment=100, ) fixed_row = Adw.SpinRow( title=f"Fixed {title} (px)", adjustment=fixed_adj, digits=0 ) group.add(fixed_row) def _update_visibility(*_): enabled = override_row.get_active() selected = mode_row.get_selected() mode_row.set_visible(enabled) custom_row.set_visible(enabled and selected == CUSTOM_SIZE_INDEX) fixed_row.set_visible(enabled and selected == FIXED_SIZE_INDEX) override_row.connect("notify::active", _update_visibility) mode_row.connect("notify::selected", _update_visibility) _update_visibility() return { "override": override_row, "mode": mode_row, "custom": custom_row, "fixed": fixed_row, } def _size_node_from_controls( self, key: str, controls: dict[str, Gtk.Widget] ) -> KdlNode | None: override_row = controls["override"] if isinstance(override_row, Adw.SwitchRow) and not override_row.get_active(): return None mode_row = controls["mode"] selected = mode_row.get_selected() if isinstance(mode_row, Adw.ComboRow) else 0 if selected == FIXED_SIZE_INDEX: fixed_row = controls["fixed"] value = fixed_row.get_value() if isinstance(fixed_row, Adw.SpinRow) else 0 return _make_size_node(key, "fixed", int(value)) if selected == CUSTOM_SIZE_INDEX: custom_row = controls["custom"] value = ( custom_row.get_value() / 100.0 if isinstance(custom_row, Adw.SpinRow) else 0 ) return _make_size_node(key, "proportion", value) _, value = SIZE_PERCENT_PRESETS[selected] return _make_size_node(key, "proportion", value) def _show_rule_dialog(self, rule: KdlNode | None, rule_idx: int): dialog = Adw.Dialog(title="Window Rule") dialog.set_content_width(520) dialog.set_content_height(680) toolbar_view = Adw.ToolbarView() hdr = Adw.HeaderBar() title_lbl = "Edit Window Rule" if rule else "New Window Rule" hdr.set_title_widget(Adw.WindowTitle(title=title_lbl)) toolbar_view.add_top_bar(hdr) scroll = Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) scroll.set_vexpand(True) prefs = Adw.PreferencesPage() # ── Match criteria ──────────────────────────────────────────────── match_grp = Adw.PreferencesGroup( title="Match Criteria", description="Leave fields empty to match any window", ) match_node = rule.get_child("match") if rule else None app_id_row = Adw.EntryRow(title="App ID (regex, e.g. ^kitty$)") app_id_row.set_text( str(match_node.props.get("app-id", "")) if match_node else "" ) match_grp.add(app_id_row) title_row = Adw.EntryRow(title="Window Title (regex)") title_row.set_text(str(match_node.props.get("title", "")) if match_node else "") match_grp.add(title_row) bool_match_rows: dict[str, Adw.SwitchRow] = {} for key, label in BOOL_MATCH_LABELS.items(): sr = Adw.SwitchRow(title=label) val = match_node.props.get(key, False) if match_node else False sr.set_active(bool(val)) match_grp.add(sr) bool_match_rows[key] = sr prefs.add(match_grp) # ── Visibility & layout ─────────────────────────────────────────── layout_grp = Adw.PreferencesGroup( title="Layout & Visibility", description="Window-size overrides apply when a matching window opens.", ) size_controls = { key: self._add_size_controls(layout_grp, rule, key) for key in WINDOW_SIZE_CONTROLS } bool_rows: dict[str, Adw.SwitchRow] = {} for key, label in BOOL_ACTION_LABELS.items(): sr = Adw.SwitchRow(title=label) sr.set_active(_bool_action_active(rule, key)) layout_grp.add(sr) bool_rows[key] = sr floating_position_controls = self._add_floating_position_controls( layout_grp, rule ) prefs.add(layout_grp) # ── Visual effects ──────────────────────────────────────────────── fx_grp = Adw.PreferencesGroup(title="Visual Effects") op_val = 0.0 if rule: op_node = rule.get_child("opacity") if op_node and op_node.args: op_val = float(op_node.args[0]) op_adj = Gtk.Adjustment(value=op_val, lower=0.0, upper=1.0, step_increment=0.05) op_row = Adw.SpinRow( title="Opacity (0 = unset, 1 = fully opaque)", adjustment=op_adj, digits=2 ) fx_grp.add(op_row) blur_row = Adw.SwitchRow( title="Background Blur", subtitle="Adds background-effect { blur true }", ) has_blur = False if rule: be = rule.get_child("background-effect") if be is not None: blur_child = be.get_child("blur") has_blur = blur_child is not None and ( not blur_child.args or blur_child.args[0] is True ) blur_row.set_active(has_blur) fx_grp.add(blur_row) prefs.add(fx_grp) # ── Numeric dimensions ──────────────────────────────────────────── dim_grp = Adw.PreferencesGroup(title="Dimensions (0 = unset)") num_rows: dict[str, Adw.SpinRow] = {} for key, (label, lo, hi, step, digits) in NUM_ACTION_LABELS.items(): if key == "opacity": continue # handled above cur = 0 if rule: cn = rule.get_child(key) cur = cn.args[0] if cn and cn.args else 0 adj = Gtk.Adjustment( value=float(cur), lower=lo, upper=hi, step_increment=step ) sr = Adw.SpinRow(title=label, adjustment=adj, digits=digits) dim_grp.add(sr) num_rows[key] = sr prefs.add(dim_grp) # ── Workspace / output ──────────────────────────────────────────── place_grp = Adw.PreferencesGroup(title="Placement") str_rows: dict[str, Adw.EntryRow] = {} for key, label in STR_ACTION_LABELS.items(): e = Adw.EntryRow(title=label) if rule: cn = rule.get_child(key) e.set_text(str(cn.args[0]) if cn and cn.args else "") place_grp.add(e) str_rows[key] = e prefs.add(place_grp) scroll.set_child(prefs) toolbar_view.set_content(scroll) # ── Save button ─────────────────────────────────────────────────── btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) btn_box.set_halign(Gtk.Align.END) btn_box.set_margin_start(16) btn_box.set_margin_end(16) btn_box.set_margin_top(8) btn_box.set_margin_bottom(16) cancel_btn = Gtk.Button(label="Cancel") cancel_btn.add_css_class("pill") cancel_btn.connect("clicked", lambda *_: dialog.close()) btn_box.append(cancel_btn) save_btn = Gtk.Button(label="Save Rule") save_btn.add_css_class("suggested-action") save_btn.add_css_class("pill") btn_box.append(save_btn) toolbar_view.add_bottom_bar(btn_box) def _save(*_): new_rule = KdlNode("window-rule") new_rule.leading_trivia = "\n" # match node m = KdlNode("match") has_match = False app_id_text = app_id_row.get_text().strip() if app_id_text: m.props["app-id"] = KdlRawString(app_id_text) has_match = True title_text = title_row.get_text().strip() if title_text: m.props["title"] = KdlRawString(title_text) has_match = True for key, sr in bool_match_rows.items(): if sr.get_active(): m.props[key] = True has_match = True if has_match: new_rule.children.append(m) # per-rule window sizing for key, controls in size_controls.items(): cn = self._size_node_from_controls(key, controls) if cn is not None: new_rule.children.append(cn) # floating position position_node = self._floating_position_node_from_controls( floating_position_controls ) if position_node is not None: new_rule.children.append(position_node) # bool actions for key, sr in bool_rows.items(): if sr.get_active(): new_rule.children.append(_bool_action_node(key)) # opacity op = op_row.get_value() if op > 0.0: cn = KdlNode("opacity") cn.args = [round(op, 2)] new_rule.children.append(cn) # blur if blur_row.get_active(): be = KdlNode("background-effect") be.children.append(KdlNode("blur", args=[True])) new_rule.children.append(be) # dimensions for key, sr in num_rows.items(): v = sr.get_value() if v > 0: cn = KdlNode(key) cn.args = [int(v)] new_rule.children.append(cn) # placement strings for key, e in str_rows.items(): v = e.get_text().strip() if v: cn = KdlNode(key) cn.args = [v] new_rule.children.append(cn) rules = self._get_rules() if rule_idx >= 0 and 0 <= rule_idx < len(rules): i = self._nodes.index(rules[rule_idx]) new_rule.source_file = rules[rule_idx].source_file new_rule.leading_trivia = rules[rule_idx].leading_trivia self._nodes[i] = new_rule else: if rules: new_rule.source_file = rules[-1].source_file self._nodes.append(new_rule) self._commit("window rule") self._rebuild() dialog.close() save_btn.connect("clicked", _save) dialog.set_child(toolbar_view) dialog.present(self._win) # ── Layer rules ─────────────────────────────────────────────────────────── def _get_layer_rules(self) -> list[KdlNode]: return [n for n in self._nodes if n.name == "layer-rule"] def _rebuild_layer(self): parent = self._layer_rules_grp.get_parent() if parent is None: return rules = self._get_layer_rules() new_grp = Adw.PreferencesGroup( title="Layer Rules", description=f"{len(rules)} rule(s) — bars, overlays, wallpapers", ) for i, rule in enumerate(rules): new_grp.add(self._make_layer_rule_row(rule, i)) parent.remove(self._layer_rules_grp) parent.append(new_grp) self._layer_rules_grp = new_grp def _make_layer_rule_row(self, rule: KdlNode, idx: int) -> Adw.ActionRow: title, subtitle = _layer_rule_summary(rule) row = Adw.ActionRow(title=title, subtitle=subtitle) row.set_activatable(True) row.add_css_class("monospace") has_blur = rule.get_child("background-effect") is not None if has_blur: lbl = Gtk.Label(label="blur") lbl.add_css_class("tag") lbl.add_css_class("accent") lbl.set_valign(Gtk.Align.CENTER) row.add_suffix(lbl) del_btn = Gtk.Button(icon_name="user-trash-symbolic") del_btn.set_valign(Gtk.Align.CENTER) del_btn.add_css_class("flat") del_btn.add_css_class("error") del_btn.set_tooltip_text("Delete layer rule") del_btn.connect("clicked", lambda *_, i=idx: self._on_delete_layer(i)) row.add_suffix(del_btn) row.connect("activated", lambda *_, i=idx: self._on_edit_layer(i)) return row def _on_add_layer(self, *_): self._show_layer_dialog(None, -1) def _on_edit_layer(self, idx: int): rules = self._get_layer_rules() if 0 <= idx < len(rules): self._show_layer_dialog(rules[idx], idx) def _on_delete_layer(self, idx: int): rules = self._get_layer_rules() if not (0 <= idx < len(rules)): return self._nodes.remove(rules[idx]) self._commit("remove layer rule") self._rebuild_layer() t = Adw.Toast(title="Layer rule deleted", button_label="Undo", timeout=5) t.connect("button-clicked", lambda *_: self._win._do_undo()) self._win._toast_overlay.add_toast(t) def _show_layer_dialog(self, rule: KdlNode | None, idx: int): dialog = Adw.Dialog(title="Layer Rule") dialog.set_content_width(460) toolbar_view = Adw.ToolbarView() hdr = Adw.HeaderBar() hdr.set_title_widget( Adw.WindowTitle(title="Edit Layer Rule" if rule else "New Layer Rule") ) toolbar_view.add_top_bar(hdr) prefs = Adw.PreferencesPage() match_grp = Adw.PreferencesGroup(title="Match") match_node = rule.get_child("match") if rule else None ns_entry = Adw.EntryRow(title="Namespace (regex, e.g. ^waybar$)") ns_entry.set_text( str(match_node.props.get("namespace", "")) if match_node else "" ) match_grp.add(ns_entry) prefs.add(match_grp) act_grp = Adw.PreferencesGroup(title="Actions") bool_rows: dict[str, Adw.SwitchRow] = {} for key, label in LAYER_BOOL_ACTION_LABELS.items(): sr = Adw.SwitchRow(title=label) sr.set_active(_bool_action_active(rule, key)) act_grp.add(sr) bool_rows[key] = sr blur_row = Adw.SwitchRow(title="Background Blur") has_blur = False if rule: be = rule.get_child("background-effect") if be: bc = be.get_child("blur") has_blur = bc is not None and (not bc.args or bc.args[0] is True) blur_row.set_active(has_blur) act_grp.add(blur_row) op_adj = Gtk.Adjustment(value=1.0, lower=0.0, upper=1.0, step_increment=0.05) if rule: op_node = rule.get_child("opacity") if op_node and op_node.args: op_adj.set_value(float(op_node.args[0])) op_row = Adw.SpinRow(title="Opacity (1 = unset)", adjustment=op_adj, digits=2) act_grp.add(op_row) prefs.add(act_grp) toolbar_view.set_content(prefs) btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) btn_box.set_halign(Gtk.Align.END) btn_box.set_margin_start(16) btn_box.set_margin_end(16) btn_box.set_margin_top(8) btn_box.set_margin_bottom(16) cancel_btn = Gtk.Button(label="Cancel") cancel_btn.add_css_class("pill") cancel_btn.connect("clicked", lambda *_: dialog.close()) btn_box.append(cancel_btn) save_btn = Gtk.Button(label="Save Rule") save_btn.add_css_class("suggested-action") save_btn.add_css_class("pill") btn_box.append(save_btn) toolbar_view.add_bottom_bar(btn_box) def _save(*_): new_rule = KdlNode("layer-rule") new_rule.leading_trivia = "\n" ns = ns_entry.get_text().strip() if ns: m = KdlNode("match") m.props["namespace"] = KdlRawString(ns) new_rule.children.append(m) for key, sr in bool_rows.items(): if sr.get_active(): new_rule.children.append(_bool_action_node(key)) if blur_row.get_active(): be = KdlNode("background-effect") be.children.append(KdlNode("blur", args=[True])) new_rule.children.append(be) op = op_row.get_value() if op < 1.0: op_node = KdlNode("opacity") op_node.args = [round(op, 2)] new_rule.children.append(op_node) rules = self._get_layer_rules() if idx >= 0 and 0 <= idx < len(rules): i = self._nodes.index(rules[idx]) new_rule.source_file = rules[idx].source_file new_rule.leading_trivia = rules[idx].leading_trivia self._nodes[i] = new_rule else: if rules: new_rule.source_file = rules[-1].source_file elif self._get_rules(): new_rule.source_file = self._get_rules()[-1].source_file self._nodes.append(new_rule) self._commit("layer rule") self._rebuild_layer() dialog.close() save_btn.connect("clicked", _save) dialog.set_child(toolbar_view) dialog.present(self._win)