Files
niritings/nirimod/pages/outputs.py
2026-05-29 00:41:12 +00:00

747 lines
26 KiB
Python
Raw Blame History

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