(Init): Added shit
This commit is contained in:
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
|
||||
)
|
||||
Reference in New Issue
Block a user