(Init): Added shit
This commit is contained in:
0
nirimod/pages/__init__.py
Normal file
0
nirimod/pages/__init__.py
Normal file
1222
nirimod/pages/animations.py
Normal 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
452
nirimod/pages/appearance.py
Normal 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
98
nirimod/pages/base.py
Normal 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
776
nirimod/pages/bindings.py
Normal 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
|
||||
161
nirimod/pages/environment.py
Normal file
161
nirimod/pages/environment.py
Normal 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
200
nirimod/pages/gestures.py
Normal 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
380
nirimod/pages/input_page.py
Normal 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 & 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
284
nirimod/pages/layout.py
Normal 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
746
nirimod/pages/outputs.py
Normal 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 & 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
322
nirimod/pages/raw_config.py
Normal 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
171
nirimod/pages/startup.py
Normal 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)
|
||||
1014
nirimod/pages/window_rules.py
Normal file
1014
nirimod/pages/window_rules.py
Normal file
File diff suppressed because it is too large
Load Diff
155
nirimod/pages/workspaces.py
Normal file
155
nirimod/pages/workspaces.py
Normal 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")
|
||||
Reference in New Issue
Block a user