1015 lines
36 KiB
Python
1015 lines
36 KiB
Python
"""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)
|