(Init): Added shit

This commit is contained in:
2026-05-29 00:41:12 +00:00
commit 72005fd71d
52 changed files with 12875 additions and 0 deletions

View File

1222
nirimod/pages/animations.py Normal file

File diff suppressed because it is too large Load Diff

452
nirimod/pages/appearance.py Normal file
View File

@@ -0,0 +1,452 @@
"""Appearance page — borders, focus ring, shadows, corner radius."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gtk
from nirimod.kdl_parser import KdlNode, find_or_create, set_child_arg, set_node_flag
from nirimod.pages.base import BasePage
from nirimod.window_effects import (
blur_effects_enabled,
focused_window_blur_enabled,
get_global_draw_border_with_background,
get_global_corner_radius,
get_global_window_opacity,
global_window_blur_enabled,
global_window_xray_enabled,
set_focused_window_blur,
set_global_draw_border_with_background,
set_global_corner_radius,
set_global_window_blur,
set_global_window_opacity,
set_global_window_xray,
set_blur_effects_enabled,
)
def _parse_color(color_str: str) -> Gdk.RGBA:
rgba = Gdk.RGBA()
if color_str and not rgba.parse(color_str):
rgba.parse("#7fc8ff")
return rgba
class AppearancePage(BasePage):
def build(self) -> Gtk.Widget:
tb, _, _, content = self._make_toolbar_page("Appearance")
self._content = content
self._build_content()
return tb
def _build_content(self):
content = self._content
nodes = self._nodes
layout = find_or_create(nodes, "layout")
fr_node = layout.get_child("focus-ring") or KdlNode("focus-ring")
fr_group = self._build_border_group("Focus Ring", "focus-ring", fr_node, layout)
content.append(fr_group)
b_node = layout.get_child("border") or KdlNode("border")
b_group = self._build_border_group("Border", "border", b_node, layout)
content.append(b_group)
shadow_grp = Adw.PreferencesGroup(title="Shadow")
shadow_node = layout.get_child("shadow") or KdlNode("shadow")
shadow_on_row = Adw.SwitchRow(title="Enable Shadows")
shadow_on_row.set_active(shadow_node.get_child("on") is not None)
shadow_on_row.connect(
"notify::active", lambda r, _: self._set_shadow_flag("on", r.get_active())
)
shadow_grp.add(shadow_on_row)
soft_val = int(shadow_node.child_arg("softness") or 30)
softness_adj = Gtk.Adjustment(
value=soft_val, lower=0, upper=100, step_increment=1
)
softness_row = Adw.SpinRow(
title="Softness (blur radius)", adjustment=softness_adj, digits=0
)
softness_row._last_val = soft_val
def _on_soft_changed(r, _):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_shadow("softness", new_val)
softness_row.connect("notify::value", _on_soft_changed)
shadow_grp.add(softness_row)
spread_val = int(shadow_node.child_arg("spread") or 5)
spread_adj = Gtk.Adjustment(
value=spread_val, lower=-50, upper=100, step_increment=1
)
spread_row = Adw.SpinRow(title="Spread", adjustment=spread_adj, digits=0)
spread_row._last_val = spread_val
def _on_spread_changed(r, _):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_shadow("spread", new_val)
spread_row.connect("notify::value", _on_spread_changed)
shadow_grp.add(spread_row)
color_str = shadow_node.child_arg("color") or "#0007"
color_row = Adw.ActionRow(title="Shadow Color")
color_btn = Gtk.ColorDialogButton(
dialog=Gtk.ColorDialog(title="Shadow Color", with_alpha=True)
)
color_btn.set_rgba(_parse_color(color_str))
color_btn.set_valign(Gtk.Align.CENTER)
color_btn.connect(
"notify::rgba", lambda b, _: self._set_shadow_color(b.get_rgba())
)
color_row.add_suffix(color_btn)
shadow_grp.add(color_row)
draw_behind_row = Adw.SwitchRow(
title="Draw Behind Window",
subtitle="Fixes corner artifacts with non-CSD apps",
)
draw_behind_row.set_active(
shadow_node.get_child("draw-behind-window") is not None
)
draw_behind_row.connect(
"notify::active",
lambda r, _: self._set_shadow_flag("draw-behind-window", r.get_active()),
)
shadow_grp.add(draw_behind_row)
content.append(shadow_grp)
blur_grp = Adw.PreferencesGroup(
title="Blur (Global)",
description=(
"Requires Niri 26.04 or later. Sets blur quality and optional "
"window blur rules."
),
)
blur_node = next((n for n in nodes if n.name == "blur"), None)
blur_effects_row = Adw.SwitchRow(
title="Enable Blur Effects",
subtitle="Controls the compositor-level blur { off } setting",
)
blur_effects_row.set_active(blur_effects_enabled(nodes))
blur_effects_row.connect(
"notify::active",
lambda r, _: self._set_blur_effects_enabled(r.get_active()),
)
blur_grp.add(blur_effects_row)
blur_enabled_row = Adw.SwitchRow(
title="Force Blur on Windows",
subtitle="Adds background-effect { blur true } to the global window rule",
)
blur_enabled_row.set_active(global_window_blur_enabled(nodes))
blur_enabled_row.connect(
"notify::active",
lambda r, _: self._set_window_blur_enabled(r.get_active()),
)
blur_grp.add(blur_enabled_row)
focused_blur_row = Adw.SwitchRow(
title="Keep Focused Windows Blurred",
subtitle="Adds a focused-window rule that forces blur on",
)
focused_blur_row.set_active(focused_window_blur_enabled(nodes))
focused_blur_row.connect(
"notify::active",
lambda r, _: self._set_focused_window_blur_enabled(r.get_active()),
)
blur_grp.add(focused_blur_row)
xray_row = Adw.SwitchRow(
title="Use Xray Wallpaper Blur",
subtitle="Use wallpaper-only blur; disable for regular background blur",
)
xray_row.set_active(global_window_xray_enabled(nodes))
xray_row.connect(
"notify::active",
lambda r, _: self._set_window_blur_xray(r.get_active()),
)
blur_grp.add(xray_row)
opacity_val = get_global_window_opacity(nodes)
opacity_adj = Gtk.Adjustment(
value=opacity_val, lower=0.1, upper=1.0, step_increment=0.05
)
opacity_row = Adw.SpinRow(
title="Window Opacity (1 = unset)", adjustment=opacity_adj, digits=2
)
opacity_row._last_val = opacity_val
def _on_opacity_changed(r, _):
new_val = round(float(r.get_value()), 2)
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_window_opacity(new_val)
opacity_row.connect("notify::value", _on_opacity_changed)
blur_grp.add(opacity_row)
border_bg_row = Adw.SwitchRow(
title="Draw Border With Background",
subtitle="Disable to avoid focus colors behind translucent windows",
)
border_bg_row.set_active(get_global_draw_border_with_background(nodes))
border_bg_row.connect(
"notify::active",
lambda r, _: self._set_draw_border_with_background(r.get_active()),
)
blur_grp.add(border_bg_row)
passes_val = int(blur_node.child_arg("passes", 0) if blur_node else 0)
passes_adj = Gtk.Adjustment(
value=passes_val, lower=0, upper=10, step_increment=1
)
passes_row = Adw.SpinRow(
title="Passes", adjustment=passes_adj, digits=0
)
passes_row._last_val = passes_val
def _on_passes_changed(r, _):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_blur("passes", new_val)
passes_row.connect("notify::value", _on_passes_changed)
blur_grp.add(passes_row)
offset_val = float(blur_node.child_arg("offset", 2.0) if blur_node else 2.0)
offset_adj = Gtk.Adjustment(
value=offset_val, lower=0.0, upper=20.0, step_increment=0.1
)
offset_row = Adw.SpinRow(title="Offset", adjustment=offset_adj, digits=1)
offset_row._last_val = offset_val
def _on_offset_changed(r, _):
new_val = float(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_blur("offset", new_val)
offset_row.connect("notify::value", _on_offset_changed)
blur_grp.add(offset_row)
noise_val = float(blur_node.child_arg("noise", 0.0) if blur_node else 0.0)
noise_adj = Gtk.Adjustment(
value=noise_val, lower=0.0, upper=1.0, step_increment=0.01
)
noise_row = Adw.SpinRow(title="Noise", adjustment=noise_adj, digits=2)
noise_row._last_val = noise_val
def _on_noise_changed(r, _):
new_val = float(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_blur("noise", new_val)
noise_row.connect("notify::value", _on_noise_changed)
blur_grp.add(noise_row)
saturation_val = float(blur_node.child_arg("saturation", 1.0) if blur_node else 1.0)
saturation_adj = Gtk.Adjustment(
value=saturation_val, lower=0.0, upper=5.0, step_increment=0.1
)
saturation_row = Adw.SpinRow(
title="Saturation", adjustment=saturation_adj, digits=1
)
saturation_row._last_val = saturation_val
def _on_saturation_changed(r, _):
new_val = float(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_blur("saturation", new_val)
saturation_row.connect("notify::value", _on_saturation_changed)
blur_grp.add(saturation_row)
content.append(blur_grp)
misc_grp = Adw.PreferencesGroup(title="Window Geometry")
cr_val = get_global_corner_radius(nodes)
cr_adj = Gtk.Adjustment(value=cr_val, lower=0, upper=40, step_increment=1)
cr_row = Adw.SpinRow(
title="Corner Radius (px)",
adjustment=cr_adj,
digits=0,
)
cr_row._last_val = cr_val
def _on_cr_changed(r, _):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_corner_radius(new_val)
cr_row.connect("notify::value", _on_cr_changed)
misc_grp.add(cr_row)
content.append(misc_grp)
def _build_border_group(
self, title: str, key: str, node: KdlNode, layout: KdlNode
) -> Adw.PreferencesGroup:
grp = Adw.PreferencesGroup(title=title)
off_row = Adw.SwitchRow(title="Enable")
off_row.set_active(node.get_child("off") is None)
off_row.connect(
"notify::active",
lambda r, _, k=key: self._set_layout_border_flag(
k, "off", not r.get_active()
),
)
grp.add(off_row)
width_val = int(node.child_arg("width") or 4)
width_adj = Gtk.Adjustment(value=width_val, lower=1, upper=20, step_increment=1)
width_row = Adw.SpinRow(title="Width (px)", adjustment=width_adj, digits=0)
width_row._last_val = width_val
def _on_width_changed(r, _, k=key):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_layout_border(k, "width", new_val)
width_row.connect("notify::value", _on_width_changed)
grp.add(width_row)
for color_key, color_label in [
("active-color", "Active Color"),
("inactive-color", "Inactive Color"),
]:
c_str = node.child_arg(color_key) or (
"#7fc8ff" if "active" in color_key else "#202020"
)
c_row = Adw.ActionRow(title=color_label)
c_btn = Gtk.ColorDialogButton(
dialog=Gtk.ColorDialog(title=color_label, with_alpha=True)
)
c_btn.set_rgba(_parse_color(c_str))
c_btn.set_valign(Gtk.Align.CENTER)
c_btn.connect(
"notify::rgba",
lambda b, _, k=key, ck=color_key: self._set_layout_border(
k, ck, self._rgba_to_hex(b.get_rgba())
),
)
c_row.add_suffix(c_btn)
grp.add(c_row)
return grp
@staticmethod
def _rgba_to_hex(rgba: Gdk.RGBA) -> str:
r = int(rgba.red * 255)
g = int(rgba.green * 255)
b = int(rgba.blue * 255)
a = int(rgba.alpha * 255)
if a == 255:
return f"#{r:02x}{g:02x}{b:02x}"
return f"#{r:02x}{g:02x}{b:02x}{a:02x}"
def _get_layout(self):
return find_or_create(self._nodes, "layout")
def _get_border_node(self, key: str) -> KdlNode:
layout = self._get_layout()
node = layout.get_child(key)
if node is None:
node = KdlNode(key)
layout.children.append(node)
return node
def _set_layout_border(self, bkey: str, prop: str, value):
node = self._get_border_node(bkey)
set_child_arg(node, prop, value)
self._commit(f"{bkey} {prop}")
def _set_layout_border_flag(self, bkey: str, flag: str, enabled: bool):
node = self._get_border_node(bkey)
set_node_flag(node, flag, enabled)
self._commit(f"{bkey} {flag}")
def _get_shadow_node(self) -> KdlNode:
layout = self._get_layout()
node = layout.get_child("shadow")
if node is None:
node = KdlNode("shadow")
layout.children.append(node)
return node
def _set_shadow(self, prop: str, value):
set_child_arg(self._get_shadow_node(), prop, value)
self._commit(f"shadow {prop}")
def _set_shadow_flag(self, flag: str, enabled: bool):
set_node_flag(self._get_shadow_node(), flag, enabled)
self._commit(f"shadow {flag}")
def _set_shadow_color(self, rgba: Gdk.RGBA):
set_child_arg(self._get_shadow_node(), "color", self._rgba_to_hex(rgba))
self._commit("shadow color")
def _set_blur(self, prop: str, value):
blur_node = find_or_create(self._nodes, "blur")
set_child_arg(blur_node, prop, value)
self._commit(f"blur {prop}")
def _set_blur_effects_enabled(self, enabled: bool):
set_blur_effects_enabled(self._nodes, enabled)
self._commit("blur effects")
def _set_window_blur_enabled(self, enabled: bool):
set_global_window_blur(self._nodes, enabled)
self._commit("window blur")
def _set_focused_window_blur_enabled(self, enabled: bool):
set_focused_window_blur(self._nodes, enabled)
self._commit("focused window blur")
def _set_window_blur_xray(self, enabled: bool):
set_global_window_xray(self._nodes, enabled)
self._commit("window blur xray")
def _set_window_opacity(self, opacity: float):
set_global_window_opacity(self._nodes, opacity)
self._commit("window opacity")
def _set_draw_border_with_background(self, enabled: bool):
set_global_draw_border_with_background(self._nodes, enabled)
self._commit("draw border with background")
def _set_corner_radius(self, radius: int):
set_global_corner_radius(self._nodes, radius)
self._commit("corner radius")
def refresh(self):
for child in list(self._content):
self._content.remove(child)
self._build_content()

98
nirimod/pages/base.py Normal file
View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gio", "2.0")
from gi.repository import Adw, Gtk, Gio
if TYPE_CHECKING:
from nirimod.window import NiriModWindow
def make_toolbar_page(
title: str,
window=None,
) -> tuple[Adw.ToolbarView, Adw.HeaderBar, Gtk.ScrolledWindow, Gtk.Box]:
tb = Adw.ToolbarView()
header = Adw.HeaderBar()
tb.add_top_bar(header)
# Hamburger menu on the content header (appears next to window close button)
if window is not None:
menu = Gio.Menu()
menu.append("Profiles", "win.open_profiles")
menu.append("Preferences", "win.open_preferences")
menu.append("Restore Backup...", "win.reset_config")
kofi_section = Gio.Menu()
kofi_section.append("Support on Ko-fi ☕", "win.open_kofi")
menu.append_section(None, kofi_section)
menu_btn = Gtk.MenuButton(icon_name="open-menu-symbolic")
menu_btn.set_tooltip_text("Menu")
menu_btn.add_css_class("flat")
menu_btn.set_menu_model(menu)
header.pack_end(menu_btn)
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroll.set_vexpand(True)
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
content.set_margin_start(32)
content.set_margin_end(32)
content.set_margin_top(24)
content.set_margin_bottom(32)
scroll.set_child(content)
tb.set_content(scroll)
return tb, header, scroll, content
class BasePage:
def __init__(self, window: "NiriModWindow"):
self._win = window
def _make_toolbar_page(
self, title: str
) -> tuple[Adw.ToolbarView, Adw.HeaderBar, Gtk.ScrolledWindow, Gtk.Box]:
return make_toolbar_page(title, window=self._win)
@property
def _nodes(self):
return self._win.get_nodes()
def _commit(self, description: str = "change"):
app_state = self._win.app_state
after = app_state.write_current_kdl()
before = app_state.undo.last_snapshot
if before is None:
before = app_state.saved_kdl
if before != after:
self._win.push_undo(description, before, after)
if after == app_state.saved_kdl:
self._win.mark_clean()
else:
self._win.mark_dirty()
def build(self) -> Gtk.Widget:
raise NotImplementedError
def refresh(self):
pass
def on_shown(self):
pass
def show_toast(self, msg: str, timeout: int = 3):
self._win.show_toast(msg, timeout)

776
nirimod/pages/bindings.py Normal file
View File

@@ -0,0 +1,776 @@
"""Key Bindings page — list editor + keyboard map visualizer.
Tab 1: "Bindings List" — the original Adw row-based editor (unchanged logic).
Tab 2: "Keyboard Map" — Cairo keyboard visualizer ported from omer-biz/visu.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio, GLib, Gtk
from nirimod.kdl_parser import KdlNode
from nirimod.pages.base import BasePage
from nirimod.widgets import KeyboardVisualizer, normalize_key_id
MODIFIERS = ["Mod", "Super", "Ctrl", "Alt", "Shift"]
NIRI_ACTIONS = [
"close-window",
"focus-column-left",
"focus-column-right",
"focus-column-first",
"focus-column-last",
"focus-window-down",
"focus-window-up",
"move-column-left",
"move-column-right",
"move-column-to-first",
"move-column-to-last",
"move-window-down",
"move-window-up",
"focus-workspace-down",
"focus-workspace-up",
"focus-workspace",
"move-column-to-workspace",
"move-column-to-workspace-down",
"move-column-to-workspace-up",
"move-workspace-down",
"move-workspace-up",
"focus-monitor-left",
"focus-monitor-right",
"focus-monitor-up",
"focus-monitor-down",
"move-column-to-monitor-left",
"move-column-to-monitor-right",
"move-column-to-monitor-down",
"move-column-to-monitor-up",
"maximize-column",
"fullscreen-window",
"maximize-window-to-edges",
"switch-preset-column-width",
"switch-preset-window-height",
"set-column-width",
"set-window-height",
"set-dynamic-cast-window",
"set-dynamic-cast-monitor",
"clear-dynamic-cast-target",
"reset-window-height",
"center-column",
"center-visible-columns",
"screenshot",
"screenshot-screen",
"screenshot-window",
"spawn",
"spawn-sh",
"quit",
"power-off-monitors",
"toggle-window-floating",
"switch-focus-between-floating-and-tiling",
"toggle-column-tabbed-display",
"toggle-overview",
"consume-or-expel-window-left",
"consume-or-expel-window-right",
"consume-window-into-column",
"expel-window-from-column",
"expand-column-to-available-width",
"show-hotkey-overlay",
"toggle-keyboard-shortcuts-inhibit",
"toggle-windowed-fullscreen",
]
_KNOWN_BIND_PROPS = {"allow-when-locked", "repeat"}
def _make_bind(
keysym: str,
action: str = "",
action_args: list | None = None,
allow_when_locked: bool = False,
repeat: bool = True,
extra_props: dict | None = None,
node: KdlNode | None = None,
) -> dict:
return {
"keysym": keysym,
"action": action,
"action_args": action_args or [],
"allow_when_locked": allow_when_locked,
"repeat": repeat,
"extra_props": extra_props or {},
"_node": node,
}
def _parse_binds_from_nodes(nodes: list[KdlNode]) -> list[dict]:
"""Parse all bind nodes from the binds block."""
binds_node = next((n for n in nodes if n.name == "binds"), None)
if not binds_node:
return []
result = []
for child in binds_node.children:
keysym = child.name
action = ""
action_args: list = []
allow_locked = child.props.get("allow-when-locked", False)
repeat = child.props.get("repeat", True)
extra_props = {
k: v for k, v in child.props.items() if k not in _KNOWN_BIND_PROPS
}
for sub in child.children:
action = sub.name
action_args = list(sub.args)
result.append(
_make_bind(
keysym,
action,
action_args,
allow_locked,
repeat,
extra_props,
node=child,
)
)
return result
def _write_binds_to_node(binds_list: list[dict], binds_node: KdlNode):
kept_nodes = {id(b.get("_node")) for b in binds_list if b.get("_node") is not None}
salvaged_trivia = ""
for orig_child in binds_node.children:
if id(orig_child) not in kept_nodes:
salvaged_trivia += orig_child.leading_trivia
new_children = []
for i, b in enumerate(binds_list):
child = b.get("_node")
if child is None:
child = KdlNode(name=b["keysym"])
child.leading_trivia = "\n "
else:
child.name = b["keysym"]
if i == 0 and salvaged_trivia:
child.leading_trivia = salvaged_trivia + child.leading_trivia
salvaged_trivia = ""
child.props.clear()
if b["allow_when_locked"]:
child.props["allow-when-locked"] = True
if not b["repeat"]:
child.props["repeat"] = False
for k, v in b.get("extra_props", {}).items():
child.props[k] = v
if b["action"]:
args = b.get("action_args") or []
if not args:
legacy = b.get("action_arg", "")
if legacy:
args = [legacy]
if child.children:
action_node = child.children[0]
action_node.name = b["action"]
action_node.args = list(args)
child.children = [action_node]
else:
action_node = KdlNode(name=b["action"])
action_node.args = list(args)
action_node.leading_trivia = " "
child.children.append(action_node)
else:
child.children.clear()
new_children.append(child)
if salvaged_trivia:
binds_node.children_trailing_trivia = salvaged_trivia + binds_node.children_trailing_trivia
binds_node.children = new_children
def _build_key_bindings_map(binds: list[dict], viz=None) -> dict[str, list[dict]]:
result: dict[str, list[dict]] = {}
for b in binds:
keysym = b.get("keysym", "")
raw_key = keysym.split("+")[-1].lower()
kid = None
if viz and viz._dynamic_keysym_to_kid:
kid = viz._dynamic_keysym_to_kid.get(raw_key)
if not kid:
kid = normalize_key_id(raw_key)
result.setdefault(kid, []).append(b)
return result
# BindingsPage
class BindingsPage(BasePage):
def __init__(self, window):
super().__init__(window)
self._binds: list[dict] = []
self._search_query = ""
self._kb_search_query = ""
self._file_monitor: Gio.FileMonitor | None = None
self._viz: KeyboardVisualizer | None = None
def build(self) -> Gtk.Widget:
tb = Adw.ToolbarView()
# Custom Header
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
header_box.set_margin_start(24)
header_box.set_margin_end(24)
header_box.set_margin_top(20)
header_box.set_margin_bottom(12)
# Title/Subtitle Group
title_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
title_vbox.set_hexpand(True)
self._main_title = Gtk.Label(label="Keybindings")
self._main_title.set_xalign(0.0)
self._main_title.add_css_class("title-1")
title_vbox.append(self._main_title)
self._kb_stats_header = Gtk.Label(label="Detecting bindings...")
self._kb_stats_header.set_xalign(0.0)
self._kb_stats_header.add_css_class("dim-label")
self._kb_stats_header.add_css_class("caption")
title_vbox.append(self._kb_stats_header)
header_box.append(title_vbox)
# Layout Selector (shown only on Keyboard tab)
from nirimod import app_settings
from nirimod.xkb_helper import XkbHelper
self._layouts = XkbHelper.get_available_layouts()
layout_names = [d for _, d in self._layouts]
self._layout_model = Gtk.StringList.new(layout_names)
self._layout_combo = Gtk.DropDown(model=self._layout_model)
self._layout_combo.set_valign(Gtk.Align.CENTER)
self._layout_combo.set_enable_search(True)
# Priority: Settings > Niri Config > US
saved_layout = app_settings.get("kb_layout")
if not saved_layout:
saved_layout = self._get_current_niri_layout() or "us"
selected_idx = 0
for i, (lid, _) in enumerate(self._layouts):
if lid == saved_layout:
selected_idx = i
break
self._layout_combo.set_selected(selected_idx)
self._layout_combo.connect("notify::selected", self._on_layout_changed)
header_box.append(self._layout_combo)
# Add Button (hidden by default, shown on List tab)
self._add_btn = Gtk.Button(icon_name="list-add-symbolic")
self._add_btn.set_tooltip_text("Add binding")
self._add_btn.add_css_class("flat")
self._add_btn.add_css_class("circular")
self._add_btn.set_valign(Gtk.Align.CENTER)
self._add_btn.set_visible(False)
self._add_btn.connect("clicked", self._on_add_clicked)
header_box.append(self._add_btn)
# View Switcher (Styled as Physical/List View buttons)
self._view_stack = Adw.ViewStack()
switcher_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
switcher_box.add_css_class("linked")
switcher_box.set_valign(Gtk.Align.CENTER)
self._btn_physical = Gtk.ToggleButton(label="Physical")
self._btn_list = Gtk.ToggleButton(label="List View")
self._btn_list.set_group(self._btn_physical)
self._btn_physical.connect("toggled", self._on_view_toggle)
self._btn_list.connect("toggled", self._on_view_toggle)
switcher_box.append(self._btn_physical)
switcher_box.append(self._btn_list)
header_box.append(switcher_box)
self._view_stack.set_vexpand(True)
list_page_widget = self._build_list_tab()
self._view_stack.add_named(list_page_widget, "list")
kb_page_widget = self._build_keyboard_tab()
self._view_stack.add_named(kb_page_widget, "keyboard")
# Default to keyboard (Physical)
self._view_stack.set_visible_child_name("keyboard")
self._btn_physical.set_active(True)
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
main_box.append(header_box)
main_box.append(self._view_stack)
tb.set_content(main_box)
self.refresh()
self._start_file_monitor()
return tb
def _get_current_niri_layout(self):
try:
from nirimod import kdl_parser
nodes = kdl_parser.load_niri_config()
for node in nodes:
if node.name == "input":
kb = node.get_child("keyboard")
if kb:
xkb = kb.get_child("xkb")
if xkb:
layout = xkb.child_arg("layout")
v = xkb.child_arg("variant")
if layout:
return f"{layout}:{v}" if v else layout
except Exception:
pass
return None
def _on_layout_changed(self, dropdown, param):
from nirimod import app_settings
idx = dropdown.get_selected()
if idx < len(self._layouts):
layout_id = self._layouts[idx][0]
app_settings.set("kb_layout", layout_id)
if self._viz:
self._viz.set_layout(layout_id)
def _on_view_toggle(self, btn):
if not btn.get_active():
return
is_list = btn == self._btn_list
self._view_stack.set_visible_child_name("list" if is_list else "keyboard")
self._add_btn.set_visible(is_list)
if hasattr(self, "_layout_combo"):
self._layout_combo.set_visible(not is_list)
def _build_list_tab(self) -> Gtk.Widget:
"""Return the scrollable list editor widget (original UI)."""
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroll.set_vexpand(True)
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
content.set_margin_start(32)
content.set_margin_end(32)
content.set_margin_top(24)
content.set_margin_bottom(32)
scroll.set_child(content)
# Search
# Search
search = Gtk.SearchEntry(placeholder_text="Filter bindings…")
search.set_margin_start(0)
search.set_margin_end(0)
search.connect("search-changed", self._on_filter_changed)
content.append(search)
# Binds Grid
self._flowbox = Gtk.FlowBox()
self._flowbox.set_valign(Gtk.Align.START)
self._flowbox.set_max_children_per_line(3)
self._flowbox.set_min_children_per_line(1)
self._flowbox.set_selection_mode(Gtk.SelectionMode.NONE)
self._flowbox.set_column_spacing(16)
self._flowbox.set_row_spacing(16)
self._flowbox.set_homogeneous(True)
content.append(self._flowbox)
return scroll
def _build_keyboard_tab(self) -> Gtk.Widget:
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroll.set_vexpand(True)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
outer.set_margin_start(24)
outer.set_margin_end(24)
outer.set_margin_top(20)
outer.set_margin_bottom(24)
scroll.set_child(outer)
# Search bar
kb_search = Gtk.SearchEntry(
placeholder_text="Filter by action… (e.g. spawn, focus)"
)
kb_search.connect("search-changed", self._on_kb_search_changed)
outer.append(kb_search)
# Search bar
self._kb_stats = Gtk.Label(label="")
self._kb_stats.set_visible(False)
self._viz = KeyboardVisualizer()
idx = self._layout_combo.get_selected()
if 0 <= idx < len(self._layouts):
self._viz.set_layout(self._layouts[idx][0])
self._viz.connect("key-selected", self._on_kb_key_selected)
self._viz.connect("edit-binding", self._on_kb_edit_binding)
self._viz.connect("add-binding", self._on_kb_add_binding)
self._viz.connect("delete-binding", self._on_kb_delete_binding)
outer.append(self._viz)
return scroll
# Tab switching
# Refresh / sync
def refresh(self):
self._binds = _parse_binds_from_nodes(self._nodes)
self._rebuild_list()
self._refresh_visualizer()
def on_shown(self):
self._refresh_visualizer()
def _refresh_visualizer(self):
if self._viz is None:
return
from nirimod import app_settings
layout_id = app_settings.get("kb_layout")
if not layout_id:
layout_id = self._get_current_niri_layout() or "us"
self._viz.set_layout(layout_id)
binds_map = _build_key_bindings_map(self._binds, self._viz)
self._viz.set_bindings(binds_map)
self._viz.set_search(self._kb_search_query)
n_total = len(self._binds)
self._kb_stats_header.set_label(
f"{n_total} active bindings detected"
)
# List editor helpers (unchanged from original)
def _rebuild_list(self):
if not hasattr(self, "_flowbox"):
return
# Clear existing children
while True:
child = self._flowbox.get_first_child()
if child is None:
break
self._flowbox.remove(child)
q = self._search_query.lower()
visible_count = 0
for i, b in enumerate(self._binds):
if q and q not in b["keysym"].lower() and q not in b["action"].lower():
continue
card = self._make_bind_card(b, i)
self._flowbox.append(card)
visible_count += 1
def _make_bind_card(self, b: dict, idx: int) -> Gtk.Widget:
keysym = b["keysym"]
action = b["action"]
action_args = b.get("action_args") or []
action_arg_display = " ".join(str(a) for a in action_args)
full_action = f"{action} {action_arg_display}".strip()
if not full_action:
full_action = "(unassigned)"
# Card container
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
card.set_size_request(240, 140)
card.add_css_class("nm-binding-card")
# 1. Keycaps Row
keys_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
parts = keysym.split("+")
_labels = {
"mod": "Mod",
"super": "Super",
"ctrl": "Ctrl",
"control": "Ctrl",
"shift": "Shift",
"alt": "Alt",
}
for i, part in enumerate(parts):
label_text = part
is_mod = i < len(parts) - 1
if is_mod:
label_text = _labels.get(part.lower(), part)
else:
label_text = label_text.upper() if len(label_text) == 1 else label_text
cap = Gtk.Label(label=label_text)
cap.add_css_class("nm-keycap-purple")
keys_box.append(cap)
if i < len(parts) - 1:
plus = Gtk.Label(label="+")
plus.add_css_class("dim-label")
keys_box.append(plus)
card.append(keys_box)
# 2. "ACTIONS" Label
actions_header = Gtk.Label(label="ACTIONS")
actions_header.set_xalign(0.0)
actions_header.add_css_class("nm-binding-actions-label")
actions_header.set_margin_top(12)
card.append(actions_header)
# 3. Action Name
action_lbl = Gtk.Label(label=full_action)
action_lbl.set_xalign(0.0)
action_lbl.set_ellipsize(3)
action_lbl.add_css_class("nm-binding-action-name")
card.append(action_lbl)
# Spacer to push action buttons to the bottom
spacer = Gtk.Box()
spacer.set_vexpand(True)
card.append(spacer)
bottom_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
bottom_row.set_halign(Gtk.Align.END)
if b.get("allow_when_locked"):
lock = Gtk.Label(label="🔒")
lock.set_opacity(0.6)
bottom_row.append(lock)
edit_btn = Gtk.Button(icon_name="document-edit-symbolic")
edit_btn.add_css_class("flat")
edit_btn.add_css_class("circular")
edit_btn.connect("clicked", lambda *_, i=idx: self._on_edit_clicked(i))
bottom_row.append(edit_btn)
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
del_btn.add_css_class("flat")
del_btn.add_css_class("circular")
del_btn.add_css_class("error")
del_btn.connect("clicked", lambda *_, i=idx: self._on_delete_clicked(i))
bottom_row.append(del_btn)
card.append(bottom_row)
return card
def _on_filter_changed(self, entry):
self._search_query = entry.get_text().strip()
self._rebuild_list()
def _on_kb_search_changed(self, entry):
self._kb_search_query = entry.get_text().strip()
if self._viz:
self._viz.set_search(self._kb_search_query)
def _on_kb_key_selected(self, viz, key_id: str):
pass
def _on_kb_edit_binding(self, viz, bind_dict):
try:
idx = self._binds.index(bind_dict)
self._show_bind_dialog(bind_dict, idx)
except ValueError:
pass
def _on_kb_delete_binding(self, viz, bind_dict):
try:
idx = self._binds.index(bind_dict)
self._on_delete_clicked(idx)
except ValueError:
pass
def _on_kb_add_binding(self, viz, key_id: str):
if len(key_id) == 1:
display_key = key_id.upper()
else:
display_key = key_id.capitalize()
new_bind = {
"keysym": f"Mod+{display_key}",
"action": "",
"action_args": [],
"allow_when_locked": False,
"repeat": True,
"extra_props": {}
}
self._show_bind_dialog(new_bind, -1)
def _on_delete_clicked(self, idx: int):
if 0 <= idx < len(self._binds):
del self._binds[idx]
self._save_binds()
self._rebuild_list()
self._refresh_visualizer()
def _on_add_clicked(self, *_):
self._show_bind_dialog(None, -1)
def _on_edit_clicked(self, idx: int):
if 0 <= idx < len(self._binds):
self._show_bind_dialog(self._binds[idx], idx)
def _show_bind_dialog(self, bind: dict | None, idx: int):
dialog = Adw.Dialog(title="Edit Binding" if bind else "Add Binding")
dialog.set_content_width(440)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
header = Adw.HeaderBar()
header.set_title_widget(Adw.WindowTitle(title=dialog.get_title()))
box.append(header)
prefs = Adw.PreferencesPage()
prefs.set_vexpand(True)
# Keysym group
keys_grp = Adw.PreferencesGroup(title="Key Combination")
mod_row = Adw.ActionRow(title="Modifiers")
mod_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
mod_box.set_valign(Gtk.Align.CENTER)
mod_checks: dict[str, Gtk.CheckButton] = {}
cur_keysym = bind["keysym"] if bind else ""
keysym_parts_lower = [p.lower() for p in cur_keysym.split("+")[:-1]]
for mod in MODIFIERS:
cb = Gtk.CheckButton(label=mod)
cb.set_active(mod.lower() in keysym_parts_lower)
mod_box.append(cb)
mod_checks[mod] = cb
mod_row.add_suffix(mod_box)
keys_grp.add(mod_row)
key_entry = Adw.EntryRow(title="Key (e.g. T, F1, Return)")
bare = cur_keysym.split("+")[-1] if bind else ""
key_entry.set_text(bare)
keys_grp.add(key_entry)
prefs.add(keys_grp)
# Action group
act_grp = Adw.PreferencesGroup(title="Action")
act_model = Gtk.StringList.new(NIRI_ACTIONS)
act_combo = Adw.ComboRow(title="Action", model=act_model)
cur_action = bind["action"] if bind else ""
if cur_action in NIRI_ACTIONS:
act_combo.set_selected(NIRI_ACTIONS.index(cur_action))
act_grp.add(act_combo)
arg_row = Adw.EntryRow(title="Argument (for spawn, focus-workspace, etc.)")
cur_args = (bind.get("action_args") or []) if bind else []
arg_row.set_text(" ".join(str(a) for a in cur_args) if cur_args else "")
act_grp.add(arg_row)
prefs.add(act_grp)
# Options
opt_grp = Adw.PreferencesGroup(title="Options")
locked_row = Adw.SwitchRow(title="Allow When Locked")
locked_row.set_active(bind["allow_when_locked"] if bind else False)
opt_grp.add(locked_row)
repeat_row = Adw.SwitchRow(title="Repeat")
repeat_row.set_active(bind["repeat"] if bind else True)
opt_grp.add(repeat_row)
prefs.add(opt_grp)
box.append(prefs)
save_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
save_row.set_halign(Gtk.Align.END)
save_row.set_margin_start(16)
save_row.set_margin_end(16)
save_row.set_margin_top(8)
save_row.set_margin_bottom(16)
save_btn = Gtk.Button(label="Save")
save_btn.add_css_class("suggested-action")
save_btn.add_css_class("pill")
def _do_save(*_):
mods = [m for m, cb in mod_checks.items() if cb.get_active()]
key = key_entry.get_text().strip()
if not key:
return
keysym = "+".join(mods + [key])
action_idx = act_combo.get_selected()
action = NIRI_ACTIONS[action_idx] if action_idx < len(NIRI_ACTIONS) else ""
arg_text = arg_row.get_text().strip()
if action == "spawn-sh":
new_args = [arg_text] if arg_text else []
else:
import shlex
try:
new_args = shlex.split(arg_text) if arg_text else []
except ValueError:
new_args = arg_text.split() if arg_text else []
new_bind = _make_bind(
keysym,
action,
new_args,
locked_row.get_active(),
repeat_row.get_active(),
bind.get("extra_props", {}) if bind else {},
node=bind.get("_node") if bind else None,
)
if idx >= 0:
self._binds[idx] = new_bind
else:
self._binds.append(new_bind)
self._save_binds()
self._rebuild_list()
self._refresh_visualizer()
dialog.close()
save_btn.connect("clicked", _do_save)
save_row.append(save_btn)
box.append(save_row)
dialog.set_child(box)
dialog.present(self._win)
def _save_binds(self):
nodes = self._nodes
binds_node = next((n for n in nodes if n.name == "binds"), None)
if binds_node is None:
binds_node = KdlNode("binds")
nodes.append(binds_node)
_write_binds_to_node(self._binds, binds_node)
self._commit("keybindings")
# File monitor (live-sync)
def _start_file_monitor(self):
try:
from nirimod.kdl_parser import NIRI_CONFIG
gfile = Gio.File.new_for_path(str(NIRI_CONFIG))
monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None)
monitor.connect("changed", self._on_config_file_changed)
self._file_monitor = monitor
except Exception:
pass
def _on_config_file_changed(self, monitor, file, other_file, event_type):
if event_type in (Gio.FileMonitorEvent.CHANGED, Gio.FileMonitorEvent.CREATED):
GLib.timeout_add(400, self._reload_from_disk)
def _reload_from_disk(self):
self._win.notify_nodes_changed()
return False # don't repeat

