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

323 lines
11 KiB
Python

"""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
)