From 2e0c25331c9768f5251192cb75eadc41314acb98 Mon Sep 17 00:00:00 2001 From: Daniel Quolke <5166587+dMopp@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:35:12 +0100 Subject: [PATCH] Raw editor search, more decimals, close-dialog fix - Raw editor: Ctrl+F search bar with highlighted matches, "n of m", Enter/Shift+Enter navigation, case toggle and Esc to close. - Float options and shader constants keep up to six decimal places; the spin rows no longer round 0.125 to 0.13 or reject extra digits. - The unsaved-changes dialog on close and the raw editor's "Edited" status work again (their *_ parameter shadowed gettext). --- CHANGELOG.md | 17 ++++ README.md | 2 +- tests/test_numbers.py | 21 +++++ umbrielmod/locale/de.po | 102 +++++++++++++-------- umbrielmod/locale/umbrielmod.pot | 102 +++++++++++++-------- umbrielmod/pages/raw.py | 150 +++++++++++++++++++++++++++++-- umbrielmod/pages/shaders.py | 7 +- umbrielmod/schema.py | 17 ++++ umbrielmod/widgets.py | 29 +++++- umbrielmod/window.py | 2 +- 10 files changed, 359 insertions(+), 90 deletions(-) create mode 100644 tests/test_numbers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d90ca7c..7573406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ that section into the next version. ## [Unreleased] +### Added + +- Search in the raw editor: Ctrl+F opens a search bar that + highlights every match and shows "n of m"; Enter / + Shift+Enter (or the arrow buttons) jump between matches, + "Aa" matches case, Esc closes it. + +### Fixed + +- Decimal options and shader constants keep up to six decimal places. The + number fields had a fixed 1–3 digits, so a value such as `0.125` was shown as + `0.13`, rounded to that when the field lost focus, and more decimals could not + be typed at all. +- Closing the window with unsaved changes asks again instead of closing without + the dialog, and the raw editor shows "Edited" again after a change (both + handlers shadowed the translation function and failed). + ## [0.5.2] - 2026-11-09 ### Security diff --git a/README.md b/README.md index c11652c..b495fab 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Without Noctalia the regular libadwaita look is used. move a window). Effects that are not installed yet are loaded through a temporary copy of your saved config, which is restored when the preview closes. -- **Raw editor** for any loaded file, **undo/redo**, and a backup browser. +- **Raw editor** for any loaded file with search (Ctrl+F), **undo/redo**, and a backup browser. ## Configuration safety diff --git a/tests/test_numbers.py b/tests/test_numbers.py new file mode 100644 index 0000000..cdc49c4 --- /dev/null +++ b/tests/test_numbers.py @@ -0,0 +1,21 @@ +from umbrielmod.schema import MAX_DIGITS, decimals, format_number + + +def test_decimals(): + assert decimals(1) == 0 + assert decimals(0.5) == 1 + assert decimals(0.125) == 3 + assert decimals(0.0005) == 4 + assert decimals(1.333333) == 6 + assert decimals(1 / 3) == MAX_DIGITS + + +def test_format_number_keeps_precision_and_minimum(): + assert format_number(0.125, 2) == "0.125" + assert format_number(0.0005, 2) == "0.0005" + assert format_number(0.5, 2) == "0.50" + assert format_number(250, 1) == "250.0" + assert format_number(1 / 3, 2) == "0.333333" + assert format_number(-0.0, 2) == "0.00" + assert format_number(-1e-9, 1) == "0.0" + assert format_number(-1.25, 1) == "-1.25" diff --git a/umbrielmod/locale/de.po b/umbrielmod/locale/de.po index f1783b3..5d214e0 100644 --- a/umbrielmod/locale/de.po +++ b/umbrielmod/locale/de.po @@ -459,11 +459,11 @@ msgstr "Live von Umbriel gerendert · {kind}" msgid "{events} · {ms} ms" msgstr "{events} · {ms} ms" -#: umbrielmod/pages/raw.py:104 +#: umbrielmod/pages/raw.py:244 msgid "Text applied – not saved yet" msgstr "Text übernommen – noch nicht gespeichert" -#: umbrielmod/pages/raw.py:154 +#: umbrielmod/pages/raw.py:294 msgid "Backup loaded – press “Save” to keep it" msgstr "Sicherung geladen – mit „Speichern“ übernehmen" @@ -471,41 +471,65 @@ msgstr "Sicherung geladen – mit „Speichern“ übernehmen" msgid "Validate" msgstr "Prüfen" -#: umbrielmod/pages/raw.py:71 +#: umbrielmod/pages/raw.py:56 +msgid "Search in the file" +msgstr "In der Datei suchen" + +#: umbrielmod/pages/raw.py:65 +msgid "Match case" +msgstr "Groß-/Kleinschreibung beachten" + +#: umbrielmod/pages/raw.py:67 +msgid "Previous match (Shift+Enter)" +msgstr "Vorheriger Treffer (Umschalt+Enter)" + +#: umbrielmod/pages/raw.py:69 +msgid "Next match (Enter)" +msgstr "Nächster Treffer (Enter)" + +#: umbrielmod/pages/raw.py:142 +msgid "No match" +msgstr "Kein Treffer" + +#: umbrielmod/pages/raw.py:205 msgid "Edited – “Apply” loads the text into the editor" msgstr "Bearbeitet – „Übernehmen“ überträgt den Text in die Oberfläche" -#: umbrielmod/pages/raw.py:113 +#: umbrielmod/pages/raw.py:253 #: umbrielmod/window.py:39 msgid "Backups" msgstr "Sicherungen" -#: umbrielmod/pages/raw.py:142 +#: umbrielmod/pages/raw.py:282 msgid "This file is no longer part of the configuration" msgstr "Diese Datei gehört nicht mehr zur Konfiguration" -#: umbrielmod/pages/raw.py:90 +#: umbrielmod/pages/raw.py:174 +msgid "{current} of {total}" +msgstr "{current} von {total}" + +#: umbrielmod/pages/raw.py:230 msgid "Invalid" msgstr "Ungültig" -#: umbrielmod/pages/raw.py:129 +#: umbrielmod/pages/raw.py:269 msgid "Restore" msgstr "Wiederherstellen" -#: umbrielmod/pages/raw.py:135 +#: umbrielmod/pages/raw.py:275 msgid "No backups yet" msgstr "Noch keine Sicherungen" -#: umbrielmod/pages/raw.py:83 -#: umbrielmod/pages/raw.py:98 +#: umbrielmod/pages/raw.py:223 +#: umbrielmod/pages/raw.py:238 msgid "TOML error: {error}" msgstr "TOML-Fehler: {error}" -#: umbrielmod/pages/raw.py:114 +#: umbrielmod/pages/raw.py:254 msgid "Before every save a copy is stored in {dir} (the last 50 per file)." msgstr "Vor jedem Speichern wird eine Kopie in {dir} abgelegt (je Datei die letzten 50)." -#: umbrielmod/pages/raw.py:148 +#: umbrielmod/pages/raw.py:288 #: umbrielmod/pages/shaders.py:343 #: umbrielmod/pages/shaders.py:217 #: umbrielmod/window.py:315 @@ -867,7 +891,7 @@ msgstr "Top-Level-Konstanten aus dem GLSL-Code – so dokumentiert die Sammlung msgid "Must match the preset padding" msgstr "Muss dem padding des Presets entsprechen" -#: umbrielmod/pages/shaders.py:857 +#: umbrielmod/pages/shaders.py:860 msgid "Global effects" msgstr "Globale Effekte" @@ -1057,98 +1081,98 @@ msgstr "aktuell" msgid "preferred" msgstr "bevorzugt" -#: umbrielmod/widgets.py:235 -#: umbrielmod/widgets.py:416 +#: umbrielmod/widgets.py:255 +#: umbrielmod/widgets.py:439 msgid "Reset to default (removes the key)" msgstr "Auf Standard zurücksetzen (entfernt den Schlüssel)" -#: umbrielmod/widgets.py:319 +#: umbrielmod/widgets.py:342 msgid "Not set" msgstr "Nicht gesetzt" -#: umbrielmod/widgets.py:371 +#: umbrielmod/widgets.py:394 msgid "TOML value, e.g. \"text\", 1.5, [1, 2] or { a = 1 }" msgstr "TOML-Wert, z. B. \"text\", 1.5, [1, 2] oder { a = 1 }" -#: umbrielmod/widgets.py:450 +#: umbrielmod/widgets.py:473 msgid "Add entry" msgstr "Eintrag hinzufügen" -#: umbrielmod/widgets.py:64 +#: umbrielmod/widgets.py:84 msgid "Suggestions" msgstr "Vorschläge" -#: umbrielmod/widgets.py:70 +#: umbrielmod/widgets.py:90 msgid "Search…" msgstr "Suchen…" -#: umbrielmod/widgets.py:139 +#: umbrielmod/widgets.py:159 msgid "built-in" msgstr "eingebaut" -#: umbrielmod/widgets.py:143 +#: umbrielmod/widgets.py:163 msgid "Spring: damping, stiffness" msgstr "Feder: Dämpfung, Steifigkeit" -#: umbrielmod/widgets.py:144 +#: umbrielmod/widgets.py:164 msgid "Bézier curve" msgstr "Bézier-Kurve" -#: umbrielmod/widgets.py:413 -#: umbrielmod/widgets.py:426 +#: umbrielmod/widgets.py:436 +#: umbrielmod/widgets.py:449 msgid "empty" msgstr "leer" -#: umbrielmod/widgets.py:444 +#: umbrielmod/widgets.py:467 msgid "Remove" msgstr "Entfernen" -#: umbrielmod/widgets.py:40 +#: umbrielmod/widgets.py:60 msgid "Open the Umbriel documentation" msgstr "Umbriel-Dokumentation öffnen" -#: umbrielmod/widgets.py:137 +#: umbrielmod/widgets.py:157 msgid "suppress" msgstr "unterdrücken" -#: umbrielmod/widgets.py:141 +#: umbrielmod/widgets.py:161 msgid "Bézier" msgstr "Bézier" -#: umbrielmod/widgets.py:142 +#: umbrielmod/widgets.py:162 msgid "Spring" msgstr "Feder" -#: umbrielmod/widgets.py:218 +#: umbrielmod/widgets.py:238 msgid "Range: {min} – {max}" msgstr "Bereich: {min} – {max}" -#: umbrielmod/widgets.py:319 +#: umbrielmod/widgets.py:342 msgid "Default ({value})" msgstr "Standard ({value})" -#: umbrielmod/widgets.py:499 +#: umbrielmod/widgets.py:522 msgid "Add option" msgstr "Option hinzufügen" -#: umbrielmod/widgets.py:220 -#: umbrielmod/widgets.py:203 +#: umbrielmod/widgets.py:240 +#: umbrielmod/widgets.py:223 msgid "Default: {value}" msgstr "Standard: {value}" -#: umbrielmod/widgets.py:430 +#: umbrielmod/widgets.py:453 msgid "Entry {n}" msgstr "Eintrag {n}" -#: umbrielmod/widgets.py:200 +#: umbrielmod/widgets.py:220 msgid "Set in {file}" msgstr "Festgelegt in {file}" -#: umbrielmod/widgets.py:223 +#: umbrielmod/widgets.py:243 msgid "Description taken from {path}" msgstr "Beschreibung übernommen von {path}" -#: umbrielmod/widgets.py:205 +#: umbrielmod/widgets.py:225 msgid "Not set – Umbriel decides" msgstr "Nicht gesetzt – Umbriel entscheidet" diff --git a/umbrielmod/locale/umbrielmod.pot b/umbrielmod/locale/umbrielmod.pot index 177c427..05e9ac0 100644 --- a/umbrielmod/locale/umbrielmod.pot +++ b/umbrielmod/locale/umbrielmod.pot @@ -461,11 +461,11 @@ msgstr "" msgid "{events} · {ms} ms" msgstr "" -#: umbrielmod/pages/raw.py:104 +#: umbrielmod/pages/raw.py:244 msgid "Text applied – not saved yet" msgstr "" -#: umbrielmod/pages/raw.py:154 +#: umbrielmod/pages/raw.py:294 msgid "Backup loaded – press “Save” to keep it" msgstr "" @@ -473,41 +473,65 @@ msgstr "" msgid "Validate" msgstr "" -#: umbrielmod/pages/raw.py:71 +#: umbrielmod/pages/raw.py:56 +msgid "Search in the file" +msgstr "" + +#: umbrielmod/pages/raw.py:65 +msgid "Match case" +msgstr "" + +#: umbrielmod/pages/raw.py:67 +msgid "Previous match (Shift+Enter)" +msgstr "" + +#: umbrielmod/pages/raw.py:69 +msgid "Next match (Enter)" +msgstr "" + +#: umbrielmod/pages/raw.py:142 +msgid "No match" +msgstr "" + +#: umbrielmod/pages/raw.py:205 msgid "Edited – “Apply” loads the text into the editor" msgstr "" -#: umbrielmod/pages/raw.py:113 +#: umbrielmod/pages/raw.py:253 #: umbrielmod/window.py:39 msgid "Backups" msgstr "" -#: umbrielmod/pages/raw.py:142 +#: umbrielmod/pages/raw.py:282 msgid "This file is no longer part of the configuration" msgstr "" -#: umbrielmod/pages/raw.py:90 +#: umbrielmod/pages/raw.py:174 +msgid "{current} of {total}" +msgstr "" + +#: umbrielmod/pages/raw.py:230 msgid "Invalid" msgstr "" -#: umbrielmod/pages/raw.py:129 +#: umbrielmod/pages/raw.py:269 msgid "Restore" msgstr "" -#: umbrielmod/pages/raw.py:135 +#: umbrielmod/pages/raw.py:275 msgid "No backups yet" msgstr "" -#: umbrielmod/pages/raw.py:83 -#: umbrielmod/pages/raw.py:98 +#: umbrielmod/pages/raw.py:223 +#: umbrielmod/pages/raw.py:238 msgid "TOML error: {error}" msgstr "" -#: umbrielmod/pages/raw.py:114 +#: umbrielmod/pages/raw.py:254 msgid "Before every save a copy is stored in {dir} (the last 50 per file)." msgstr "" -#: umbrielmod/pages/raw.py:148 +#: umbrielmod/pages/raw.py:288 #: umbrielmod/pages/shaders.py:343 #: umbrielmod/pages/shaders.py:217 #: umbrielmod/window.py:315 @@ -869,7 +893,7 @@ msgstr "" msgid "Must match the preset padding" msgstr "" -#: umbrielmod/pages/shaders.py:857 +#: umbrielmod/pages/shaders.py:860 msgid "Global effects" msgstr "" @@ -1059,98 +1083,98 @@ msgstr "" msgid "preferred" msgstr "" -#: umbrielmod/widgets.py:235 -#: umbrielmod/widgets.py:416 +#: umbrielmod/widgets.py:255 +#: umbrielmod/widgets.py:439 msgid "Reset to default (removes the key)" msgstr "" -#: umbrielmod/widgets.py:319 +#: umbrielmod/widgets.py:342 msgid "Not set" msgstr "" -#: umbrielmod/widgets.py:371 +#: umbrielmod/widgets.py:394 msgid "TOML value, e.g. \"text\", 1.5, [1, 2] or { a = 1 }" msgstr "" -#: umbrielmod/widgets.py:450 +#: umbrielmod/widgets.py:473 msgid "Add entry" msgstr "" -#: umbrielmod/widgets.py:64 +#: umbrielmod/widgets.py:84 msgid "Suggestions" msgstr "" -#: umbrielmod/widgets.py:70 +#: umbrielmod/widgets.py:90 msgid "Search…" msgstr "" -#: umbrielmod/widgets.py:139 +#: umbrielmod/widgets.py:159 msgid "built-in" msgstr "" -#: umbrielmod/widgets.py:143 +#: umbrielmod/widgets.py:163 msgid "Spring: damping, stiffness" msgstr "" -#: umbrielmod/widgets.py:144 +#: umbrielmod/widgets.py:164 msgid "Bézier curve" msgstr "" -#: umbrielmod/widgets.py:413 -#: umbrielmod/widgets.py:426 +#: umbrielmod/widgets.py:436 +#: umbrielmod/widgets.py:449 msgid "empty" msgstr "" -#: umbrielmod/widgets.py:444 +#: umbrielmod/widgets.py:467 msgid "Remove" msgstr "" -#: umbrielmod/widgets.py:40 +#: umbrielmod/widgets.py:60 msgid "Open the Umbriel documentation" msgstr "" -#: umbrielmod/widgets.py:137 +#: umbrielmod/widgets.py:157 msgid "suppress" msgstr "" -#: umbrielmod/widgets.py:141 +#: umbrielmod/widgets.py:161 msgid "Bézier" msgstr "" -#: umbrielmod/widgets.py:142 +#: umbrielmod/widgets.py:162 msgid "Spring" msgstr "" -#: umbrielmod/widgets.py:218 +#: umbrielmod/widgets.py:238 msgid "Range: {min} – {max}" msgstr "" -#: umbrielmod/widgets.py:319 +#: umbrielmod/widgets.py:342 msgid "Default ({value})" msgstr "" -#: umbrielmod/widgets.py:499 +#: umbrielmod/widgets.py:522 msgid "Add option" msgstr "" -#: umbrielmod/widgets.py:220 -#: umbrielmod/widgets.py:203 +#: umbrielmod/widgets.py:240 +#: umbrielmod/widgets.py:223 msgid "Default: {value}" msgstr "" -#: umbrielmod/widgets.py:430 +#: umbrielmod/widgets.py:453 msgid "Entry {n}" msgstr "" -#: umbrielmod/widgets.py:200 +#: umbrielmod/widgets.py:220 msgid "Set in {file}" msgstr "" -#: umbrielmod/widgets.py:223 +#: umbrielmod/widgets.py:243 msgid "Description taken from {path}" msgstr "" -#: umbrielmod/widgets.py:205 +#: umbrielmod/widgets.py:225 msgid "Not set – Umbriel decides" msgstr "" diff --git a/umbrielmod/pages/raw.py b/umbrielmod/pages/raw.py index a7962c8..43d5717 100644 --- a/umbrielmod/pages/raw.py +++ b/umbrielmod/pages/raw.py @@ -3,7 +3,7 @@ from __future__ import annotations import tomlkit -from gi.repository import Adw, GLib, Gtk, Pango +from gi.repository import Adw, Gdk, GLib, Gtk, Pango from .. import config as cfgmod from ..context import AppContext @@ -35,12 +35,145 @@ def __init__(self, ctx: AppContext): self.buffer = Gtk.TextBuffer(enable_undo=True) self.buffer.connect("changed", self._changed) - view = Gtk.TextView(buffer=self.buffer, monospace=True, wrap_mode=Gtk.WrapMode.NONE, - top_margin=8, bottom_margin=8, left_margin=12, right_margin=12) - scroller = Gtk.ScrolledWindow(vexpand=True, child=view) + self.match_tag = self.buffer.create_tag("search-match", background="rgba(246, 211, 45, 0.45)") + self.view = Gtk.TextView(buffer=self.buffer, monospace=True, wrap_mode=Gtk.WrapMode.NONE, + top_margin=8, bottom_margin=8, left_margin=12, right_margin=12) + self.append(self._build_search()) + scroller = Gtk.ScrolledWindow(vexpand=True, child=self.view) self.append(scroller) + + keys = Gtk.ShortcutController(propagation_phase=Gtk.PropagationPhase.CAPTURE) + keys.add_shortcut(Gtk.Shortcut(trigger=Gtk.ShortcutTrigger.parse_string("f"), + action=Gtk.CallbackAction.new(lambda *_: self.open_search() or True))) + self.add_controller(keys) self.refresh() + # ----------------------------------------------------------------- search + + def _build_search(self) -> Gtk.SearchBar: + self._matches: list[tuple[int, int]] = [] + self._match = -1 + self.search = Gtk.SearchEntry(hexpand=True, placeholder_text=_("Search in the file")) + self.search.connect("search-changed", lambda *_: self._find()) + self.search.connect("activate", lambda *_: self._step(1)) + self.search.connect("next-match", lambda *_: self._step(1)) + self.search.connect("previous-match", lambda *_: self._step(-1)) + self.search.connect("stop-search", lambda *_: self.close_search()) + keys = Gtk.EventControllerKey() + keys.connect("key-pressed", self._search_key) + self.search.add_controller(keys) + self.case = Gtk.ToggleButton(label="Aa", tooltip_text=_("Match case")) + self.case.connect("toggled", lambda *_: self._find()) + prev = Gtk.Button(icon_name="go-up-symbolic", tooltip_text=_("Previous match (Shift+Enter)")) + prev.connect("clicked", lambda *_: self._step(-1)) + nxt = Gtk.Button(icon_name="go-down-symbolic", tooltip_text=_("Next match (Enter)")) + nxt.connect("clicked", lambda *_: self._step(1)) + self.match_label = Gtk.Label(width_chars=8, xalign=1) + self.match_label.add_css_class("dim-label") + nav = Gtk.Box(css_classes=["linked"]) + nav.append(prev) + nav.append(nxt) + box = Gtk.Box(spacing=6) + for w in (self.search, self.match_label, self.case, nav): + box.append(w) + clamp = Adw.Clamp(maximum_size=640, child=box) + self.search_bar = Gtk.SearchBar(child=clamp, show_close_button=True) + self.search_bar.connect_entry(self.search) + self.search_bar.connect("notify::search-mode-enabled", self._search_mode) + return self.search_bar + + def open_search(self): + sel = self.buffer.get_selection_bounds() + if sel and sel[0].get_line() == sel[1].get_line(): + self.search.set_text(self.buffer.get_text(sel[0], sel[1], False)) + self.search_bar.set_search_mode(True) + self.search.grab_focus() + self.search.select_region(0, -1) + self._find() + + def close_search(self): + self.search_bar.set_search_mode(False) + + def _search_mode(self, *_): + if self.search_bar.get_search_mode(): + return + self._clear_matches() + self.match_label.set_label("") + self.view.grab_focus() + + def _search_key(self, _ctrl, keyval, _code, state): + if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter) and state & Gdk.ModifierType.SHIFT_MASK: + self._step(-1) + return True + return False + + def _clear_matches(self): + start, end = self.buffer.get_bounds() + self.buffer.remove_tag(self.match_tag, start, end) + self._matches = [] + self._match = -1 + + def _find(self, keep: int | None = None, select: bool = True): + """Highlight every match and select the first one at or after *keep* (default: the cursor). + + With ``select=False`` (while the user types in the text) only the + highlights and the counter follow, the cursor stays where it is. + """ + if self._loading or not self.search_bar.get_search_mode(): + return + self._clear_matches() + needle = self.search.get_text() + if not needle: + self.match_label.set_label("") + self.search.remove_css_class("error") + return + flags = Gtk.TextSearchFlags.TEXT_ONLY + if not self.case.get_active(): + flags |= Gtk.TextSearchFlags.CASE_INSENSITIVE + it = self.buffer.get_start_iter() + while (found := it.forward_search(needle, flags, None)) is not None: + start, end = found + self.buffer.apply_tag(self.match_tag, start, end) + self._matches.append((start.get_offset(), end.get_offset())) + if end.equal(it): # zero-length guard + break + it = end + if not self._matches: + self.match_label.set_label(_("No match")) + self.search.add_css_class("error") + return + self.search.remove_css_class("error") + if keep is None: + keep = self.buffer.get_iter_at_mark(self.buffer.get_insert()).get_offset() + sel = self.buffer.get_selection_bounds() + if sel: + keep = sel[0].get_offset() + self._match = next((i for i, (s, _e) in enumerate(self._matches) if s >= keep), 0) + if select: + self._show_match() + else: + self._update_count() + + def _step(self, delta: int): + if not self.search_bar.get_search_mode(): + return + if not self._matches: + self._find() + return + self._match = (self._match + delta) % len(self._matches) + self._show_match() + + def _show_match(self): + s, e = self._matches[self._match] + start, end = self.buffer.get_iter_at_offset(s), self.buffer.get_iter_at_offset(e) + self.buffer.select_range(start, end) + self.view.scroll_to_iter(start, 0.1, False, 0, 0) + self._update_count() + + def _update_count(self): + self.match_label.set_label(_("{current} of {total}").format(current=self._match + 1, + total=len(self._matches))) + def _current(self) -> cfgmod.TomlFile: idx = self.files.get_selected() files = self.ctx.cfg.files @@ -64,11 +197,18 @@ def load(self): self._loading = False self._edited = False self.status.set_label(str(tf.path)) + self._find(keep=0) - def _changed(self, *_): + def _changed(self, *_args): # not *_: that would shadow the gettext _ if not self._loading: self._edited = True self.status.set_label(_("Edited – “Apply” loads the text into the editor")) + if self.search_bar.get_search_mode(): + GLib.idle_add(self._refind) + + def _refind(self): + self._find(select=False) + return False def _text(self) -> str: start, end = self.buffer.get_bounds() diff --git a/umbrielmod/pages/shaders.py b/umbrielmod/pages/shaders.py index b48c8e2..4c4109f 100644 --- a/umbrielmod/pages/shaders.py +++ b/umbrielmod/pages/shaders.py @@ -16,7 +16,7 @@ from ..context import AppContext from ..docs import DOCS_SITE, section_url from ..i18n import N_, _ -from ..widgets import AnimatedPicture, color_swatches, doc_button, esc, icon_button, option_doc_url, option_row, rgba_from_string, rgba_to_string +from ..widgets import AnimatedPicture, color_swatches, doc_button, esc, icon_button, option_doc_url, option_row, precise_digits, rgba_from_string, rgba_to_string from .schema_page import MapGroup, leaf_groups ANIMATION_EVENTS_LABELS = { @@ -796,7 +796,10 @@ def write(const, values): row = Adw.SpinRow.new_with_range(lo, span, 1 if c.type == "int" else 0.01) row.set_title(title) row.set_subtitle(sub) - row.set_digits(0 if c.type == "int" else 3) + if c.type == "int": + row.set_digits(0) + else: + precise_digits(row, 3) row.set_value(v) row.connect("notify::value", lambda r, *_, c=c: write(c, [r.get_value()])) elif c.is_color: diff --git a/umbrielmod/schema.py b/umbrielmod/schema.py index 43b8584..79cd42e 100644 --- a/umbrielmod/schema.py +++ b/umbrielmod/schema.py @@ -163,3 +163,20 @@ def collections(self, schema_prefix: str) -> list[Option]: def humanize(key: str) -> str: text = key.replace("_", " ").replace("-", " ").strip() return text[:1].upper() + text[1:] + + +# Spin rows keep this many decimals internally; the display trims trailing zeros. +MAX_DIGITS = 6 + + +def decimals(value: float) -> int: + """Decimal places *value* needs (at most MAX_DIGITS), e.g. 0.125 -> 3.""" + text = f"{float(value):.{MAX_DIGITS}f}".rstrip("0") + return len(text.partition(".")[2]) + + +def format_number(value: float, min_digits: int = 0) -> str: + """*value* with as many decimals as it needs, but at least *min_digits*.""" + value = round(float(value), MAX_DIGITS) + 0.0 # also turns -0.0 into 0.0 + digits = min(max(min_digits, decimals(value)), MAX_DIGITS) + return f"{value:.{digits}f}" diff --git a/umbrielmod/widgets.py b/umbrielmod/widgets.py index 35cbaf1..73df3f6 100644 --- a/umbrielmod/widgets.py +++ b/umbrielmod/widgets.py @@ -7,6 +7,7 @@ from __future__ import annotations +import locale from typing import Callable import gi @@ -19,7 +20,7 @@ from .i18n import _ # noqa: E402 from .config import TomlFile, parse_literal, toml_literal # noqa: E402 from .context import AppContext # noqa: E402 -from .schema import Option, humanize # noqa: E402 +from .schema import MAX_DIGITS, Option, format_number, humanize # noqa: E402 _MISSING = object() BUILTIN_CURVES = ("linear", "ease", "easeout", "snappy", "bounce", "elastic") @@ -33,6 +34,25 @@ def esc(text: str) -> str: # --------------------------------------------------------------------------- small widgets +def precise_digits(row: Adw.SpinRow, min_digits: int) -> None: + """Let a float spin row keep up to MAX_DIGITS decimals. + + GtkSpinButton rounds the value to its ``digits`` whenever it re-reads its + text and refuses typed decimals beyond them, so a fixed 2 would turn 0.125 + into 0.13. The row keeps MAX_DIGITS and only the display drops trailing + zeros (never below *min_digits*). The text uses the locale's decimal + separator, as GTK only accepts that one when parsing. + """ + row.set_digits(MAX_DIGITS) + + def output(r): + point = locale.localeconv()["decimal_point"] or "." + r.set_text(format_number(r.get_value(), min_digits).replace(".", point)) + return True + + row.connect("output", output) + + def doc_button(ctx: AppContext, url: str | None, what: str = "") -> Gtk.Button: """The round (?) button that opens the Umbriel documentation.""" btn = Gtk.Button(label="?", valign=Gtk.Align.CENTER, css_classes=["doc-link"]) @@ -301,14 +321,17 @@ def on_bool(*_): step, digits = (0.01, 3) if span <= 2 else (0.05, 2) if span <= 20 else (1, 1) row = Adw.SpinRow.new_with_range(lo, hi, step) row.set_title(esc(title)) - row.set_digits(digits) + if t == "int": + row.set_digits(0) + else: + precise_digits(row, digits) val = b.value() row.set_value(float(val) if isinstance(val, (int, float)) else max(lo, min(hi, 0))) reset = _decorate(ctx, row, b, refresh) def on_num(*_): v = row.get_value() - b.set(int(round(v)) if t == "int" else round(v, 6)) + b.set(int(round(v)) if t == "int" else round(v, MAX_DIGITS)) reset.set_visible(True) row.connect("notify::value", on_num) diff --git a/umbrielmod/window.py b/umbrielmod/window.py index cba2b37..03068b8 100644 --- a/umbrielmod/window.py +++ b/umbrielmod/window.py @@ -479,7 +479,7 @@ def _check_external(self): else _("The configuration changed on disk and was reloaded")) return False - def _on_close(self, *_): + def _on_close(self, *_args): # not *_: that would shadow the gettext _ if not self.ctx.cfg.dirty: return False dlg = Adw.AlertDialog(heading=_("Unsaved changes"),