323 lines
11 KiB
Python
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
|
|
)
|