View File

@@ -0,0 +1,161 @@
"""Environment Variables page."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, Gtk
from nirimod.kdl_parser import KdlNode, find_or_create
from nirimod.pages.base import BasePage
class EnvironmentPage(BasePage):
def build(self) -> Gtk.Widget:
tb, header, _, content = self._make_toolbar_page("Environment")
self._content = content
# Add button has been moved to the page body for better visibility
self.refresh()
return tb
def refresh(self):
self._rebuild()
def _get_env_node(self) -> KdlNode:
return find_or_create(self._nodes, "environment")
def _rebuild(self):
# Clear existing content
while True:
child = self._content.get_first_child()
if child is None:
break
self._content.remove(child)
env = self._get_env_node()
entries = list(env.children)
if not entries:
status = Adw.StatusPage(
title="No Environment Variables",
description="Variables set here will apply to niri and all processes it spawns.",
icon_name="preferences-system-symbolic",
)
add_btn = Gtk.Button(label="Add Variable")
add_btn.add_css_class("pill")
add_btn.add_css_class("suggested-action")
add_btn.set_halign(Gtk.Align.CENTER)
add_btn.connect("clicked", self._on_add)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
box.set_valign(Gtk.Align.CENTER)
box.set_vexpand(True)
box.append(status)
box.append(add_btn)
self._content.append(box)
else:
grp = Adw.PreferencesGroup(
title="Environment Variables",
description=f"{len(entries)} variable{'s' if len(entries) != 1 else ''} configured",
)
for i, child in enumerate(entries):
row = self._make_row(child, i)
grp.add(row)
self._content.append(grp)
# Convenient button at the bottom
add_btn = Gtk.Button(label="Add Another Variable")
add_btn.add_css_class("pill")
add_btn.set_halign(Gtk.Align.CENTER)
add_btn.set_margin_top(16)
add_btn.connect("clicked", self._on_add)
self._content.append(add_btn)
def _make_row(self, node: KdlNode, idx: int) -> Adw.ActionRow:
key = node.name
val = node.args[0] if node.args else ""
# Make key bold and distinct
key_str = GLib.markup_escape_text(key)
val_str = GLib.markup_escape_text(str(val))
row = Adw.ActionRow(
title=f"<b>{key_str}</b>",
subtitle=val_str if val_str else "(empty)",
)
row.set_use_markup(True)
edit_btn = Gtk.Button(icon_name="document-edit-symbolic")
edit_btn.set_valign(Gtk.Align.CENTER)
edit_btn.add_css_class("flat")
edit_btn.connect("clicked", lambda *_, i=idx: self._on_edit(i))
row.add_suffix(edit_btn)
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.connect("clicked", lambda *_, i=idx: self._on_delete(i))
row.add_suffix(del_btn)
return row
def _on_add(self, *_):
self._show_dialog(None, -1)
def _on_edit(self, idx: int):
env = self._get_env_node()
if 0 <= idx < len(env.children):
self._show_dialog(env.children[idx], idx)
def _on_delete(self, idx: int):
env = self._get_env_node()
if 0 <= idx < len(env.children):
env.children.pop(idx)
self._commit("remove env var")
self._rebuild()
def _show_dialog(self, node: KdlNode | None, idx: int):
dialog = Adw.AlertDialog(
heading="Environment Variable", body="Set a key=value environment variable."
)
key_entry = Adw.EntryRow(title="Variable Name (e.g. QT_QPA_PLATFORM)")
val_entry = Adw.EntryRow(title="Value (e.g. wayland)")
if node:
key_entry.set_text(node.name)
key_entry.set_editable(False) # editing key means replacing the node
val_entry.set_text(str(node.args[0]) if node.args else "")
grp = Adw.PreferencesGroup()
grp.add(key_entry)
grp.add(val_entry)
dialog.set_extra_child(grp)
dialog.add_response("cancel", "Cancel")
dialog.add_response("save", "Save")
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
def _on_resp(d, r):
if r != "save":
return
key = key_entry.get_text().strip()
val = val_entry.get_text()
if not key:
return
env = self._get_env_node()
new_node = KdlNode(key, args=[val])
if idx >= 0 and 0 <= idx < len(env.children):
env.children[idx] = new_node
else:
env.children.append(new_node)
self._commit("env var")
self._rebuild()
dialog.connect("response", _on_resp)
dialog.present(self._win)

200
nirimod/pages/gestures.py Normal file
View File

@@ -0,0 +1,200 @@
"""Gestures & Miscellaneous settings page."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk
from nirimod.kdl_parser import (
KdlNode,
find_or_create,
set_node_flag,
safe_switch_connect,
)
from nirimod.pages.base import BasePage
_CORNERS = [
("top-left", "Top-Left", "Moves cursor to the top-left corner"),
("top-right", "Top-Right", "Moves cursor to the top-right corner"),
("bottom-left", "Bottom-Left", "Moves cursor to the bottom-left corner"),
("bottom-right", "Bottom-Right", "Moves cursor to the bottom-right corner"),
]
class GesturesPage(BasePage):
def build(self) -> Gtk.Widget:
tb, _, _, content = self._make_toolbar_page("Gestures & Misc")
self._content = content
self._build_content()
return tb
def _build_content(self):
content = self._content
nodes = self._nodes
# ── Hot Corners ───────────────────────────────────────────────────────
hc_grp = Adw.PreferencesGroup(
title="Hot Corners",
description="Trigger the overview when the cursor touches a screen corner (niri ≥ 25.05)",
)
gestures_node = next((n for n in nodes if n.name == "gestures"), None)
hc_node = gestures_node.get_child("hot-corners") if gestures_node else None
hc_off = hc_node is not None and hc_node.get_child("off") is not None
hc_enabled = not hc_off
# Which individual corners are active
active_corners: set[str] = set()
if hc_node and not hc_off:
for corner_key, _, _ in _CORNERS:
if hc_node.get_child(corner_key) is not None:
active_corners.add(corner_key)
# ExpanderRow = the enable/disable switch + collapsible corner list
hc_expander = Adw.ExpanderRow(
title="Enable Hot Corners",
subtitle="Expand to choose which corners are active (default: top-left)",
)
hc_expander.set_expanded(hc_enabled)
hc_expander.set_show_enable_switch(True)
hc_expander.set_enable_expansion(hc_enabled)
# Per-corner rows nested inside the expander
corner_rows: dict[str, Adw.SwitchRow] = {}
for corner_key, corner_label, corner_subtitle in _CORNERS:
sr = Adw.SwitchRow(title=corner_label, subtitle=corner_subtitle)
is_active = corner_key in active_corners
sr.set_active(is_active)
safe_switch_connect(
sr, is_active,
lambda enabled, k=corner_key: self._set_corner(k, enabled),
)
hc_expander.add_row(sr)
corner_rows[corner_key] = sr
# Wire the expander's enable-switch to the hot corners on/off mutation
hc_expander._last_enabled = hc_enabled
def _on_expander_toggled(expander, _param):
val = expander.get_enable_expansion()
if val != getattr(expander, "_last_enabled", None):
expander._last_enabled = val
self._set_hot_corners(val)
hc_expander.connect("notify::enable-expansion", _on_expander_toggled)
hc_grp.add(hc_expander)
content.append(hc_grp)
# ── Hotkey Overlay ────────────────────────────────────────────────────
hko_grp = Adw.PreferencesGroup(title="Hotkey Overlay")
hko_node = next((n for n in nodes if n.name == "hotkey-overlay"), None)
skip_initial = (
hko_node is not None and hko_node.get_child("skip-at-startup") is not None
)
skip_row = Adw.SwitchRow(
title="Skip at Startup",
subtitle="Don't show the hotkey overlay when niri starts",
)
skip_row.set_active(skip_initial)
safe_switch_connect(skip_row, skip_initial, self._set_skip_hotkey_overlay)
hko_grp.add(skip_row)
content.append(hko_grp)
# ── Screenshots ───────────────────────────────────────────────────────
ss_grp = Adw.PreferencesGroup(
title="Screenshots", description="Path template for saved screenshots"
)
cur_path = next(
(n.args[0] for n in nodes if n.name == "screenshot-path" and n.args),
"~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png",
)
path_row = Adw.EntryRow(title="Save Path (strftime format)")
path_row.set_text(str(cur_path))
path_row.set_show_apply_button(True)
path_row.connect("apply", lambda r: self._set_screenshot_path(r.get_text()))
ss_grp.add(path_row)
content.append(ss_grp)
# ── Overview ──────────────────────────────────────────────────────────
ov_grp = Adw.PreferencesGroup(title="Overview")
ov_node = next((n for n in nodes if n.name == "overview"), None)
ws_shadow_node = ov_node.get_child("workspace-shadow") if ov_node else None
ws_shadow_initial = (
ws_shadow_node is None or ws_shadow_node.get_child("off") is None
)
ws_shadow_row = Adw.SwitchRow(
title="Workspace Shadow in Overview",
subtitle="Show drop shadows under workspaces in overview mode",
)
ws_shadow_row.set_active(ws_shadow_initial)
safe_switch_connect(
ws_shadow_row, ws_shadow_initial, self._set_overview_ws_shadow
)
ov_grp.add(ws_shadow_row)
content.append(ov_grp)
# ── Mutation methods ──────────────────────────────────────────────────────
def _get_hot_corners_node(self) -> KdlNode:
gestures = find_or_create(self._nodes, "gestures")
hc = gestures.get_child("hot-corners")
if hc is None:
hc = KdlNode("hot-corners")
hc.leading_trivia = "\n"
gestures.children.append(hc)
return hc
def _set_hot_corners(self, enabled: bool):
hc = self._get_hot_corners_node()
set_node_flag(hc, "off", not enabled)
self._commit("gestures hot-corners")
def _set_corner(self, corner_key: str, enabled: bool):
"""Enable or disable an individual hot corner (niri ≥ 25.11)."""
hc = self._get_hot_corners_node()
# Remove 'off' if it exists — enabling a corner implicitly enables hot corners
set_node_flag(hc, "off", False)
set_node_flag(hc, corner_key, enabled)
self._commit(f"hot-corner {corner_key}")
def _set_skip_hotkey_overlay(self, skip: bool):
nodes = self._nodes
hko = next((n for n in nodes if n.name == "hotkey-overlay"), None)
if hko is None:
hko = KdlNode("hotkey-overlay")
nodes.append(hko)
set_node_flag(hko, "skip-at-startup", skip)
self._commit("hotkey-overlay skip-at-startup")
def _set_screenshot_path(self, path: str):
nodes = self._nodes
existing = next((n for n in nodes if n.name == "screenshot-path"), None)
if path.strip():
if existing:
existing.args = [path.strip()]
else:
nodes.append(KdlNode("screenshot-path", args=[path.strip()]))
elif existing:
nodes.remove(existing)
self._commit("screenshot-path")
def _set_overview_ws_shadow(self, enabled: bool):
ov = find_or_create(self._nodes, "overview")
ws_shadow = ov.get_child("workspace-shadow")
if ws_shadow is None:
ws_shadow = KdlNode("workspace-shadow")
ov.children.append(ws_shadow)
set_node_flag(ws_shadow, "off", not enabled)
self._commit("overview workspace-shadow")
def refresh(self):
for child in list(self._content):
self._content.remove(child)
self._build_content()

