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