380
nirimod/pages/input_page.py Normal file
View File

@@ -0,0 +1,380 @@
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk
from nirimod import niri_ipc
from nirimod.kdl_parser import (
KdlNode,
find_or_create,
set_child_arg,
set_node_flag,
safe_switch_connect,
)
from nirimod.pages.base import BasePage
ACCEL_PROFILES = ["default", "flat", "adaptive"]
SCROLL_METHODS_TP = ["two-finger", "edge", "on-button-down", "no-scroll"]
CLICK_METHODS = ["button-areas", "clickfinger"]
class InputPage(BasePage):
def build(self) -> Gtk.Widget:
tb, _, _, content = self._make_toolbar_page("Input")
self._content = content
self._build_content()
return tb
def _build_content(self):
content = self._content
nodes = self._nodes
kb_expander = Adw.ExpanderRow(title="Keyboard", subtitle="XKB options &amp; key repeat")
kb_expander.add_css_class("nm-expander")
kb_node = find_or_create(nodes, "input", "keyboard")
xkb_node = kb_node.get_child("xkb") or KdlNode("xkb")
fields = [
("layout", "Layout", "e.g. us,ru"),
("variant", "Variant", "e.g. dvorak"),
("model", "Model", ""),
("options", "Options", "e.g. grp:win_space_toggle"),
("rules", "Rules", ""),
]
self._xkb_entries: dict[str, Adw.EntryRow] = {}
for key, title, ph in fields:
row = Adw.EntryRow(title=title)
row.set_show_apply_button(True)
val = xkb_node.child_arg(key) if xkb_node else None
if val:
row.set_text(str(val))
row.set_input_purpose(Gtk.InputPurpose.FREE_FORM)
row.connect("apply", lambda r, k=key: self._set_xkb(k, r.get_text()))
kb_expander.add_row(row)
self._xkb_entries[key] = row
delay_adj = Gtk.Adjustment(
value=kb_node.child_arg("repeat-delay") or 600,
lower=100, upper=3000, step_increment=50,
)
delay_row = Adw.SpinRow(title="Repeat Delay (ms)", adjustment=delay_adj, digits=0)
delay_row.connect("notify::value", lambda r, _: self._set_kb("repeat-delay", int(r.get_value())))
kb_expander.add_row(delay_row)
rate_adj = Gtk.Adjustment(
value=kb_node.child_arg("repeat-rate") or 25,
lower=1, upper=200, step_increment=1,
)
rate_row = Adw.SpinRow(title="Repeat Rate (keys/sec)", adjustment=rate_adj, digits=0)
rate_row.connect("notify::value", lambda r, _: self._set_kb("repeat-rate", int(r.get_value())))
kb_expander.add_row(rate_row)
numlock_row = Adw.SwitchRow(title="Enable Num Lock on Startup")
nl_init = kb_node.get_child("numlock") is not None
numlock_row.set_active(nl_init)
safe_switch_connect(numlock_row, nl_init, self._toggle_numlock)
kb_expander.add_row(numlock_row)
kb_grp = Adw.PreferencesGroup()
kb_grp.add(kb_expander)
content.append(kb_grp)
# focus / pointer
focus_grp = Adw.PreferencesGroup(title="Pointer Behavior")
input_node = find_or_create(nodes, "input")
ffm_row = Adw.SwitchRow(title="Focus Follows Mouse")
ffm_node = input_node.get_child("focus-follows-mouse")
ffm_row._last_active = ffm_node is not None
ffm_row.set_active(ffm_node is not None)
def _on_ffm_toggled(r, _):
new_val = r.get_active()
if new_val != getattr(r, "_last_active", None):
r._last_active = new_val
self._toggle_ffm(new_val)
ffm_row.connect("notify::active", _on_ffm_toggled)
focus_grp.add(ffm_row)
scroll_val = 33
if ffm_node:
vRaw = ffm_node.props.get("max-scroll-amount")
if vRaw is not None:
try:
scroll_val = int(float(str(vRaw).replace("%", "").strip()))
except ValueError:
pass
self._last_scroll_val = scroll_val
scroll_adj = Gtk.Adjustment(value=scroll_val, lower=0, upper=100, step_increment=1)
scroll_pct_row = Adw.SpinRow(
title="Max Scroll Amount (%)", subtitle="0% = only fully visible windows",
adjustment=scroll_adj, digits=0,
)
scroll_pct_row.set_sensitive(ffm_node is not None)
self._scroll_pct_row = scroll_pct_row
scroll_pct_row._last_val = scroll_val
def _on_scroll_pct_changed(r, _):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_ffm_scroll(new_val)
scroll_pct_row.connect("notify::value", _on_scroll_pct_changed)
focus_grp.add(scroll_pct_row)
warp_init = input_node.get_child("warp-mouse-to-focus") is not None
warp_row = Adw.SwitchRow(title="Warp Mouse to Focus")
warp_row.set_active(warp_init)
safe_switch_connect(warp_row, warp_init,
lambda enabled: self._toggle_input_flag("warp-mouse-to-focus", enabled))
focus_grp.add(warp_row)
content.append(focus_grp)
# touchpad
tp_expander = Adw.ExpanderRow(title="Touchpad")
tp_expander.add_css_class("nm-expander")
has_tp = niri_ipc.has_touchpad()
if not has_tp:
tp_expander.set_subtitle("No touchpad detected")
tp_expander.set_sensitive(False)
tp_node = find_or_create(nodes, "input", "touchpad")
def tp_switch(key, label, subtitle=""):
r = Adw.SwitchRow(title=label, subtitle=subtitle)
ini = tp_node.get_child(key) is not None
r.set_active(ini)
safe_switch_connect(r, ini, lambda enabled, k=key: self._set_tp_flag(k, enabled))
return r
def tp_bool_switch(key, label, default_active=True, subtitle=""):
r = Adw.SwitchRow(title=label, subtitle=subtitle)
node = tp_node.get_child(key)
if node is not None and node.args:
ini = bool(node.args[0])
else:
ini = default_active
r.set_active(ini)
safe_switch_connect(r, ini, lambda enabled, k=key: self._set_tp(k, enabled))
return r
tp_expander.add_row(tp_switch("tap", "Tap to Click"))
tp_expander.add_row(tp_switch("dwt", "Disable While Typing"))
tp_expander.add_row(tp_switch("dwtp", "Disable While Trackpointing"))
tp_expander.add_row(tp_switch("natural-scroll", "Natural Scroll"))
tp_expander.add_row(tp_bool_switch("drag", "Tap Drag"))
tp_expander.add_row(tp_switch("drag-lock", "Tap Drag Lock"))
tp_expander.add_row(tp_switch("disabled-on-external-mouse", "Disable on External Mouse"))
spd_adj = Gtk.Adjustment(value=float(tp_node.child_arg("accel-speed") or 0.0),
lower=-1.0, upper=1.0, step_increment=0.05)
spd_row = Adw.SpinRow(title="Accel Speed", adjustment=spd_adj, digits=2)
spd_row.connect("notify::value", lambda r, _: self._set_tp("accel-speed", r.get_value()))
tp_expander.add_row(spd_row)
ap_model = Gtk.StringList.new(ACCEL_PROFILES)
ap_row = Adw.ComboRow(title="Accel Profile", model=ap_model)
cur_ap = tp_node.child_arg("accel-profile") or "default"
if cur_ap in ACCEL_PROFILES:
ap_row.set_selected(ACCEL_PROFILES.index(cur_ap))
ap_row.connect("notify::selected",
lambda r, _: self._set_tp("accel-profile", ACCEL_PROFILES[r.get_selected()]))
tp_expander.add_row(ap_row)
sm_model = Gtk.StringList.new(SCROLL_METHODS_TP)
sm_row = Adw.ComboRow(title="Scroll Method", model=sm_model)
cur_sm = tp_node.child_arg("scroll-method") or "two-finger"
if cur_sm in SCROLL_METHODS_TP:
sm_row.set_selected(SCROLL_METHODS_TP.index(cur_sm))
sm_row.connect("notify::selected",
lambda r, _: self._set_tp("scroll-method", SCROLL_METHODS_TP[r.get_selected()]))
tp_expander.add_row(sm_row)
cm_model = Gtk.StringList.new(CLICK_METHODS)
cm_row = Adw.ComboRow(title="Click Method", model=cm_model)
cur_cm = tp_node.child_arg("click-method") or "button-areas"
if cur_cm in CLICK_METHODS:
cm_row.set_selected(CLICK_METHODS.index(cur_cm))
cm_row.connect("notify::selected",
lambda r, _: self._set_tp("click-method", CLICK_METHODS[r.get_selected()]))
tp_expander.add_row(cm_row)
tp_grp = Adw.PreferencesGroup()
tp_grp.add(tp_expander)
content.append(tp_grp)
# mouse
m_expander = Adw.ExpanderRow(title="Mouse")
m_expander.add_css_class("nm-expander")
m_node = find_or_create(nodes, "input", "mouse")
m_nat = Adw.SwitchRow(title="Natural Scroll")
mn_init = m_node.get_child("natural-scroll") is not None
m_nat.set_active(mn_init)
safe_switch_connect(m_nat, mn_init, lambda enabled: self._set_m_flag("natural-scroll", enabled))
m_expander.add_row(m_nat)
m_spd_adj = Gtk.Adjustment(value=float(m_node.child_arg("accel-speed") or 0.0),
lower=-1.0, upper=1.0, step_increment=0.05)
m_spd_row = Adw.SpinRow(title="Accel Speed", adjustment=m_spd_adj, digits=2)
m_spd_row.connect("notify::value", lambda r, _: self._set_m("accel-speed", r.get_value()))
m_expander.add_row(m_spd_row)
m_ap_model = Gtk.StringList.new(ACCEL_PROFILES)
m_ap_row = Adw.ComboRow(title="Accel Profile", model=m_ap_model)
cur_m_ap = m_node.child_arg("accel-profile") or "default"
if cur_m_ap in ACCEL_PROFILES:
m_ap_row.set_selected(ACCEL_PROFILES.index(cur_m_ap))
m_ap_row.connect("notify::selected",
lambda r, _: self._set_m("accel-profile", ACCEL_PROFILES[r.get_selected()]))
m_expander.add_row(m_ap_row)
m_grp = Adw.PreferencesGroup()
m_grp.add(m_expander)
content.append(m_grp)
# cursor
cursor_grp = Adw.PreferencesGroup(title="Cursor")
cursor_node = next((n for n in nodes if n.name == "cursor"), None)
size_val = int(cursor_node.child_arg("xcursor-size") or 24) if cursor_node else 24
size_adj = Gtk.Adjustment(value=size_val, lower=8, upper=256, step_increment=2)
size_row = Adw.SpinRow(title="Cursor Size (px)", adjustment=size_adj, digits=0)
size_row.connect("notify::value",
lambda r, _: self._set_cursor("xcursor-size", int(r.get_value())))
cursor_grp.add(size_row)
hide_val = int(cursor_node.child_arg("hide-after-inactive-ms") or 0) if cursor_node else 0
hide_adj = Gtk.Adjustment(value=hide_val, lower=0, upper=60000, step_increment=500)
hide_row = Adw.SpinRow(title="Hide After Inactive (ms)", subtitle="0 = never hide",
adjustment=hide_adj, digits=0)
hide_row.connect("notify::value",
lambda r, _: self._set_cursor("hide-after-inactive-ms", int(r.get_value())))
cursor_grp.add(hide_row)
theme_val = str(cursor_node.child_arg("xcursor-theme") or "") if cursor_node else ""
theme_row = Adw.EntryRow(title="Cursor Theme (e.g. Adwaita)")
theme_row.set_text(theme_val)
theme_row.set_show_apply_button(True)
theme_row.connect("apply", lambda r: self._set_cursor_theme(r.get_text()))
cursor_grp.add(theme_row)
content.append(cursor_grp)
def _get_kb_node(self):
return find_or_create(self._nodes, "input", "keyboard")
def _get_xkb_node(self):
kb = self._get_kb_node()
xkb = kb.get_child("xkb")
if xkb is None:
xkb = KdlNode("xkb")
kb.children.insert(0, xkb)
return xkb
def _set_xkb(self, key: str, value: str):
xkb = self._get_xkb_node()
if value.strip():
set_child_arg(xkb, key, value.strip())
else:
from nirimod.kdl_parser import remove_child
remove_child(xkb, key)
self._commit(f"keyboard xkb {key}")
def _set_kb(self, key: str, value):
set_child_arg(self._get_kb_node(), key, value)
self._commit(f"keyboard {key}")
def _toggle_numlock(self, enabled: bool):
set_node_flag(self._get_kb_node(), "numlock", enabled)
self._commit("keyboard numlock")
def _get_input_node(self):
return find_or_create(self._nodes, "input")
def _toggle_ffm(self, enabled: bool):
inp = self._get_input_node()
existing = inp.get_child("focus-follows-mouse")
if enabled:
if existing is None:
new_ffm = KdlNode(name="focus-follows-mouse")
if hasattr(self, "_last_scroll_val"):
new_ffm.props["max-scroll-amount"] = f"{self._last_scroll_val}%"
inp.children.insert(0, new_ffm)
else:
if existing is not None:
inp.children.remove(existing)
if hasattr(self, "_scroll_pct_row"):
self._scroll_pct_row.set_sensitive(enabled)
self._commit("focus-follows-mouse")
def _set_ffm_scroll(self, pct: int):
inp = self._get_input_node()
ffm = inp.get_child("focus-follows-mouse")
if ffm is None:
ffm = KdlNode("focus-follows-mouse")
inp.children.append(ffm)
ffm.props["max-scroll-amount"] = f"{pct}%"
self._commit("ffm scroll amount")
def _toggle_input_flag(self, key: str, enabled: bool):
set_node_flag(self._get_input_node(), key, enabled)
self._commit(f"input {key}")
def _get_tp_node(self):
return find_or_create(self._nodes, "input", "touchpad")
def _set_tp_flag(self, key: str, enabled: bool):
set_node_flag(self._get_tp_node(), key, enabled)
self._commit(f"touchpad {key}")
def _set_tp(self, key: str, value):
set_child_arg(self._get_tp_node(), key, value)
self._commit(f"touchpad {key}")
def _get_m_node(self):
return find_or_create(self._nodes, "input", "mouse")
def _set_m_flag(self, key: str, enabled: bool):
set_node_flag(self._get_m_node(), key, enabled)
self._commit(f"mouse {key}")
def _set_m(self, key: str, value):
set_child_arg(self._get_m_node(), key, value)
self._commit(f"mouse {key}")
def _get_cursor_node(self):
existing = next((n for n in self._nodes if n.name == "cursor"), None)
if existing is None:
existing = KdlNode("cursor")
self._nodes.append(existing)
return existing
def _set_cursor(self, key: str, value):
set_child_arg(self._get_cursor_node(), key, value)
self._commit(f"cursor {key}")
def _set_cursor_theme(self, theme: str):
cur = self._get_cursor_node()
if theme.strip():
set_child_arg(cur, "xcursor-theme", theme.strip())
else:
from nirimod.kdl_parser import remove_child
remove_child(cur, "xcursor-theme")
self._commit("cursor xcursor-theme")
def refresh(self):
child = self._content.get_first_child()
while child:
next_child = child.get_next_sibling()
self._content.remove(child)
child = next_child
self._build_content()

284
nirimod/pages/layout.py Normal file
View File

@@ -0,0 +1,284 @@
"""Layout settings page."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk
from nirimod.kdl_parser import KdlNode, find_or_create, set_child_arg, remove_child
from nirimod.pages.base import BasePage
CENTER_OPTIONS = ["never", "always", "on-overflow"]
class LayoutPage(BasePage):
def build(self) -> Gtk.Widget:
tb, _, _, content = self._make_toolbar_page("Layout")
self._content = content
self._build_content()
return tb
def _build_content(self):
content = self._content
nodes = self._nodes
layout = find_or_create(nodes, "layout")
basic_grp = Adw.PreferencesGroup(title="General")
gaps_val = int(layout.child_arg("gaps") or 16)
gaps_adj = Gtk.Adjustment(value=gaps_val, lower=0, upper=200, step_increment=2)
gaps_row = Adw.SpinRow(title="Window Gaps (px)", adjustment=gaps_adj, digits=0)
gaps_row._last_val = gaps_val
def _on_gaps_changed(r, _):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_layout("gaps", new_val)
gaps_row.connect("notify::value", _on_gaps_changed)
basic_grp.add(gaps_row)
cfc_model = Gtk.StringList.new(CENTER_OPTIONS)
cfc_row = Adw.ComboRow(title="Center Focused Column", model=cfc_model)
cur_cfc = layout.child_arg("center-focused-column") or "never"
if cur_cfc in CENTER_OPTIONS:
cfc_row.set_selected(CENTER_OPTIONS.index(cur_cfc))
cfc_row.connect(
"notify::selected",
lambda r, _: self._set_layout(
"center-focused-column", CENTER_OPTIONS[r.get_selected()]
),
)
basic_grp.add(cfc_row)
prefer_csd_row = Adw.SwitchRow(
title="Prefer No CSD", subtitle="Ask apps to omit client-side decorations"
)
prefer_csd_row.set_active(any(n.name == "prefer-no-csd" for n in nodes))
prefer_csd_row.connect(
"notify::active",
lambda r, _: self._toggle_top("prefer-no-csd", r.get_active()),
)
basic_grp.add(prefer_csd_row)
bg_color_val = str(layout.child_arg("background-color") or "transparent")
bg_row = Adw.EntryRow(title="Background Color (e.g. transparent, #000000)")
bg_row.set_text(bg_color_val)
bg_row.set_show_apply_button(True)
bg_row.connect(
"apply",
lambda r: self._set_layout("background-color", r.get_text().strip()),
)
basic_grp.add(bg_row)
content.append(basic_grp)
dcw_grp = Adw.PreferencesGroup(title="Default Column Width")
dcw_node = layout.get_child("default-column-width")
prop_val = 0.5
fixed_val = 800
use_fixed = False
if dcw_node:
fc = dcw_node.get_child("fixed")
pc = dcw_node.get_child("proportion")
if fc and fc.args:
fixed_val = int(fc.args[0])
use_fixed = True
elif pc and pc.args:
prop_val = float(pc.args[0])
mode_model = Gtk.StringList.new(["Proportion", "Fixed (px)"])
mode_row = Adw.ComboRow(title="Mode", model=mode_model)
mode_row.set_selected(1 if use_fixed else 0)
dcw_grp.add(mode_row)
prop_adj = Gtk.Adjustment(value=prop_val, lower=0.05, upper=1.0, step_increment=0.05)
prop_spin = Gtk.SpinButton(adjustment=prop_adj, digits=2, climb_rate=1)
prop_spin.set_valign(Gtk.Align.CENTER)
prop_spin.connect("value-changed", lambda s: self._set_dcw_proportion(s.get_value()))
prop_row = Adw.ActionRow(title="Proportion")
prop_row.add_suffix(prop_spin)
prop_row.set_visible(not use_fixed)
dcw_grp.add(prop_row)
fixed_adj = Gtk.Adjustment(value=fixed_val, lower=100, upper=7680, step_increment=10)
fixed_spin = Gtk.SpinButton(adjustment=fixed_adj, digits=0, climb_rate=1)
fixed_spin.set_valign(Gtk.Align.CENTER)
fixed_spin.connect("value-changed", lambda s: self._set_dcw_fixed(int(s.get_value())))
fixed_row = Adw.ActionRow(title="Fixed Width (px)")
fixed_row.add_suffix(fixed_spin)
fixed_row.set_visible(use_fixed)
dcw_grp.add(fixed_row)
def _on_mode_changed(r, _):
is_fixed = r.get_selected() == 1
prop_row.set_visible(not is_fixed)
fixed_row.set_visible(is_fixed)
if is_fixed:
self._set_dcw_fixed(int(fixed_spin.get_value()))
else:
self._set_dcw_proportion(prop_spin.get_value())
mode_row.connect("notify::selected", _on_mode_changed)
content.append(dcw_grp)
pw_grp = Adw.PreferencesGroup(title="Preset Column Widths (proportions)")
pw_grp.set_description("Cycled through by Mod+R")
pcw_node = layout.get_child("preset-column-widths")
presets = []
if pcw_node:
for c in pcw_node.children:
if c.name == "proportion" and c.args:
presets.append(float(c.args[0]))
self._preset_spins: list[Gtk.SpinButton] = []
for val in presets or [0.333, 0.5, 0.667]:
self._add_preset_row(pw_grp, val)
add_preset_btn = Gtk.Button(label="Add Preset")
add_preset_btn.add_css_class("flat")
add_preset_btn.connect("clicked", lambda *_: self._add_preset_row(pw_grp, 0.5))
pw_grp.set_header_suffix(add_preset_btn)
content.append(pw_grp)
struts_grp = Adw.PreferencesGroup(title="Struts (outer gaps, px)")
struts_node = layout.get_child("struts")
for side in ["left", "right", "top", "bottom"]:
val = int(struts_node.child_arg(side) or 0) if struts_node else 0
adj = Gtk.Adjustment(value=val, lower=0, upper=500, step_increment=4)
row = Adw.SpinRow(title=side.capitalize(), adjustment=adj, digits=0)
row._last_val = val
def _on_strut_changed(r, _, s=side):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_strut(s, new_val)
row.connect("notify::value", _on_strut_changed)
struts_grp.add(row)
content.append(struts_grp)
def _add_preset_row(self, grp: Adw.PreferencesGroup, val: float):
spin_adj = Gtk.Adjustment(value=val, lower=0.05, upper=1.0, step_increment=0.05)
spin = Gtk.SpinButton(adjustment=spin_adj, digits=3, climb_rate=1)
spin.set_valign(Gtk.Align.CENTER)
self._preset_spins.append(spin)
row = Adw.ActionRow(title=f"Proportion {val:.3f}")
spin.connect(
"value-changed",
lambda s, r=row: (
r.set_title(f"Proportion {s.get_value():.3f}"),
self._save_presets(),
),
)
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")
def _on_delete(s=spin):
self._preset_spins.remove(s)
grp.remove(row)
self._save_presets()
del_btn.connect("clicked", lambda *_: _on_delete())
row.add_suffix(spin)
row.add_suffix(del_btn)
grp.add(row)
def _save_presets(self):
layout = find_or_create(self._nodes, "layout")
pcw = layout.get_child("preset-column-widths")
if pcw is None:
pcw = KdlNode("preset-column-widths")
layout.children.append(pcw)
new_children = []
for i, s in enumerate(self._preset_spins):
if i < len(pcw.children):
child = pcw.children[i]
child.name = "proportion"
child.args = [round(s.get_value(), 5)]
new_children.append(child)
else:
new_children.append(KdlNode("proportion", args=[round(s.get_value(), 5)]))
salvaged = ""
for i in range(len(self._preset_spins), len(pcw.children)):
salvaged += pcw.children[i].leading_trivia
if salvaged and new_children:
new_children[-1].trailing_trivia += salvaged
pcw.children = new_children
self._commit("preset column widths")
def _set_layout(self, key: str, value):
layout = find_or_create(self._nodes, "layout")
set_child_arg(layout, key, value)
self._commit(f"layout {key}")
def _set_dcw_proportion(self, val: float):
layout = find_or_create(self._nodes, "layout")
dcw = layout.get_child("default-column-width")
if dcw is None:
dcw = KdlNode("default-column-width")
layout.children.append(dcw)
dcw.children = [KdlNode("proportion", args=[round(val, 4)])]
self._commit("default column width proportion")
def _set_dcw_fixed(self, px: int):
layout = find_or_create(self._nodes, "layout")
dcw = layout.get_child("default-column-width")
if dcw is None:
dcw = KdlNode("default-column-width")
layout.children.append(dcw)
dcw.children = [KdlNode("fixed", args=[px])]
self._commit("default column width fixed")
def _set_strut(self, side: str, val: int):
layout = find_or_create(self._nodes, "layout")
struts = layout.get_child("struts")
if struts is None:
struts = KdlNode("struts")
layout.children.append(struts)
if val > 0:
set_child_arg(struts, side, val)
else:
remove_child(struts, side)
self._commit(f"strut {side}")
def _toggle_top(self, key: str, enabled: bool):
nodes = self._nodes
existing = next((n for n in reversed(nodes) if n.name == key), None)
app_state = self._win.app_state
if enabled and not existing:
cache = getattr(app_state, "_removed_top_nodes", {})
if key in cache:
idx, node = cache[key]
nodes.insert(min(idx, len(nodes)), node)
else:
nodes.append(KdlNode(key))
elif not enabled and existing:
if not hasattr(app_state, "_removed_top_nodes"):
app_state._removed_top_nodes = {}
app_state._removed_top_nodes[key] = (nodes.index(existing), existing)
nodes.remove(existing)
self._commit(f"toggle {key}")
def refresh(self):
for child in list(self._content):
self._content.remove(child)
self._build_content()

746
nirimod/pages/outputs.py Normal file
View File

@@ -0,0 +1,746 @@
"""Outputs / Monitors page with interactive canvas."""
from __future__ import annotations
from typing import TYPE_CHECKING
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk
from nirimod import niri_ipc
from nirimod.kdl_parser import KdlNode, set_child_arg, safe_switch_connect
from nirimod.pages.base import BasePage
if TYPE_CHECKING:
from nirimod.window import NiriModWindow
TRANSFORMS = [
"normal",
"90",
"180",
"270",
"flipped",
"flipped-90",
"flipped-180",
"flipped-270",
]
class OutputsPage(BasePage):
def __init__(self, window: "NiriModWindow"):
super().__init__(window)
self._outputs: list[dict] = []
self._current_out: dict | None = None
self._canvas: Gtk.DrawingArea | None = None
self._drag_output: str | None = None
self._drag_offset: tuple[float, float] = (0, 0)
def build(self) -> Gtk.Widget:
tb, header, scroll, content = self._make_toolbar_page("Outputs")
add_fake_btn = Gtk.Button(icon_name="list-add-symbolic")
add_fake_btn.set_tooltip_text("Add fake monitor for testing")
add_fake_btn.add_css_class("flat")
add_fake_btn.connect("clicked", lambda *_: self._add_fake_monitor())
header.pack_end(add_fake_btn)
refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic")
refresh_btn.set_tooltip_text("Reload outputs from niri")
refresh_btn.add_css_class("flat")
refresh_btn.connect("clicked", lambda *_: self.refresh())
header.pack_end(refresh_btn)
canvas_frame = Gtk.Frame()
canvas_frame.add_css_class("card")
canvas_frame.set_margin_bottom(8)
self._canvas = Gtk.DrawingArea()
self._canvas.set_content_height(350)
self._canvas.set_draw_func(self._draw_canvas)
canvas_frame.set_child(self._canvas)
content.append(canvas_frame)
drag = Gtk.GestureDrag()
drag.connect("drag-begin", self._on_drag_begin)
drag.connect("drag-update", self._on_drag_update)
drag.connect("drag-end", self._on_drag_end)
self._canvas.add_controller(drag)
click = Gtk.GestureClick()
click.connect("pressed", self._on_canvas_click)
self._canvas.add_controller(click)
self._out_combo = Adw.ComboRow(title="Monitor")
self._out_combo.connect("notify::selected", self._on_output_selected)
sel_group = Adw.PreferencesGroup()
sel_group.add(self._out_combo)
content.append(sel_group)
self._detail_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
content.append(self._detail_box)
self.refresh()
return tb
def refresh(self):
def _on_got_outputs(outputs):
self._outputs = outputs
names = [o.get("name", "?") for o in self._outputs]
model = Gtk.StringList.new(names)
self._out_combo.set_model(model)
if self._outputs:
self._load_output_detail(self._outputs[0])
if self._canvas:
self._canvas.queue_draw()
# Rebuild search index as the detail rows are now populated
if hasattr(self._win, "_build_search_index"):
self._win._build_search_index()
niri_ipc.get_outputs(_on_got_outputs)
def _add_fake_monitor(self):
idx = 1
while any(o.get("name") == f"fake-{idx}" for o in self._outputs):
idx += 1
name = f"fake-{idx}"
o = {
"name": name,
"modes": [{"width": 1920, "height": 1080, "refresh_rate": 60000}],
"current_mode": 0,
"logical": {
"x": 0,
"y": 0,
"width": 1920,
"height": 1080,
"scale": 1.0,
"transform": "normal",
},
}
self._outputs.append(o)
names = [out.get("name", "?") for out in self._outputs]
model = Gtk.StringList.new(names)
self._out_combo.set_model(model)
self._out_combo.set_selected(len(self._outputs) - 1)
if self._canvas:
self._canvas.queue_draw()
if hasattr(self._win, "_build_search_index"):
self._win._build_search_index()
# Canvas drawing
def _draw_canvas(self, area, cr, width, height):
if not self._outputs:
cr.set_source_rgba(0.05, 0.05, 0.05, 0.4)
cr.rectangle(0, 0, width, height)
cr.fill()
cr.set_source_rgba(0.5, 0.5, 0.5, 0.8)
cr.select_font_face("Sans", 0, 0)
cr.set_font_size(14)
cr.move_to(width / 2 - 80, height / 2)
cr.show_text("No outputs detected")
return
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
for o in self._outputs:
pos = o.get("logical", {})
lx = pos.get("x", 0)
ly = pos.get("y", 0)
lw = pos.get("width", 1920)
lh = pos.get("height", 1080)
min_x = min(min_x, lx)
min_y = min(min_y, ly)
max_x = max(max_x, lx + lw)
max_y = max(max_y, ly + lh)
if min_x == float("inf"):
min_x = min_y = 0
max_x = 1920
max_y = 1080
total_w = max_x - min_x
total_h = max_y - min_y
scale = min(width / max(total_w, 1), height / max(total_h, 1)) * 0.9
off_x = (width - total_w * scale) / 2 - min_x * scale
off_y = (height - total_h * scale) / 2 - min_y * scale
if self._drag_output and hasattr(self, "_drag_start_scale"):
scale = self._drag_start_scale
off_x, off_y = self._drag_start_offset
self._canvas_scale = scale
self._canvas_offset = (off_x, off_y)
self._canvas_pixel_w = width
self._canvas_pixel_h = height
# grid background
cr.set_source_rgba(1, 1, 1, 0.03)
cr.set_line_width(1)
grid_size = 40
for gx in range(0, int(width), grid_size):
cr.move_to(gx, 0)
cr.line_to(gx, height)
for gy in range(0, int(height), grid_size):
cr.move_to(0, gy)
cr.line_to(width, gy)
cr.stroke()
for i, o in enumerate(self._outputs):
pos = o.get("logical", {})
x = off_x + pos.get("x", 0) * scale
y = off_y + pos.get("y", 0) * scale
w = pos.get("width", 1920) * scale
h = pos.get("height", 1080) * scale
is_sel = o.get("name") == (
self._current_out.get("name") if self._current_out else None
)
if is_sel:
cr.set_source_rgba(155 / 255, 109 / 255, 1.0, 1.0)
else:
cr.set_source_rgba(0.2, 0.2, 0.2, 1.0)
cr.rectangle(x, y, w, h)
cr.fill_preserve()
# border
cr.set_line_width(1.5)
if is_sel:
cr.set_source_rgba(0.7, 0.7, 0.75, 0.9)
else:
cr.set_source_rgba(0.4, 0.4, 0.45, 0.6)
cr.stroke()
name = o.get("name", f"Output {i}")
mode_idx = o.get("current_mode")
modes = o.get("modes", [])
mode = (
modes[mode_idx]
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
else {}
)
out_scale = o.get("logical", {}).get("scale", 1.0)
res = f"{mode.get('width', '?')}×{mode.get('height', '?')}"
scale_text = f"Scale: {out_scale}x"
cr.set_source_rgba(1, 1, 1, 0.95 if is_sel else 0.7)
cr.select_font_face("Sans", 0, 1)
font_size = max(10, min(16, w / 10))
cr.set_font_size(font_size)
te = cr.text_extents(name)
cr.move_to(x + w / 2 - te.width / 2, y + h / 2 - font_size * 0.3)
cr.show_text(name)
cr.select_font_face("Sans", 0, 0)
res_size = max(8, min(12, w / 15))
cr.set_font_size(res_size)
te2 = cr.text_extents(res)
cr.move_to(x + w / 2 - te2.width / 2, y + h / 2 + res_size * 1.2)
cr.show_text(res)
cr.set_source_rgba(0.6, 0.6, 0.65, 0.9 if is_sel else 0.6)
scale_size = max(7, min(11, w / 18))
cr.set_font_size(scale_size)
te3 = cr.text_extents(scale_text)
cr.move_to(
x + w / 2 - te3.width / 2, y + h / 2 + res_size * 1.2 + scale_size * 1.4
)
cr.show_text(scale_text)
def _on_drag_begin(self, gesture, sx, sy):
if not hasattr(self, "_canvas_scale"):
return
scale = self._canvas_scale
ox, oy = self._canvas_offset
for o in reversed(self._outputs):
pos = o.get("logical", {})
x = ox + pos.get("x", 0) * scale
y = oy + pos.get("y", 0) * scale
w = pos.get("width", 1920) * scale
h = pos.get("height", 1080) * scale
if x <= sx <= x + w and y <= sy <= y + h:
self._drag_output = o["name"]
self._last_dx = 0
self._last_dy = 0
self._drag_current_lx = pos.get("x", 0)
self._drag_current_ly = pos.get("y", 0)
self._drag_start_scale = scale
self._drag_start_offset = (ox, oy)
return
def _on_drag_update(self, gesture, dx, dy):
if not self._drag_output or not hasattr(self, "_canvas_scale"):
return
scale = getattr(self, "_drag_start_scale", self._canvas_scale)
delta_dx = dx - getattr(self, "_last_dx", 0)
delta_dy = dy - getattr(self, "_last_dy", 0)
self._last_dx = dx
self._last_dy = dy
self._drag_current_lx += delta_dx / scale
self._drag_current_ly += delta_dy / scale
new_lx = self._drag_current_lx
new_ly = self._drag_current_ly
drag_o = next(
(o for o in self._outputs if o.get("name") == self._drag_output), None
)
if not drag_o:
return
monitor_scale = drag_o.get("logical", {}).get("scale", 1.0)
mode_idx = drag_o.get("current_mode")
modes = drag_o.get("modes", [])
mode = (
modes[mode_idx]
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
else {}
)
pixel_w = mode.get("width", 1920)
pixel_h = mode.get("height", 1080)
transform = str(drag_o.get("logical", {}).get("transform", "normal")).lower().replace("_", "-")
if transform in ["90", "270", "flipped-90", "flipped-270"]:
pixel_w, pixel_h = pixel_h, pixel_w
logical_w = pixel_w / monitor_scale
logical_h = pixel_h / monitor_scale
# edge snapping
SNAP_THRESHOLD = 30
snapped_x = new_lx
snapped_y = new_ly
closest_x = SNAP_THRESHOLD + 1
closest_y = SNAP_THRESHOLD + 1
dragged_left = new_lx
dragged_right = new_lx + logical_w
dragged_top = new_ly
dragged_bottom = new_ly + logical_h
for other in self._outputs:
if other.get("name") == self._drag_output:
continue
other_pos = other.get("logical", {})
other_x = other_pos.get("x", 0)
other_y = other_pos.get("y", 0)
other_scale = other_pos.get("scale", 1.0)
other_mode_idx = other.get("current_mode")
other_modes = other.get("modes", [])
other_mode = other_modes[other_mode_idx] if isinstance(other_mode_idx, int) and 0 <= other_mode_idx < len(other_modes) else {}
other_pixel_w = other_mode.get("width", 1920)
other_pixel_h = other_mode.get("height", 1080)
other_transform = str(other_pos.get("transform", "normal")).lower().replace("_", "-")
if other_transform in ["90", "270", "flipped-90", "flipped-270"]:
other_pixel_w, other_pixel_h = other_pixel_h, other_pixel_w
other_logical_w = other_pixel_w / other_scale
other_logical_h = other_pixel_h / other_scale
other_left = other_x
other_right = other_x + other_logical_w
other_top = other_y
other_bottom = other_y + other_logical_h
for dragged_edge, is_left_edge in [(dragged_left, True), (dragged_right, False)]:
for other_edge in [other_left, other_right]:
dist = abs(dragged_edge - other_edge)
if dist < closest_x:
closest_x = dist
snapped_x = other_edge if is_left_edge else other_edge - logical_w
for dragged_edge, is_top_edge in [(dragged_top, True), (dragged_bottom, False)]:
for other_edge in [other_top, other_bottom]:
dist = abs(dragged_edge - other_edge)
if dist < closest_y:
closest_y = dist
snapped_y = other_edge if is_top_edge else other_edge - logical_h
if closest_x <= SNAP_THRESHOLD:
new_lx = snapped_x
if closest_y <= SNAP_THRESHOLD:
new_ly = snapped_y
if "logical" not in drag_o:
drag_o["logical"] = {}
drag_o["logical"]["x"] = new_lx
drag_o["logical"]["y"] = new_ly
if self._canvas:
self._canvas.queue_draw()
def _on_drag_end(self, gesture, dx, dy):
if self._drag_output:
if self._canvas:
self._canvas.queue_draw()
for o in self._outputs:
self._apply_position(o["name"])
if self._current_out:
cur_pos = self._current_out.get("logical", {})
if hasattr(self, "_pos_x_adj"):
self._pos_x_adj.set_value(cur_pos.get("x", 0))
if hasattr(self, "_pos_y_adj"):
self._pos_y_adj.set_value(cur_pos.get("y", 0))
self._drag_output = None
def _on_canvas_click(self, gesture, n_press, x, y):
if not hasattr(self, "_canvas_scale"):
return
scale = self._canvas_scale
ox, oy = self._canvas_offset
for i, o in reversed(list(enumerate(self._outputs))):
pos = o.get("logical", {})
mx = ox + pos.get("x", 0) * scale
my = oy + pos.get("y", 0) * scale
mw = pos.get("width", 1920) * scale
mh = pos.get("height", 1080) * scale
if mx <= x <= mx + mw and my <= y <= my + mh:
self._out_combo.set_selected(i)
return
def _apply_position(self, name: str):
o = next((x for x in self._outputs if x["name"] == name), None)
if not o:
return
pos = o.get("logical", {})
nx = pos.get("x", 0)
ny = pos.get("y", 0)
out_node = self._get_or_create_out_node(name)
pos_node = out_node.get_child("position")
if pos_node is None:
pos_node = KdlNode(name="position")
out_node.children.append(pos_node)
pos_node.props["x"] = int(round(nx))
pos_node.props["y"] = int(round(ny))
if self._current_out and self._current_out.get("name") == name:
if hasattr(self, "_pos_x_adj"):
self._pos_x_adj.set_value(nx)
if hasattr(self, "_pos_y_adj"):
self._pos_y_adj.set_value(ny)
self._commit("output position")
def _on_output_selected(self, combo, _):
idx = combo.get_selected()
if 0 <= idx < len(self._outputs):
self._load_output_detail(self._outputs[idx])
# Rebuild search index as the detail rows have changed
if hasattr(self._win, "_build_search_index"):
self._win._build_search_index()
def _load_output_detail(self, output: dict):
self._current_out = output
for child in list(self._detail_box):
self._detail_box.remove(child)
name = output.get("name", "?")
nodes = self._nodes
out_node = next(
(n for n in nodes if n.name == "output" and n.args and n.args[0] == name),
None,
)
modes = output.get("modes", [])
mode_strs = [
f"{m.get('width', 0)}×{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}"
for m in modes
]
mode_model = Gtk.StringList.new(mode_strs)
mode_row = Adw.ComboRow(title="Resolution &amp; Refresh Rate")
mode_row.set_model(mode_model)
mode_idx = output.get("current_mode")
cur_mode = (
modes[mode_idx]
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
else {}
)
cur_str = f"{cur_mode.get('width', 0)}×{cur_mode.get('height', 0)}@{cur_mode.get('refresh_rate', 0) / 1000:.3f}"
if cur_str in mode_strs:
mode_row.set_selected(mode_strs.index(cur_str))
mode_row.connect(
"notify::selected",
lambda r, _: self._on_mode_changed(name, modes, r.get_selected()),
)
scale_val = round(output.get("logical", {}).get("scale", 1.0), 3)
scale_adj = Gtk.Adjustment(
value=scale_val,
lower=0.01,
upper=100.0,
step_increment=0.05,
)
scale_row = Adw.SpinRow(title="Scale", adjustment=scale_adj, digits=2)
scale_row.connect(
"notify::value",
lambda r, _: self._set_output_prop(name, "scale", r.get_value()),
)
t_model = Gtk.StringList.new(TRANSFORMS)
transform_row = Adw.ComboRow(title="Transform", model=t_model)
cur_t = output.get("logical", {}).get("transform", "normal")
cur_t_norm = str(cur_t).lower().replace("_", "-") if cur_t else "normal"
if cur_t_norm in TRANSFORMS:
transform_row.set_selected(TRANSFORMS.index(cur_t_norm))
transform_row.connect(
"notify::selected",
lambda r, _: self._set_output_prop(
name, "transform", TRANSFORMS[r.get_selected()]
),
)
px = output.get("logical", {}).get("x", 0)
py = output.get("logical", {}).get("y", 0)
px_adj = Gtk.Adjustment(value=px, lower=-1000000, upper=1000000, step_increment=1)
py_adj = Gtk.Adjustment(value=py, lower=-1000000, upper=1000000, step_increment=1)
self._pos_x_adj = px_adj
self._pos_y_adj = py_adj
pos_x_row = Adw.SpinRow(title="Position X", adjustment=px_adj, digits=0)
pos_y_row = Adw.SpinRow(title="Position Y", adjustment=py_adj, digits=0)
pos_x_row.connect(
"notify::value",
lambda r, _: self._set_output_pos(
name, int(r.get_value()), int(py_adj.get_value())
),
)
pos_y_row.connect(
"notify::value",
lambda r, _: self._set_output_pos(
name, int(px_adj.get_value()), int(r.get_value())
),
)
vrr_row = Adw.SwitchRow(title="Variable Refresh Rate (VRR)")
vrr_val = (
(out_node.get_child("variable-refresh-rate") is not None)
if out_node
else False
)
vrr_row.set_active(vrr_val)
safe_switch_connect(
vrr_row,
vrr_val,
lambda enabled: self._set_output_flag(
name, "variable-refresh-rate", enabled
),
)
off_row = Adw.SwitchRow(title="Disable Output")
off_val = (out_node.get_child("off") is not None) if out_node else False
off_row.set_active(off_val)
safe_switch_connect(
off_row,
off_val,
lambda enabled: self._set_output_flag(name, "off", enabled),
)
grp = Adw.PreferencesGroup(title=f"Output: {name}")
for r in [
mode_row,
scale_row,
transform_row,
pos_x_row,
pos_y_row,
vrr_row,
off_row,
]:
grp.add(r)
self._detail_box.append(grp)
if self._canvas:
self._canvas.queue_draw()
def _ensure_output_fields(self, out_node: KdlNode, name: str):
manual_out = None
try:
manual_nodes = self._nodes
if manual_nodes:
manual_out = next(
(
n
for n in manual_nodes
if n.name == "output" and n.args and n.args[0] == name
),
None,
)
except Exception:
pass
if manual_out:
if out_node.get_child("mode") is None:
m = manual_out.child_arg("mode")
if m:
set_child_arg(out_node, "mode", m)
if out_node.get_child("scale") is None:
s = manual_out.child_arg("scale")
if s is not None:
set_child_arg(out_node, "scale", s)
if out_node.get_child("transform") is None:
t = manual_out.child_arg("transform")
if t:
set_child_arg(out_node, "transform", t)
if out_node.get_child("position") is None:
pos_node = manual_out.get_child("position")
if pos_node:
new_pos = KdlNode(name="position", props=pos_node.props.copy())
out_node.children.append(new_pos)
o = next((x for x in self._outputs if x.get("name") == name), None)
if o:
if out_node.get_child("mode") is None:
mode_idx = o.get("current_mode")
modes = o.get("modes", [])
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes):
m = modes[mode_idx]
mode_str = f"{m.get('width', 0)}x{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}"
set_child_arg(out_node, "mode", mode_str)
if out_node.get_child("scale") is None:
set_child_arg(out_node, "scale", o.get("logical", {}).get("scale", 1.0))
if out_node.get_child("transform") is None:
t = o.get("logical", {}).get("transform", "normal")
t = str(t).lower().replace("_", "-") if t else "normal"
if t not in TRANSFORMS:
t = "normal"
set_child_arg(out_node, "transform", t)
pos_node = out_node.get_child("position")
if pos_node is None:
pos_node = KdlNode(name="position")
out_node.children.append(pos_node)
pos_node.props["x"] = o.get("logical", {}).get("x", 0)
pos_node.props["y"] = o.get("logical", {}).get("y", 0)
def _get_or_create_out_node(self, name: str) -> KdlNode:
nodes = self._nodes
out_node = next(
(n for n in nodes if n.name == "output" and n.args and n.args[0] == name),
None,
)
is_new = out_node is None
if out_node is None:
out_node = KdlNode(name="output", args=[name])
nodes.append(out_node)
assert out_node is not None
if is_new:
self._ensure_output_fields(out_node, name)
order = {"mode": 0, "scale": 1, "transform": 2, "position": 3}
out_node.children.sort(key=lambda c: order.get(c.name, 999))
return out_node
def _update_logical_dims(self, o: dict):
if "logical" not in o:
o["logical"] = {}
mode_idx = o.get("current_mode")
modes = o.get("modes", [])
m = (
modes[mode_idx]
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
else {}
)
pw = m.get("width", 1920)
ph = m.get("height", 1080)
scale = o["logical"].get("scale", 1.0)
if scale <= 0:
scale = 1.0
t = o["logical"].get("transform", "normal")
t_str = str(t).lower().replace("_", "-")
if t_str in ["90", "270", "flipped-90", "flipped-270"]:
pw, ph = ph, pw
o["logical"]["width"] = round(pw / scale)
o["logical"]["height"] = round(ph / scale)
def _on_mode_changed(self, name: str, modes: list, idx: int):
if not (0 <= idx < len(modes)):
return
m = modes[idx]
mode_str = f"{m.get('width', 0)}x{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}"
out_node = self._get_or_create_out_node(name)
set_child_arg(out_node, "mode", mode_str)
o = next((x for x in self._outputs if x.get("name") == name), None)
if o:
o["current_mode"] = idx
self._update_logical_dims(o)
self._commit("output mode")
if self._canvas:
self._canvas.queue_draw()
def _set_output_prop(self, name: str, prop: str, value):
if prop == "scale" and isinstance(value, float):
value = round(value, 3)
out_node = self._get_or_create_out_node(name)
set_child_arg(out_node, prop, value)
o = next((x for x in self._outputs if x.get("name") == name), None)
if o:
if "logical" not in o:
o["logical"] = {}
if prop == "scale":
o["logical"]["scale"] = value
elif prop == "transform":
o["logical"]["transform"] = value
self._update_logical_dims(o)
self._commit(f"output {prop}")
if self._canvas:
self._canvas.queue_draw()
def _set_output_pos(self, name: str, x: int, y: int):
out_node = self._get_or_create_out_node(name)
pos_node = out_node.get_child("position")
if pos_node is None:
pos_node = KdlNode(name="position")
out_node.children.append(pos_node)
pos_node.props["x"] = int(round(x))
pos_node.props["y"] = int(round(y))
o = next((out for out in self._outputs if out.get("name") == name), None)
if o:
if "logical" not in o:
o["logical"] = {}
o["logical"]["x"] = x
o["logical"]["y"] = y
self._commit("output position")
if self._canvas:
self._canvas.queue_draw()
def _set_output_flag(self, name: str, flag: str, enabled: bool):
from nirimod.kdl_parser import set_node_flag
out_node = self._get_or_create_out_node(name)
set_node_flag(out_node, flag, enabled)
self._commit(f"output {flag}")

322
nirimod/pages/raw_config.py Normal file
View File

@@ -0,0 +1,322 @@
"""Raw Config page — editable view of the full merged config."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Pango, GLib
from pathlib import Path
from nirimod import niri_ipc
from nirimod.kdl_parser import NIRI_CONFIG
from nirimod.pages.base import BasePage
class RawConfigPage(BasePage):
def build(self) -> Gtk.Widget:
tb, header, _, content = self._make_toolbar_page("Raw Config")
self._content = content
self._scroll_positions: dict[Path, tuple[float, float]] = {}
self._buffer_modified = False
self._original_text = ""
self._current_files: list[Path] = []
self._file_dropdown = Gtk.DropDown()
self._file_dropdown.set_valign(Gtk.Align.CENTER)
self._file_dropdown.connect("notify::selected-item", self._on_file_selected)
title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
title_box.set_halign(Gtk.Align.CENTER)
title_box.set_valign(Gtk.Align.CENTER)
title_label = Gtk.Label(label="Config File")
title_label.add_css_class("title")
title_box.append(title_label)
title_box.append(self._file_dropdown)
header.pack_start(title_box)
title_box.set_margin_start(12)
# Header actions
validate_btn = Gtk.Button(label="Validate")
validate_btn.add_css_class("suggested-action")
validate_btn.connect("clicked", self._on_validate)
header.pack_end(validate_btn)
self._save_btn = Gtk.Button(label="Save")
self._save_btn.add_css_class("suggested-action")
self._save_btn.set_tooltip_text("Save this file and reload niri (Ctrl+S)")
self._save_btn.connect("clicked", self._on_save_raw)
self._save_btn.set_sensitive(False)
header.pack_end(self._save_btn)
self._discard_btn = Gtk.Button(label="Discard")
self._discard_btn.add_css_class("destructive-action")
self._discard_btn.add_css_class("flat")
self._discard_btn.set_tooltip_text("Discard unsaved changes")
self._discard_btn.connect("clicked", self._on_discard_raw)
self._discard_btn.set_sensitive(False)
header.pack_end(self._discard_btn)
# Editor
self._textview = Gtk.TextView()
self._textview.set_editable(True)
self._textview.set_monospace(True)
self._textview.set_wrap_mode(Gtk.WrapMode.NONE)
self._textview.set_left_margin(16)
self._textview.set_right_margin(16)
self._textview.set_top_margin(16)
self._textview.set_bottom_margin(16)
self._textview.add_css_class("code-editor")
self._buf = self._textview.get_buffer()
self._buf.connect("changed", self._on_buffer_changed)
self._scroll = Gtk.ScrolledWindow()
self._scroll.add_css_class("card")
self._scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
self._scroll.set_vexpand(True)
self._scroll.set_hexpand(True)
self._scroll.set_child(self._textview)
content.append(self._scroll)
self.refresh()
return tb
# Scroll position helpers
def _save_scroll_position(self):
"""Persist the current scroll position for the active file."""
idx = self._file_dropdown.get_selected()
if idx == Gtk.INVALID_LIST_POSITION or idx >= len(self._current_files):
return
path = self._current_files[idx]
hadj = self._scroll.get_hadjustment()
vadj = self._scroll.get_vadjustment()
self._scroll_positions[path] = (hadj.get_value(), vadj.get_value())
def _restore_scroll_position(self, path: Path):
"""Restore the saved scroll position for a given file, if any."""
if path not in self._scroll_positions:
return
hval, vval = self._scroll_positions[path]
def _apply():
hadj = self._scroll.get_hadjustment()
vadj = self._scroll.get_vadjustment()
hadj.set_value(hval)
vadj.set_value(vval)
return False # don't repeat
# Defer one frame so the buffer is fully laid out before scrolling
GLib.idle_add(_apply)
# Page lifecycle
def on_shown(self):
"""Called every time the user navigates back to this page."""
# Restore scroll for whichever file is currently selected
idx = self._file_dropdown.get_selected()
if idx != Gtk.INVALID_LIST_POSITION and idx < len(self._current_files):
self._restore_scroll_position(self._current_files[idx])
def refresh(self):
state = self._win.app_state
if state.is_multi_file:
self._current_files = sorted(list(state.source_files))
if NIRI_CONFIG in self._current_files:
self._current_files.remove(NIRI_CONFIG)
self._current_files.insert(0, NIRI_CONFIG)
else:
self._current_files = [NIRI_CONFIG]
strings = [p.name for p in self._current_files]
self._file_dropdown.set_model(Gtk.StringList.new(strings))
self._load_selected_file()
def _reload_from_disk(self):
"""Re-read the file from disk, discarding any edits."""
self._load_selected_file(force=True)
# File loading
def _on_file_selected(self, dropdown, param):
self._save_scroll_position()
self._load_selected_file()
def _load_selected_file(self, force: bool = False):
idx = self._file_dropdown.get_selected()
if idx == Gtk.INVALID_LIST_POSITION or idx >= len(self._current_files):
return
if self._buffer_modified and not force:
self._confirm_discard_then(lambda: self._do_load_file(idx))
return
self._do_load_file(idx)
def _do_load_file(self, idx: int):
path = self._current_files[idx]
text = path.read_text() if path.exists() else f"// File not found: {path}"
self._buf.handler_block_by_func(self._on_buffer_changed)
self._buf.set_text(text)
self._original_text = text
self._apply_syntax_highlighting(self._buf, text)
self._buf.handler_unblock_by_func(self._on_buffer_changed)
self._set_modified(False)
self._restore_scroll_position(path)
# Buffer modification tracking
def _on_buffer_changed(self, buf):
text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False)
is_changed = (text != self._original_text)
if is_changed != self._buffer_modified:
self._set_modified(is_changed)
def _set_modified(self, modified: bool):
self._buffer_modified = modified
self._save_btn.set_sensitive(modified)
self._discard_btn.set_sensitive(modified)
# Save / Discard
def _on_save_raw(self, *_):
idx = self._file_dropdown.get_selected()
if idx == Gtk.INVALID_LIST_POSITION or idx >= len(self._current_files):
return
path = self._current_files[idx]
text = self._buf.get_text(self._buf.get_start_iter(), self._buf.get_end_iter(), False)
from nirimod import app_settings
if app_settings.get("auto_backup", True):
from nirimod.backup import backup_all_sources
limit = app_settings.get("backup_limit", 10)
backup_all_sources(self._win.app_state.source_files, limit=limit)
tmp = path.with_suffix(path.suffix + ".tmp")
try:
tmp.write_text(text)
except Exception as e:
self.show_toast(f"Write error: {e}", timeout=6)
return
self.show_toast("Validating…", timeout=2)
def _on_validated(result):
ok, msg = result
if not ok:
tmp.unlink(missing_ok=True)
self.show_toast(f"Validation error: {msg[:120]}", timeout=8)
return
try:
tmp.replace(path)
except Exception as e:
self.show_toast(f"Save error: {e}", timeout=6)
return
self._set_modified(False)
self._original_text = text
self._apply_syntax_highlighting(self._buf, text)
niri_ipc.run_in_thread(niri_ipc.load_config_file, self._on_reloaded)
niri_ipc.run_in_thread(
lambda: niri_ipc.validate_config(str(tmp)), _on_validated
)
def _on_reloaded(self, result):
ok, msg = result
if ok:
self.show_toast("Config saved and applied ✓", timeout=3)
else:
self.show_toast(f"Saved, but reload failed: {msg[:80]}", timeout=8)
self._win.app_state.reload_from_disk()
self._win._build_search_index()
def _on_discard_raw(self, *_):
self._confirm_discard_then(self._reload_from_disk)
def _confirm_discard_then(self, callback):
import gi
gi.require_version("Adw", "1")
from gi.repository import Adw
dialog = Adw.AlertDialog(
heading="Discard changes?",
body="Your unsaved edits to this file will be lost.",
)
dialog.add_response("cancel", "Cancel")
dialog.add_response("discard", "Discard")
dialog.set_response_appearance("discard", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
def _on_response(dlg, response):
if response == "discard":
self._set_modified(False)
callback()
dialog.connect("response", _on_response)
dialog.present(self._win)
# Syntax highlighting
def _apply_syntax_highlighting(self, buf: Gtk.TextBuffer, text: str):
tag_table = buf.get_tag_table()
def _get_or_create_tag(name, **props):
t = tag_table.lookup(name)
if t is None:
t = buf.create_tag(name, **props)
return t
comment_tag = _get_or_create_tag(
"comment", foreground="#6a9955", style=Pango.Style.ITALIC
)
string_tag = _get_or_create_tag("string", foreground="#ce9178")
node_tag = _get_or_create_tag("node", foreground="#9cdcfe")
keyword_tag = _get_or_create_tag("keyword", foreground="#c586c0")
import re
def _apply(pattern, tag, group=0):
for m in re.finditer(pattern, text, re.MULTILINE):
s = buf.get_iter_at_offset(m.start(group))
e = buf.get_iter_at_offset(m.end(group))
buf.apply_tag(tag, s, e)
_apply(r"//[^\n]*", comment_tag)
_apply(r'"[^"\\]*(?:\\.[^"\\]*)*"', string_tag)
_apply(r"\b(true|false|null)\b", keyword_tag)
_apply(r"^(\s*)([a-zA-Z][\w\-]*)", node_tag, group=2)
# Copy / Validate
def _on_validate(self, *_):
self.show_toast("Validating...")
def _on_validated(result):
ok, msg = result
self.show_toast(msg[:120], timeout=5)
niri_ipc.run_in_thread(
lambda: niri_ipc.validate_config(str(NIRI_CONFIG)), _on_validated
)

171
nirimod/pages/startup.py Normal file
View File

@@ -0,0 +1,171 @@
"""Startup Programs page."""
from __future__ import annotations
import shlex
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
from nirimod.pages.base import BasePage
class StartupPage(BasePage):
def build(self) -> Gtk.Widget:
tb, header, _, content = self._make_toolbar_page("Startup Programs")
self._content = content
self.refresh()
return tb
def refresh(self):
self._rebuild()
def _get_entries(self) -> list[KdlNode]:
return [
n
for n in self._nodes
if n.name in ("spawn-at-startup", "spawn-sh-at-startup")
]
def _rebuild(self):
# Clear existing content
while True:
child = self._content.get_first_child()
if child is None:
break
self._content.remove(child)
entries = self._get_entries()
if not entries:
status = Adw.StatusPage(
title="No Startup Programs",
description="Programs added here will launch automatically when niri starts.",
icon_name="applications-system-symbolic",
)
add_btn = Gtk.Button(label="Add Program")
add_btn.add_css_class("pill")
add_btn.add_css_class("suggested-action")
add_btn.set_halign(Gtk.Align.CENTER)
add_btn.connect("clicked", self._on_add)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
box.set_valign(Gtk.Align.CENTER)
box.set_vexpand(True)
box.append(status)
box.append(add_btn)
self._content.append(box)
else:
grp = Adw.PreferencesGroup(
title="Startup Programs",
description=f"{len(entries)} program{'s' if len(entries) != 1 else ''} configured to launch",
)
for i, entry in enumerate(entries):
row = self._make_row(entry, i)
grp.add(row)
self._content.append(grp)
# Also add a convenient button at the bottom
add_btn = Gtk.Button(label="Add Another Program")
add_btn.add_css_class("pill")
add_btn.set_halign(Gtk.Align.CENTER)
add_btn.set_margin_top(16)
add_btn.connect("clicked", self._on_add)
self._content.append(add_btn)
def _make_row(self, node: KdlNode, idx: int) -> Adw.ActionRow:
cmd = " ".join(str(a) for a in node.args)
is_sh = "sh" in node.name
cmd_str = GLib.markup_escape_text(cmd) if cmd else "(empty)"
row = Adw.ActionRow(
title=cmd_str or "(empty)",
subtitle="Via shell (spawn-sh-at-startup)" if is_sh else "Launched directly",
)
row.set_activatable(True)
row.connect("activated", lambda *_, i=idx: self._on_edit(i))
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("Remove startup entry")
del_btn.connect("clicked", lambda *_, i=idx: self._on_delete(i))
row.add_suffix(del_btn)
return row
def _on_add(self, *_):
self._show_dialog(None, -1)
def _on_edit(self, idx: int):
entries = self._get_entries()
if 0 <= idx < len(entries):
self._show_dialog(entries[idx], idx)
def _on_delete(self, idx: int):
entries = self._get_entries()
if 0 <= idx < len(entries):
self._nodes.remove(entries[idx])
self._commit("remove startup entry")
self._rebuild()
def _show_dialog(self, node: KdlNode | None, idx: int):
dialog = Adw.AlertDialog(
heading="Startup Program", body="Enter the command to launch at startup."
)
cmd_entry = Adw.EntryRow(title="Command")
sh_switch = Adw.SwitchRow(title="Use shell (spawn-sh-at-startup)")
if node:
cmd_entry.set_text(" ".join(str(a) for a in node.args))
sh_switch.set_active("sh" in node.name)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
grp = Adw.PreferencesGroup()
grp.add(cmd_entry)
grp.add(sh_switch)
box.append(grp)
dialog.set_extra_child(box)
dialog.add_response("cancel", "Cancel")
dialog.add_response("save", "Save")
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
def _on_resp(d, r):
if r != "save":
return
cmd = cmd_entry.get_text().strip()
if not cmd:
return
is_sh = sh_switch.get_active()
node_name = "spawn-sh-at-startup" if is_sh else "spawn-at-startup"
if is_sh:
# sh -c expects a single string; store the whole command as one arg
args = [cmd]
else:
try:
args = shlex.split(cmd)
except ValueError:
args = cmd.split()
new_node = KdlNode(node_name, args=args)
entries = self._get_entries()
if idx >= 0 and 0 <= idx < len(entries):
i = self._nodes.index(entries[idx])
self._nodes[i] = new_node
else:
self._nodes.append(new_node)
self._commit("startup entry")
self._rebuild()
dialog.connect("response", _on_resp)
dialog.present(self._win)

File diff suppressed because it is too large Load Diff

155
nirimod/pages/workspaces.py Normal file
View File

@@ -0,0 +1,155 @@
"""Workspaces page."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk
from nirimod.kdl_parser import KdlNode, set_child_arg
from nirimod import niri_ipc
from nirimod.pages.base import BasePage
class WorkspacesPage(BasePage):
def build(self) -> Gtk.Widget:
tb, header, _, content = self._make_toolbar_page("Workspaces")
self._content = content
add_btn = Gtk.Button(icon_name="list-add-symbolic")
add_btn.add_css_class("flat")
add_btn.connect("clicked", self._on_add)
header.pack_end(add_btn)
self._grp = Adw.PreferencesGroup(
title="Named Workspaces",
description="Named workspaces open immediately at niri startup",
)
content.append(self._grp)
self.refresh()
return tb
def refresh(self):
self._rebuild()
def _get_ws_nodes(self) -> list[KdlNode]:
return [n for n in self._nodes if n.name == "workspace"]
def _rebuild(self):
parent = self._grp.get_parent()
if parent is None:
return
def _on_outputs(outputs_data):
ws_nodes = self._get_ws_nodes()
outputs = [o.get("name", "") for o in outputs_data]
output_model = Gtk.StringList.new(["(any)"] + outputs)
new_grp = Adw.PreferencesGroup(
title="Named Workspaces", description=f"{len(ws_nodes)} workspace(s)"
)
for i, ws in enumerate(ws_nodes):
row = self._make_ws_row(ws, i, outputs, output_model)
new_grp.add(row)
parent.remove(self._grp)
parent.append(new_grp)
self._grp = new_grp
niri_ipc.get_outputs(_on_outputs)
def _make_ws_row(
self, ws: KdlNode, idx: int, outputs: list[str], output_model: Gtk.StringList
) -> Adw.ExpanderRow:
name = ws.args[0] if ws.args else f"workspace-{idx + 1}"
assigned_out = ws.child_arg("open-on-output") or ""
exp = Adw.ExpanderRow(title=name)
name_row = Adw.EntryRow(title="Name")
name_row.set_text(str(name))
name_row.set_show_apply_button(True)
name_row.connect("apply", lambda r, i=idx: self._rename_ws(i, r.get_text()))
exp.add_row(name_row)
out_row = Adw.ComboRow(title="Open on Output")
out_list = ["(any)"] + outputs
out_row.set_model(Gtk.StringList.new(out_list))
if assigned_out in outputs:
out_row.set_selected(out_list.index(assigned_out))
out_row.connect(
"notify::selected",
lambda r, _, i=idx, ol=out_list: self._set_ws_output(
i, ol[r.get_selected()]
),
)
exp.add_row(out_row)
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
del_btn.add_css_class("flat")
del_btn.add_css_class("error")
del_btn.set_valign(Gtk.Align.CENTER)
del_btn.connect("clicked", lambda *_, i=idx: self._on_delete(i))
exp.add_suffix(del_btn)
return exp
def _on_add(self, *_):
dialog = Adw.AlertDialog(
heading="Add Workspace", body="Enter a name for the new workspace."
)
entry = Adw.EntryRow(title="Workspace Name")
grp = Adw.PreferencesGroup()
grp.add(entry)
dialog.set_extra_child(grp)
dialog.add_response("cancel", "Cancel")
dialog.add_response("add", "Add")
dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)
def _on_resp(d, r):
if r != "add":
return
name = entry.get_text().strip()
if not name:
return
node = KdlNode("workspace", args=[name])
ws_nodes = self._get_ws_nodes()
if ws_nodes:
last_idx = self._nodes.index(ws_nodes[-1])
self._nodes.insert(last_idx + 1, node)
else:
# If no workspaces, insert at the top
self._nodes.insert(0, node)
self._commit("add workspace")
self._rebuild()
dialog.connect("response", _on_resp)
dialog.present(self._win)
def _on_delete(self, idx: int):
ws_nodes = self._get_ws_nodes()
if 0 <= idx < len(ws_nodes):
self._nodes.remove(ws_nodes[idx])
self._commit("remove workspace")
self._rebuild()
def _rename_ws(self, idx: int, name: str):
ws_nodes = self._get_ws_nodes()
if 0 <= idx < len(ws_nodes) and name.strip():
ws_nodes[idx].args = [name.strip()]
self._commit("rename workspace")
self._rebuild()
def _set_ws_output(self, idx: int, output: str):
ws_nodes = self._get_ws_nodes()
if 0 <= idx < len(ws_nodes):
ws = ws_nodes[idx]
if output and output != "(any)":
set_child_arg(ws, "open-on-output", output)
else:
from nirimod.kdl_parser import remove_child
remove_child(ws, "open-on-output")
self._commit("workspace output")