diff --git a/CHANGELOG.md b/CHANGELOG.md index 1083edb..3d65861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ## [Unreleased] +### Added + +- **You can now search for a setting on the Control page.** The box at the top right takes part of + a field or section name, or a `--flag`. Every match is highlighted, the one you are on is filled + in, and the `1 / 4` counter points at it. `Enter` goes to the next, `Shift+Enter` back, `Escape` + clears, `Ctrl+F` focuses the box. A folded section opens for the search and folds back after it. + Accents are optional: `opoznienie` finds `Opóźnienie`. A setting that lives in the Settings + window is named rather than missed. + ### Fixed - **The Settings window no longer scrolls off the top of itself.** Dragging its scrollbar upwards pushed everything down and left a blank band above the first setting, as if the window had lost diff --git a/README.md b/README.md index de40e2e..13a13eb 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,14 @@ effect. | `Ctrl+Enter` | Apply changes | | `Ctrl+S` / `Ctrl+O` | Save / Load config file | | `Ctrl+L` | Clear the log | +| `Ctrl+F` | Search: the field search on Control, the table search on Connections | + +**Finding a setting.** The box at the top of the Control page searches the settings by name - +type part of a field or section name, or the command-line flag such as `--loss`. Every match is +highlighted and the one you are on is filled in, so the `1 / 4` counter always points at something +you can see. `Enter` goes to the next match, `Shift+Enter` back, `Escape` clears the box. A folded section opens while the search lasts and folds itself back afterwards. Accents are +optional. If what you are after is one of the settings that live in the Settings window, the +search tells you so. ### Field validation @@ -1182,6 +1190,7 @@ beantester/ the implementation package gui/ the tkinter interface app.py window composition, state, log, start/stop, dirty-state form.py form generated from fields.FIELD_DEFS + form_search.py matching a typed query against the field registry (pure) scaling.py DPI, scaled pixels, window/chart/tooltip geometry wheel.py mouse-wheel normalisation (a pure function) scrollable.py ScrollableFrame + ONE global wheel dispatcher diff --git a/README.pl.md b/README.pl.md index 4b74746..d399e8a 100644 --- a/README.pl.md +++ b/README.pl.md @@ -152,6 +152,15 @@ Pole, które przejęło inne ustawienie, jest **wyszarzone razem z etykietą**, | `Ctrl+Enter` | Zastosuj zmiany | | `Ctrl+S` / `Ctrl+O` | Zapisz / Wczytaj plik konfiguracji | | `Ctrl+L` | Wyczyść log | +| `Ctrl+F` | Szukanie: pól na Sterowaniu, tabeli na Połączeniach | + +**Jak znaleźć ustawienie.** Pole u góry strony Sterowanie szuka ustawień po nazwie - wpisz część +nazwy pola albo sekcji, albo flagę z wiersza poleceń, na przykład `--loss`. Każde trafienie jest +podświetlone, a to, na którym stoisz, jest wypełnione kolorem - licznik `1 / 4` zawsze wskazuje +coś, co widać. `Enter` przechodzi do następnego, `Shift+Enter` do poprzedniego, `Escape` czyści +pole. Zwinięta sekcja rozwija się na czas szukania i sama się zwija, kiedy +skończysz. Polskich znaków nie musisz pisać. Jeśli szukane ustawienie mieszka w oknie Ustawienia, +wyszukiwarka o tym powie. ### Walidacja pól @@ -1037,6 +1046,7 @@ beantester/ pakiet z implementacją gui/ interfejs tkinter app.py kompozycja okna, stan, log, start/stop, dirty-state form.py formularz generowany z fields.FIELD_DEFS + form_search.py dopasowanie wpisanego hasła do rejestru pól (czyste) scaling.py DPI, skalowane piksele, geometria okna/wykresu/tooltipa wheel.py normalizacja kółka myszy (czysta funkcja) scrollable.py ScrollableFrame + JEDEN globalny dispatcher kółka diff --git a/beantester/gui/form.py b/beantester/gui/form.py index ce5f744..feaaddb 100644 --- a/beantester/gui/form.py +++ b/beantester/gui/form.py @@ -58,11 +58,16 @@ class ControlForm: """ def __init__(self, parent, app, extras=None, scroller=None, sections=None, - collapsible=True): + collapsible=True, on_rebuilt=None): self.app = app self.parent = parent self.extras = extras or {} self.scroller = scroller # the ScrollableFrame the form lives in + # Called after a REBUILD (the column switch below), never after the first + # build. Crossing the width threshold destroys every panel and widget, so + # anything holding references into the form - the Control page's search + # marks - has to be told rather than left pointing at dead widgets. + self.on_rebuilt = on_rebuilt # Which registry sections this form renders. The Control page passes the # default (CONTROL_SECTIONS); the Settings window passes SETTINGS_SECTIONS. self._sections = CONTROL_SECTIONS if sections is None else tuple(sections) @@ -351,6 +356,9 @@ def set_columns(self, columns): child.destroy() self._build() self.app.set_filter_cli_key(self.app._filter_key) + if self.on_rebuilt is not None: + with crashlog.quiet("gui.form"): + self.on_rebuilt() def _apply_toggle_state(self, section_id): """Grey out a section whose 'enable' box is unchecked. diff --git a/beantester/gui/form_search.py b/beantester/gui/form_search.py new file mode 100644 index 0000000..c842e83 --- /dev/null +++ b/beantester/gui/form_search.py @@ -0,0 +1,148 @@ +"""Finding a setting on the Control page by name - the pure half. + +Why this exists +--------------- +The Control page renders the whole field registry: a dozen collapsible sections +and forty-odd fields, several of them folded away on a fresh install. Somebody +who knows what they want ("opoznienie") had no way to get to it except opening +every section and reading. This turns the registry into something searchable. + +Everything here is pure: no Tk, no widgets, no state. It answers one question - +which registry entries does this query name? - and `gui/pages/control.py` does +the highlighting and the scrolling. That split is what makes the matching +testable at all, since the GUI half needs a fake Tk and a subprocess. + +Three decisions, all the owner's (2026-08-18), written down because each one +narrows the answer and would otherwise look arbitrary: + +* **Names only.** Field labels and section titles - not tooltip bodies. Searching + the tips finds "loses packets" and half the page with it, which is a different + feature (a symptom index) and would make one word match everything. +* **CLI flags too.** Somebody who knows `--loss` should be able to type it and be + taken to the field it drives. This is the one place where the search is not + about the visible text - and it is cheap, because `fields.py` already carries + the flag. +* **Settings-window fields are indexed, but never jumped to.** They are not on + this page and they never will be, so a hit there answers "it lives in the + Settings window" instead of "not found". A dead end that knows where the thing + is beats an honest shrug. + +🔴 **The index must be built AFTER `set_language`, never at import time.** Every +label here comes out of `i18n`, so an index built once would keep answering in +the language the process started in - and a language switch rebuilds the whole +UI precisely so that nothing keeps the old words. +""" +import unicodedata + +from .. import fields as F +from ..i18n import T, field_name + +SECTION, FIELD = "section", "field" + + +def fold(text): + """Case- and accent-insensitive form of a string, for comparing by hand. + + 🔴 Polish labels carry diacritics and people type without them: somebody + looking for "Opoznienie" must find "Opoznienie" spelled with the o-acute and + the z-dot. `casefold` alone does not do that - it lowercases, and the accent + survives - so the string is decomposed (NFKD) and the combining marks are + dropped. Without this the feature works for half the Polish labels and nobody + can tell which half. + """ + decomposed = unicodedata.normalize("NFKD", str(text or "")) + return "".join(ch for ch in decomposed + if not unicodedata.combining(ch)).casefold().strip() + + +class Entry: + """One searchable thing: a section title, or a field inside one.""" + + __slots__ = ("kind", "key", "section_id", "label", "section_label", + "surface", "haystack") + + def __init__(self, kind, key, section_id, label, section_label, surface, + haystack): + self.kind = kind + self.key = key # field key, or the section id + self.section_id = section_id + self.label = label # as shown, already translated + self.section_label = section_label + self.surface = surface # "control" | "settings" + self.haystack = haystack # folded strings this entry answers to + + @property + def on_control(self): + return self.surface == "control" + + def __repr__(self): # pragma: no cover - debugging only + return "Entry(%s %s)" % (self.kind, self.key) + + +def _field_haystack(field, label, section_label): + """What a field answers to: its own name, its section's name, its flag. + + The section title is included so that typing a group name ("blokowanie") + lights up the fields inside it rather than only the header - which is what + somebody scanning for a subject, not a field, is actually asking for. + """ + parts = [label, section_label] + if field.cli: + # Both spellings, because the flag is written `--dst-ip` in the README and + # the settings key is `dst_ip` in every config file - and a person types + # whichever they last read. + parts += [field.cli, field.cli.replace("-", "_"), "--" + field.cli] + parts.append(field.key) + return tuple(fold(part) for part in parts if part) + + +def build_index(sections=None, fields=None): + """Every searchable entry, in the order the page renders them. + + Document order matters: "next hit" walks this list, so it must run down the + page the way the eye does, or F3 jumps around at random. + """ + sections = F.SECTIONS if sections is None else sections + fields = F.FIELDS if fields is None else fields + index = [] + for section in sections: + section_label = T(section.label) + index.append(Entry(SECTION, section.id, section.id, section_label, + section_label, section.surface, + (fold(section_label), fold(section.id)))) + for key in section.fields: + field = fields[key] + label = field_name(field.label) + index.append(Entry(FIELD, key, section.id, label, section_label, + section.surface, + _field_haystack(field, label, section_label))) + return tuple(index) + + +def find(index, query): + """Entries the query names, in page order. Blank query finds nothing. + + Substring rather than prefix, deliberately: "limit" has to find "Limit + pobierania" and "Limit wierszy", and a person searching a form types the + distinctive middle of a word as often as its start. A leading `--` is dropped + so that pasting a flag straight out of the README works. + """ + needle = fold(query) + if needle.startswith("--"): + needle = needle[2:] + if not needle: + return [] + return [entry for entry in index + if any(needle in straw for straw in entry.haystack)] + + +def summarise(hits): + """(how many are on this page, which entries are somewhere else). + + The second half is what turns "not found" into "it is in the Settings + window": the page shows the count for what it can jump to, and names the rest + rather than pretending they do not exist. + """ + here = [entry for entry in hits if entry.on_control] + elsewhere = [entry for entry in hits if not entry.on_control] + return here, elsewhere diff --git a/beantester/gui/pages/__init__.py b/beantester/gui/pages/__init__.py index c52c04b..3ff5621 100644 --- a/beantester/gui/pages/__init__.py +++ b/beantester/gui/pages/__init__.py @@ -23,4 +23,31 @@ class Page(NamedTuple): Page(ConnsPage.ID, ConnsPage.LABEL, ConnsPage), ) -__all__ = ["PAGES", "Page", "ControlPage", "StatsPage", "ConnsPage"] +SEARCH_FALLBACK = ConnsPage.ID + + +def focus_search(app): + """Ctrl+F: put the caret in the search box of the page the user is looking at. + + 🔴 There are TWO search boxes now (the connection table and the Control + page's field search) and only one Ctrl+F. Both pages used to bind it on the + ROOT - and a root binding without ``add="+"`` REPLACES the one before it, so + whichever page happened to be built second would have silently taken the + shortcut away from the other. One dispatcher, bound by both, cannot do that. + + From a page with no search box the shortcut still does what it has always + done: bring the connection table forward and start typing there. That is the + older behaviour and the one people already have in their fingers. + """ + page = app.current_page() + if page is not None and hasattr(page, "focus_search"): + page.focus_search() + return "break" + app.select_page(SEARCH_FALLBACK) + fallback = app.pages.get(SEARCH_FALLBACK) + if fallback is not None: + fallback.focus_search() + return "break" + + +__all__ = ["PAGES", "Page", "ControlPage", "StatsPage", "ConnsPage", "focus_search"] diff --git a/beantester/gui/pages/conns.py b/beantester/gui/pages/conns.py index 9346c0a..61bb5f2 100644 --- a/beantester/gui/pages/conns.py +++ b/beantester/gui/pages/conns.py @@ -204,8 +204,9 @@ def __init__(self, app, parent): # Bound here rather than in App._bind_shortcuts because the search box # belongs to this page - and because app.py sits on the size ratchet. with crashlog.quiet("gui.pages.conns"): - app.root.bind("", self._focus_search) - app.root.bind("", self._focus_search) + from . import focus_search as _dispatch # one shortcut, two boxes + app.root.bind("", lambda e: _dispatch(app)) + app.root.bind("", lambda e: _dispatch(app)) # The same "?" affordance the expression fields use (gui/form.py): the search # box understands `port:443` and `ip:10.0.0.0/8`, and a cheat sheet you can # read is the only way anyone finds that out. A tooltip cannot be it - it @@ -393,8 +394,12 @@ def _choose_columns(self): self.table.set_visible_columns(chosen) self.app.ui.set("conn_columns", list(self.table.visible_columns())) - def _focus_search(self, _event=None): - """Ctrl+F: show this page and put the caret in its search box.""" + def focus_search(self, _event=None): + """Ctrl+F: show this page and put the caret in its search box. + + Public and named the same on every page that has a box, because the + dispatcher in `gui/pages/__init__.py` finds it by that name. + """ with crashlog.quiet("gui.pages.conns"): self.app.select_page(self.ID) self._search_entry.focus_set() diff --git a/beantester/gui/pages/control.py b/beantester/gui/pages/control.py index d352b40..68a6d35 100644 --- a/beantester/gui/pages/control.py +++ b/beantester/gui/pages/control.py @@ -2,10 +2,22 @@ Contains no natively scrollable widget (no Treeview/Text), which is the rule that keeps the mouse-wheel dispatcher unambiguous. + +The search bar at the top marks and jumps; it never filters. Hiding what does +not match would fight two things this page already does: fields are packed +left-to-right in rows (so a hidden field leaves a hole in its row) and sections +are spread over two columns by weights computed at build time (so sections +disappearing rebalances the page under the reader). Marking keeps the page still +and keeps the user's sense of where things live - see `gui/form_search.py` for +what counts as a match. """ +import tkinter as tk from tkinter import ttk +from ... import crashlog +from ...fields import BOOL, FIELDS from ...i18n import T +from .. import form_search as search from ..form import ControlForm from ..labels import wrapping_label from ..scaling import scaled @@ -13,6 +25,17 @@ from ..theme import popdown_height, popdown_width from ..tooltip import add_tooltip +# 🔴 The query lives here, at module level, on purpose. It must survive a rebuild +# of this page - a language switch rebuilds the whole UI, and crossing the +# two-column threshold rebuilds the form - and it must NOT survive closing the +# program (owner's decision, 2026-08-18: a search box that greets you with last +# week's word is a puzzle, not a convenience). A module global is exactly that +# lifetime. It is not on `App` because that file sits on the code-shape ratchet +# with zero headroom, and because the App has no other reason to know. +_LAST_QUERY = [""] + +SEARCH_DEBOUNCE_MS = 120 + class ControlPage: ID = "control" @@ -27,14 +50,267 @@ def __init__(self, app, parent): rule = ttk.Frame(self.frame, style="Line.TFrame", height=max(1, scaled(1))) rule.pack(side="top", fill="x") rule.pack_propagate(False) + # Search state, before the bar that writes it and the form it points into. + self._index = search.build_index() + self._targets = [] # entries Enter walks, in page order + self._at = 0 # which one is current + self._marks = [] # per target: (widget, kind, style it had) + self._opened = set() # sections the SEARCH opened, to close again + self._job = None + self._build_search_bar() self.scroll = ScrollableFrame(self.frame, top_margin=scaled(8)) - self.form = ControlForm(self.scroll.body, app, scroller=self.scroll, extras={ + self.form = ControlForm(self.scroll.body, app, scroller=self.scroll, + on_rebuilt=self._reapply, extras={ "target": self._build_target, "advanced": self._build_advanced, "repro": self._build_repro, "profiles": self._build_profiles, }) app.form = self.form + # A query typed before a language switch (or before the window was + # widened into two columns) is still in the box after the rebuild, so the + # marks have to come back with it. + if _LAST_QUERY[0]: + self.frame.after_idle(self._apply) + + # -- search -------------------------------------------------------------- # + def _build_search_bar(self): + """The bar sits on the RIGHT, against the page's own margin. + + On the left it read as a stray label floating above the first section with + the whole width empty beside it. On the right it lines up with the page + margin, sits clear of the eye's path down the first column, and lands + where a find box is looked for. + + Packed right to left, and in this order for a reason: the COUNT is pinned + to the margin with a fixed width, then the box, then its label - so none + of them shift as the count grows from "1 / 9" to "10 / 12". The sentence + that names another window is a separate label further left, free to grow + into empty space without pushing the box around. + """ + bar = ttk.Frame(self.frame) + bar.pack(side="top", fill="x", padx=(scaled(12), scaled(14)), + pady=(scaled(8), scaled(2))) + self._count = ttk.Label(bar, text="", style="Muted.TLabel", + width=9, anchor="e") + self._count.pack(side="right") + self.query_var = tk.StringVar(value=_LAST_QUERY[0]) + entry = ttk.Entry(bar, textvariable=self.query_var, width=24) + entry.pack(side="right", padx=(scaled(5), scaled(6))) + entry.bind("", self._on_key) + entry.bind("", lambda e: self._step(1)) + entry.bind("", lambda e: self._step(-1)) + entry.bind("", lambda e: self.clear()) + add_tooltip(entry, "tips.control_search", shortcut="Ctrl+F") + self._entry = entry + ttk.Label(bar, text=T("fields.search")).pack(side="right") + # Free to grow leftwards: "..." is in the Settings window, or "nothing + # matches". Never between the box and the count, which must not move. + self._note = ttk.Label(bar, text="", style="Muted.TLabel", anchor="e") + self._note.pack(side="right", padx=(0, scaled(10))) + # Bound on the ROOT through the shared dispatcher, for the same reason the + # connection table does it: a shortcut that only works once the caret is + # already in the box is not a shortcut. The dispatcher is what keeps the + # two boxes from taking Ctrl+F away from each other. + with crashlog.quiet("gui.pages.control"): + from . import focus_search as _dispatch + self.app.root.bind("", lambda e: _dispatch(self.app)) + self.app.root.bind("", lambda e: _dispatch(self.app)) + + def focus_search(self): + """Put the caret in the box (the Ctrl+F path, see gui/app.py).""" + self._entry.focus_set() + self._entry.select_range(0, "end") + + def _on_key(self, event): + # Enter and Escape have their own bindings; letting them through here + # would re-run the search a second time on the same keystroke. + if getattr(event, "keysym", "") in ("Return", "Escape"): + return + if self._job is not None: + with crashlog.quiet("gui.pages.control"): + self.frame.after_cancel(self._job) + self._job = self.frame.after(SEARCH_DEBOUNCE_MS, self._apply) + + def clear(self): + self.query_var.set("") + self._apply() + + def _apply(self): + """Mark what matches, reveal it, and say what was found. The whole feature.""" + self._job = None + query = self.query_var.get() + _LAST_QUERY[0] = query + self._unmark() + hits = search.find(self._index, query) + here, elsewhere = search.summarise(hits) + self._targets, self._marks = self._claim(here) + self._at = 0 + if not query.strip(): + self._restore_folds() + self._say("", "") + return + if self._targets: + self._paint() + self._reveal(self._targets[0]) + self._say(self._count_text(), self._note_text(elsewhere)) + + def _claim(self, here): + """Pair every hit with the widget that will show it - one widget, one hit. + + 🔴 Two hits CAN land on the same widget: a dropdown field has no text of + its own, so it marks its section header, and that header is often a hit in + its own right (searching "ruch" matches the section AND the filter inside + it). Measured on real Tk before this: the header was painted as the + current hit and then repainted as an ordinary one by the later target, so + "1 / 5" was shown with nothing filled anywhere - the count promising a + place the eye could not find. + + The second claim on a widget is therefore dropped, which keeps the + invariant this feature rests on: as many marks as the count says, each in + its own place. + """ + targets, marks, seen = [], [], [] + for entry in here: + mark = self._mark(entry) + if mark is None or any(mark[0] is widget for widget in seen): + continue + seen.append(mark[0]) + targets.append(entry) + marks.append(mark) + return targets, marks + + def _say(self, count, note): + self._count.config(text=count) + self._note.config(text=note) + + def _count_text(self): + return "%d / %d" % (self._at + 1, len(self._targets)) if self._targets else "" + + def _note_text(self, elsewhere): + if elsewhere: + # Not a dead end: the field exists, it simply renders in another + # window, and saying so is the whole point of indexing that surface. + return T("fields.search_elsewhere", name=elsewhere[0].label) + return "" if self._targets else T("fields.search_none") + + def _step(self, delta): + """Next hit, wrapping. Enter forwards, Shift+Enter back.""" + if not self._targets: + return + self._at = (self._at + delta) % len(self._targets) + self._paint() + self._reveal(self._targets[self._at]) + self._count.config(text=self._count_text()) + + def _widget_for(self, entry): + if entry.kind == search.SECTION: + panel = self.form.sections.get(entry.section_id) + return panel.header if panel else None + # A checkbox field has no separate label - the widget IS the label + # (gui/form.py::_place_one), so fall back to the entry widget. + return self.form.labels.get(entry.key) or self.form.entries.get(entry.key) + + def _mark(self, entry): + """Claim the widget that will carry this hit: (widget, kind, old style). + + Which widget it is comes from the REGISTRY, not from asking the widget + what it is - the fake Tk the tests run on has one widget class for + everything, and more importantly the registry is where the answer belongs: + + * a field with a label of its own -> that label, + * a checkbox field -> the checkbox, whose text IS its label, + * anything else (a dropdown, or a section with no fields) -> the SECTION + HEADER, because a hit the page cannot point at is a hit the user cannot + see. This is the case that made the count lie: the traffic filter is a + dropdown, so before this it was counted and never marked. + """ + widget, kind = None, "label" + if entry.kind == search.FIELD: + widget = self.form.labels.get(entry.key) + if widget is None: + field = FIELDS.get(entry.key) + if field is not None and field.kind == BOOL: + widget, kind = self.form.entries.get(entry.key), "check" + if widget is None: + panel = self.form.sections.get(entry.section_id) + widget, kind = (panel.header if panel is not None else None), "section" + if widget is None: + return None + with crashlog.quiet("gui.pages.control"): + return (widget, kind, str(widget.cget("style") or "")) + return None + + HIT_STYLES = {"label": ("Hit.TLabel", "HitDim.TLabel"), + "check": ("Hit.TCheckbutton", "HitDim.TCheckbutton"), + "section": ("Hit.Section.TButton", "HitDim.Section.TButton")} + + def _paint(self): + """Fill the current hit, tint the rest. + + Every match looking identical is what made "3 / 7" useless: Enter moved + the page and nothing on it said which one you had arrived at. + """ + for index, mark in enumerate(self._marks): + widget, kind, _old = mark + current, other = self.HIT_STYLES[kind] + with crashlog.quiet("gui.pages.control"): + widget.configure(style=current if index == self._at else other) + + def _unmark(self): + for widget, _kind, old in self._marks: + # A widget destroyed by a rebuild cannot be put back, and that is not + # a failure worth a dialog - but it is worth the crash log saying so. + with crashlog.quiet("gui.pages.control"): + widget.configure(style=old) + self._marks = [] + # The style a field SHOULD have can have changed while it was marked (a + # schedule taking over the rate fields greys their labels), so the form + # gets the last word rather than the style we happened to remember. + with crashlog.quiet("gui.pages.control"): + self.form.apply_overrides() + + def _reveal(self, entry): + """Open the section a hit sits in and scroll to it. + + 🔴 `set_open`, never `toggle`: toggling runs the accordion's callback, + which writes the fold state into `ui.json` through `App.on_sections_changed` + - so searching would permanently unfold the sections the user had chosen + to keep closed. What the search opened it closes again in + `_restore_folds`. + """ + panel = self.form.sections.get(entry.section_id) + if panel is None: + return + if not panel.is_open: + panel.set_open(True) + # Track what the SEARCH opened rather than snapshotting what was + # closed when it started: a user who opens a section by hand + # mid-search means to keep it open, and a snapshot would fold it away + # again the moment the box was cleared. + self._opened.add(entry.section_id) + widget = self._widget_for(entry) or panel.frame + with crashlog.quiet("gui.pages.control"): + self.frame.after_idle(lambda: self.scroll.ensure_visible(widget)) + + def _restore_folds(self): + for section_id in sorted(self._opened): + panel = self.form.sections.get(section_id) + if panel is not None and panel.is_open: + panel.set_open(False) + self._opened = set() + + def _reapply(self): + """The form rebuilt itself (column switch): the marks referred to dead widgets. + + The rebuild reads the fold state back from the App, so whatever the search + had opened is closed again by the rebuild itself - the page must forget it + rather than trying to close it twice. + """ + self._marks = [] + self._opened = set() + if _LAST_QUERY[0]: + self._apply() # -- extra widgets referenced by fields.SECTIONS ------------------------- # def _build_target(self, body): diff --git a/beantester/gui/theme.py b/beantester/gui/theme.py index 28f409f..5f1689f 100644 --- a/beantester/gui/theme.py +++ b/beantester/gui/theme.py @@ -85,6 +85,12 @@ # nearly invisible. They get their own, lighter surface plus an outline. BTN_BG, BTN_HOVER, BTN_BORDER = "#394152", "#4a5468", "#525d73" DONATE_C = "#ff8fb1" # the support button (not a session control - own colour) +# The Control page's search highlight. Amber because every other meaning on this +# page is already spoken for: blue is the accent (headers, links, "?"), green is +# running, red is a fault, pink is the support button. A hit is none of those - it +# is "you asked for this one" - and it has to survive being read at a glance on a +# page whose every heading is blue. +HIT, HIT_TEXT = "#f5c451", "#16181d" SCROLL_BG = "#3a4150" # scrollbar thumb SCROLL_TROUGH = "#20232b" @@ -108,6 +114,17 @@ def _style_surfaces(s): # (ttk paints a disabled ttk.Label with a filled box, which looked broken) s.configure("CardOff.TLabel", background=BG2, foreground=DIS_FG, font=(FONT, 9)) s.configure("Unit.TLabel", background=BG2, foreground=MUT, font=(FONT, 9)) + # 🔴 Search hits, in TWO strengths - and in a colour this page uses nowhere + # else. The first version painted them in ACC, which is the accent every + # section header, "?" button and link already wears: on a page this blue the + # highlight simply disappeared. Amber is unused here, so it can only mean + # "this is what you typed". + # Hit = the one you are ON: filled, like a marker pen, so it is findable + # from across the window and tells you where Enter has taken you. + # HitDim = the other matches: the same amber as text only. Present, clearly + # related, not competing with the current one. + s.configure("Hit.TLabel", background=HIT, foreground=HIT_TEXT, font=(FONT, 9, "bold")) + s.configure("HitDim.TLabel", background=BG2, foreground=HIT, font=(FONT, 9, "bold")) # the "?" syntax help used to melt into the background s.configure("Help.TLabel", background=BG2, foreground=ACC, font=(FONT, 9, "bold")) # Defensive only: NOTHING in this tool switches a label with `state` today. @@ -191,6 +208,17 @@ def _style_buttons(s): font=(FONT, 9, "bold"), borderwidth=0, focuscolor=ACC, padding=(scaled(2), scaled(3)), anchor="w") s.map("Section.TButton", background=[("active", BG2)]) + # A section header is a search target too: when the thing that matched has no + # text of its own to mark (a dropdown field, or a section with no fields at + # all), the header is what the eye can be pointed at. + s.configure("Hit.Section.TButton", background=HIT, foreground=HIT_TEXT, + font=(FONT, 9, "bold"), borderwidth=0, focuscolor=ACC, + padding=(scaled(2), scaled(3)), anchor="w") + s.map("Hit.Section.TButton", background=[("active", HIT)]) + s.configure("HitDim.Section.TButton", background=BG, foreground=HIT, + font=(FONT, 9, "bold"), borderwidth=0, focuscolor=ACC, + padding=(scaled(2), scaled(3)), anchor="w") + s.map("HitDim.Section.TButton", background=[("active", BG2)]) # header gear: an icon-only button that opens the Settings window. Flat on the # header at rest (bevel colours pinned to BG so clam draws no raised edge), but @@ -214,6 +242,20 @@ def _style_checkbuttons(s): # amount of option juggling fixes. Draw the box ourselves instead. s.configure("TCheckbutton", background=BG2, foreground=FG, font=(FONT, 9), focuscolor=ACC, padding=(0, scaled(2)), borderwidth=0) + # A checkbox field IS its label (gui/form.py::_place_one draws no separate one), + # so the search highlight needs its own style here or those fields would be the + # only ones a search cannot mark. Two strengths, same as the labels. + s.configure("Hit.TCheckbutton", background=HIT, foreground=HIT_TEXT, + font=(FONT, 9, "bold"), focuscolor=ACC, + padding=(0, scaled(2)), borderwidth=0) + s.configure("HitDim.TCheckbutton", background=BG2, foreground=HIT, + font=(FONT, 9, "bold"), focuscolor=ACC, + padding=(0, scaled(2)), borderwidth=0) + for hit_style in ("Hit.TCheckbutton", "HitDim.TCheckbutton"): + # ttk would otherwise paint the hover/pressed background from the base + # style and the highlight would blink away under the pointer. + s.map(hit_style, background=[("active", HIT if hit_style == "Hit.TCheckbutton" + else BG2)]) s.map("TCheckbutton", background=[("active", BG2)], foreground=[("disabled", DIS_FG)]) diff --git a/lang/en.json b/lang/en.json index 08fda76..60d5731 100644 --- a/lang/en.json +++ b/lang/en.json @@ -204,6 +204,8 @@ "fields.schedule": "Schedule (dur:down:up,...):", "fields.schedule_overrides": "The throughput schedule is active - it replaces these limits (the rates come from its steps).", "fields.search": "Search:", + "fields.search_elsewhere": "\"{name}\" is in the Settings window", + "fields.search_none": "Nothing matches", "fields.seed": "Seed:", "fields.seed_hint": "(empty = random)", "fields.spike": "Latency spike:", @@ -461,6 +463,7 @@ "tips.conn_search": "Narrows the table. Plain text searches every column, or write port:443 or ip:10.0.0.0/8 to search one. Click ? for the full list.", "tips.conn_search_help": "How to search this table", "tips.conn_table": "Who (process) talks to whom (IP:port). 'time[s]' is the connection duration, 'idle[s]' is seconds since the last packet. Click a header to sort. The list scrolls vertically and horizontally.", + "tips.control_search": "Finds a field or a section by name, or by its CLI flag such as --loss. Enter goes to the next match, Escape clears the box.", "tips.copy_cli": "Copies to the clipboard a ready CLI command that reproduces these conditions (a frozen .exe uses its own name).", "tips.corrupt": "Percent of packets with one flipped data bit - tests resilience to corrupted data.", "tips.data_down": "How much data actually passed downstream (download) since start - real usage. Packets dropped by loss or a speed limit are not counted here.", diff --git a/lang/pl.json b/lang/pl.json index ac045c6..1e90b72 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -204,6 +204,8 @@ "fields.schedule": "Harmonogram (dur:down:up,...):", "fields.schedule_overrides": "Aktywny harmonogram przepustowości - zastępuje te limity (przepustowość bierze się z jego kroków).", "fields.search": "Szukaj:", + "fields.search_elsewhere": "„{name}” jest w oknie Ustawienia", + "fields.search_none": "Nic nie pasuje", "fields.seed": "Seed:", "fields.seed_hint": "(puste = losowo)", "fields.spike": "Skok latencji:", @@ -461,6 +463,7 @@ "tips.conn_search": "Zawezenie tabeli. Zwykly tekst szuka po wszystkich kolumnach, a port:443 czy ip:10.0.0.0/8 w jednej. Pelna lista pod przyciskiem ?.", "tips.conn_search_help": "Jak szukac w tej tabeli", "tips.conn_table": "Kto (proces) z kim (IP:port) gada. 'czas[s]' to czas trwania połączenia, 'nieakt.[s]' to ile sekund minęło od ostatniego pakietu. Klik w nagłówek sortuje. Lista przewija się pionowo i poziomo.", + "tips.control_search": "Znajduje pole albo sekcję po nazwie, albo po fladze CLI takiej jak --loss. Enter przechodzi do następnego trafienia, Escape czyści pole.", "tips.copy_cli": "Kopiuje do schowka gotową komendę CLI, która odtwarza te warunki (w zbudowanym .exe użyje jego nazwy).", "tips.corrupt": "Procent pakietów z przekłamanym jednym bitem danych - test odporności na uszkodzone dane.", "tips.data_down": "Ile danych faktycznie przeszło w dół (pobieranie) od startu - realne zużycie. Pakiety porzucone przez utratę albo limit prędkości nie są tu liczone.", diff --git a/tests/test_conns_columns.py b/tests/test_conns_columns.py index d05d5e7..84c9608 100644 --- a/tests/test_conns_columns.py +++ b/tests/test_conns_columns.py @@ -204,7 +204,7 @@ def test_the_table_is_reachable_and_readable_without_a_mouse(): assert "" in root_binds, \ "no Ctrl+F on the main window: %r" % sorted(root_binds) - page._focus_search() + page.focus_search() assert app.current_page() is page, \ "Ctrl+F did not bring the Connections page forward" assert root.focus_get() is page._search_entry, \ diff --git a/tests/test_form_search.py b/tests/test_form_search.py new file mode 100644 index 0000000..d838546 --- /dev/null +++ b/tests/test_form_search.py @@ -0,0 +1,143 @@ +"""``gui/form_search.py`` - finding a setting on the Control page by name. + +The matching half is pure, so it is tested here without Tk and without a +subprocess; the highlighting and the scrolling are guarded in +``test_gui_layout.py``. + +Most of these build their own tiny registry rather than asserting on real +Polish labels: a test pinned to a translation breaks the day somebody improves +the wording, which teaches the wrong lesson. The two that DO use the shipped +registry ask questions the synthetic one cannot - that every field is reachable, +and that accented labels are reachable without their accents. +""" +from beantester import fields as F +from beantester.gui import form_search as S +from beantester.i18n import set_language +from fakes import check + + +class FakeField: + """The four attributes the index reads. Anything else is not its business.""" + + def __init__(self, key, label, cli=""): + self.key = key + self.label = label + self.cli = cli + + +def tiny_index(monkeypatched_labels=None): + """A two-section registry whose labels are literal, so assertions can be too.""" + sections = ( + F.Section("engine", "Engine room", ("throttle", "brake")), + F.Section("view", "View options", ("row_limit",), surface="settings"), + ) + fields = {"throttle": FakeField("throttle", "Throttle limit", cli="throttle"), + "brake": FakeField("brake", "Emergency brake", cli="brake-hard"), + "row_limit": FakeField("row_limit", "Row limit", cli="rows")} + labels = monkeypatched_labels or {} + + # `build_index` translates through i18n; here the "translation" is the label + # itself, which is what makes the expectations readable. + real_t, real_field_name = S.T, S.field_name + S.T = lambda key, **kw: labels.get(key, key) + S.field_name = lambda key, **kw: labels.get(key, key) + try: + return S.build_index(sections, fields) + finally: + S.T, S.field_name = real_t, real_field_name + + +def test_the_index_carries_every_section_and_every_field(): + """A registry entry nobody indexed is a setting the search cannot find.""" + set_language("en") + index = S.build_index() + sections = [e for e in index if e.kind == S.SECTION] + fields = [e for e in index if e.kind == S.FIELD] + check("search: one entry per section", len(sections) == len(F.SECTIONS), + f"({len(sections)} against {len(F.SECTIONS)})") + placed = [key for s in F.SECTIONS for key in s.fields] + check("search: one entry per placed field", len(fields) == len(placed), + f"({len(fields)} against {len(placed)})") + missing = sorted(set(placed) - {e.key for e in fields}) + check("search: no field is left out of the index", not missing, f"({missing})") + + +def test_a_blank_query_finds_nothing(): + """The empty state is the common one - it must not light up the whole page.""" + index = tiny_index() + for query in ("", " ", "\t", None, "--"): + check(f"search: {query!r} finds nothing", S.find(index, query) == []) + + +def test_matching_ignores_case_and_finds_the_middle_of_a_word(): + index = tiny_index() + hits = [e.key for e in S.find(index, "THROTTLE")] + check("search: case is ignored", "throttle" in hits, f"({hits})") + hits = [e.key for e in S.find(index, "mergency")] + check("search: a substring in the middle matches", "brake" in hits, f"({hits})") + + +def test_a_section_name_finds_the_section_and_its_fields(): + """Somebody typing a group name is looking for the group, not one field.""" + index = tiny_index() + hits = S.find(index, "engine room") + kinds = {(e.kind, e.key) for e in hits} + check("search: the section itself is a hit", (S.SECTION, "engine") in kinds, f"({kinds})") + check("search: its fields come with it", + {(S.FIELD, "throttle"), (S.FIELD, "brake")} <= kinds, f"({kinds})") + + +def test_a_cli_flag_finds_the_field_it_drives(): + """`--loss` is what the README and every repro command say; typing it must work.""" + index = tiny_index() + for query in ("brake-hard", "--brake-hard", "brake_hard"): + hits = [e.key for e in S.find(index, query)] + check(f"search: {query!r} finds its field", hits == ["brake"], f"({hits})") + + +def test_hits_come_back_in_page_order(): + """`F3` walks this list, so it has to run down the page the way the eye does.""" + index = tiny_index() + order = [e.key for e in index] + hits = S.find(index, "limit") # matches "Throttle limit" and "Row limit" + positions = [order.index(e.key) for e in hits] + check("search: hits are in document order", positions == sorted(positions), + f"({[e.key for e in hits]})") + + +def test_a_settings_field_is_found_but_never_offered_as_a_jump(): + """Decision of 2026-08-18: say where it lives instead of shrugging. + + The Control page cannot scroll to a field that renders in another window, so + the split is what stops the page promising a jump it cannot make. + """ + index = tiny_index() + here, elsewhere = S.summarise(S.find(index, "row limit")) + check("search: nothing to jump to on this page", here == [], f"({[e.key for e in here]})") + check("search: the settings field is reported instead", + [e.key for e in elsewhere] == ["row_limit"], f"({[e.key for e in elsewhere]})") + check("search: and it knows which window to name", + elsewhere[0].section_label == "View options", f"({elsewhere[0].section_label})") + + +def test_an_accented_label_is_reachable_without_its_accents(): + """🔴 The reason `fold` exists: people type Polish without the diacritics. + + Derived from the shipped labels rather than hard-coded, so it keeps asking + the real question after any wording change: take a real label that HAS an + accent, strip it, and demand that the stripped spelling still finds it. + """ + set_language("pl") + index = S.build_index() + accented = [e for e in index + if e.kind == S.FIELD and S.fold(e.label) != e.label.casefold()] + check("search: the Polish labels do carry accents (else this test is empty)", + accented, "(no accented label found)") + entry = accented[0] + stripped = S.fold(entry.label) + hits = [e.key for e in S.find(index, stripped)] + check(f"search: {entry.label!r} is reachable as {stripped!r}", + entry.key in hits, f"({hits})") + check("search: and it is still reachable WITH the accents", + entry.key in [e.key for e in S.find(index, entry.label)]) + set_language("en") diff --git a/tests/test_gui_layout.py b/tests/test_gui_layout.py index bbca32f..10adb66 100644 --- a/tests/test_gui_layout.py +++ b/tests/test_gui_layout.py @@ -395,3 +395,281 @@ def stretched(widget, found=None): bad = stretched(root) assert not bad, bad """) + + +# -- the Control-page search -------------------------------------------------- # + + +def test_searching_marks_what_matched_and_counts_it(): + """The whole promise in one run: type, see the match bolded, see how many.""" + run_gui(""" + page = app.pages["control"] + page.query_var.set("loss") + page._apply() + + label = app.form.labels["loss"] + assert label.cget("style") == "Hit.TLabel", label.cget("style") + assert page._count.cget("text").startswith("1 / "), page._note.cget("text") + + # ...and the field next to it, which did not match, is untouched + other = app.form.labels["latency"] + assert other.cget("style") == "Card.TLabel", other.cget("style") + """) + + +def test_clearing_the_search_puts_every_style_back(): + """A mark left behind after the box is empty is a page that looks broken.""" + run_gui(""" + page = app.pages["control"] + before = app.form.labels["loss"].cget("style") + page.query_var.set("loss") + page._apply() + assert app.form.labels["loss"].cget("style") == "Hit.TLabel" + + page.clear() + assert app.form.labels["loss"].cget("style") == before, ( + app.form.labels["loss"].cget("style")) + assert page._note.cget("text") == "" and page._count.cget("text") == "" + """) + + +def test_a_hit_in_a_folded_section_is_opened_but_never_remembered(): + """🔴 The regression this feature could most easily cause. + + Fold state is persisted (`App.on_sections_changed` -> `ui.json`), so opening a + section through the accordion's own toggle would make a search permanently + unfold what the user had chosen to keep closed. The search must open it for + the length of the search and put it back. + """ + run_gui(""" + page = app.pages["control"] + panel = app.form.sections["advanced"] + panel.set_open(False) + app.on_sections_changed(["advanced"]) + assert app.collapsed_sections == ["advanced"] + written = app.ui.get("collapsed") + + page.query_var.set("--syn-drop") # a field inside that section + page._apply() + assert panel.is_open, "the section holding the hit must be opened" + assert app.collapsed_sections == ["advanced"], ( + "opening it for a search must not change the remembered state: " + + str(app.collapsed_sections)) + assert app.ui.get("collapsed") == written, "nothing may be written to ui.json" + + page.clear() + assert not panel.is_open, "clearing the search must fold it back" + """) + + +def test_a_field_that_lives_in_the_settings_window_says_so(): + """Decision of 2026-08-18: a hit this page cannot jump to still answers.""" + run_gui(""" + from beantester.i18n import T + page = app.pages["control"] + page.query_var.set("row_limit") # renders in the Settings window + page._apply() + text = page._note.cget("text") + assert text and "/" not in text, "there is nothing on this page to walk: " + text + assert text == T("fields.search_elsewhere", name=T("fields.row_limit").rstrip(":")), text + assert not page._marks, "nothing on this page may be marked" + """) + + +def test_a_query_that_matches_nothing_says_so_instead_of_going_quiet(): + run_gui(""" + from beantester.i18n import T + page = app.pages["control"] + page.query_var.set("zzzznothing") + page._apply() + assert page._note.cget("text") == T("fields.search_none"), page._note.cget("text") + """) + + +def test_the_search_survives_the_form_rebuilding_itself(): + """Crossing the two-column threshold destroys every widget in the form. The + marks pointed at those widgets, so without the rebuild hook the page would + come back with a query in the box and nothing marked.""" + run_gui(""" + page = app.pages["control"] + page.query_var.set("loss") + page._apply() + assert app.form.labels["loss"].cget("style") == "Hit.TLabel" + + app.form.set_columns(2) # rebuilds the whole form + assert app.form.columns == 2 + assert app.form.labels["loss"].cget("style") == "Hit.TLabel", ( + "the mark did not come back after the rebuild") + """) + + +def test_only_the_control_page_has_a_search_box(): + """The Settings window renders the SAME ControlForm; the search belongs to the + page, not to the form, or it would appear in both.""" + run_gui(""" + from beantester.gui.form import ControlForm + assert hasattr(app.pages["control"], "query_var") + assert not hasattr(ControlForm, "query_var") + + app.windows.open("settings") + panel = app.windows._open.get("settings") + assert panel is not None + assert not hasattr(panel, "query_var"), "the Settings window grew a search box" + # its form is a ControlForm all the same - that is the point of the check + assert isinstance(getattr(panel, "form", None), ControlForm) + """) + + +def test_one_ctrl_f_reaches_whichever_search_box_is_in_front(): + """🔴 Two boxes, one shortcut - and a root binding without `add` REPLACES the + one before it, so the page built second would have silently taken Ctrl+F away + from the other. From a page with no box the shortcut keeps its older + behaviour: bring the connection table forward and type there.""" + run_gui(""" + from beantester.gui.pages import focus_search + control, conns = app.pages["control"], app.pages["connections"] + + app.select_page("control") + focus_search(app) + assert app.current_page() is control, "Ctrl+F left the Control page" + assert root.focus_get() is control._entry, "the caret missed the field search" + + app.select_page("connections") + focus_search(app) + assert root.focus_get() is conns._search_entry, "the caret missed the table search" + + app.select_page("statistics") + focus_search(app) + assert app.current_page() is conns, "from a page with no box it must fall back" + assert root.focus_get() is conns._search_entry + """) + + +def test_the_hit_you_are_on_looks_different_from_the_rest(): + """Reported by the owner: with several matches, Enter moved the page and + nothing said WHICH one you had arrived at - every match looked the same. + + The current one is filled (`Hit.*`), the others are tinted (`HitDim.*`), and + the pair moves together with the count. + """ + run_gui(""" + page = app.pages["control"] + page.query_var.set("port") # matches two fields, no section + page._apply() + assert len(page._targets) > 1, page._targets + + first = page._marks[0][0] + second = page._marks[1][0] + assert first.cget("style") == "Hit.TLabel", first.cget("style") + assert second.cget("style") == "HitDim.TLabel", second.cget("style") + assert page._count.cget("text") == "1 / %d" % len(page._targets) + + page._step(1) + assert first.cget("style") == "HitDim.TLabel", "the old current stayed filled" + assert second.cget("style") == "Hit.TLabel", "the new current was not filled" + assert page._count.cget("text") == "2 / %d" % len(page._targets) + + # ...and it wraps rather than stopping at the end + for _ in range(len(page._targets) - 1): + page._step(1) + assert page._count.cget("text") == "1 / %d" % len(page._targets) + assert first.cget("style") == "Hit.TLabel" + """) + + +def test_the_search_highlight_is_not_the_colour_everything_else_uses(): + """Also reported by the owner: the first version painted hits in the page + accent, which is the colour of every section header, link and "?" button - so + the highlight vanished into the page it was meant to stand out from. + + The check is that the colours DIFFER, not that any particular one was picked: + the palette may be retuned, the meaning may not collide again. + """ + run_gui(""" + from beantester.gui import theme + assert theme.HIT != theme.ACC, "the highlight is the page accent again" + assert theme.HIT not in (theme.BG, theme.BG2), "it is the surface colour" + assert theme.HIT not in (theme.OK, theme.WARN, theme.DONATE_C), ( + "it now means the same as running / faulty / support") + assert theme.HIT_TEXT != theme.HIT, "the text is the same colour as its fill" + assert theme.HIT_TEXT in (theme.BG, theme.BG2) or theme.HIT_TEXT < "#404040", ( + "text on the fill has to be the dark end, or it will not read") + """) + + +def test_a_match_with_no_text_of_its_own_marks_its_section(): + """The traffic filter is a dropdown and Profiles has no fields at all: both + were counted and never marked, so the count promised something the page did + not show.""" + run_gui(""" + page = app.pages["control"] + page.query_var.set("profil") # a section with no fields of its own + page._apply() + assert page._targets, "the Profiles section must be reachable" + widget, kind, _old = page._marks[0] + assert kind == "section", kind + assert widget is app.form.sections["profiles"].header + assert widget.cget("style") == "Hit.Section.TButton", widget.cget("style") + + page.clear() + assert widget.cget("style") == "Section.TButton", widget.cget("style") + """) + + +def test_a_section_the_user_opens_during_a_search_is_left_open(): + """The page closes what IT opened, not everything that happened to be closed + when the search started - otherwise clearing the box folds away a section the + user deliberately opened while looking at the results.""" + run_gui(""" + page = app.pages["control"] + advanced, block = app.form.sections["advanced"], app.form.sections["block"] + advanced.set_open(False) + block.set_open(False) + app.on_sections_changed(["advanced", "block"]) + + page.query_var.set("--syn-drop") # lives in "advanced" + page._apply() + assert advanced.is_open, "the section holding the hit must open" + assert not block.is_open + + block.set_open(True) # the user opens another one by hand + page.clear() + assert not advanced.is_open, "what the search opened must close again" + assert block.is_open, "what the USER opened must stay open" + """) + + +def test_a_query_of_nothing_but_punctuation_is_not_a_search(): + """Edge cases that must not raise or light up the page: a lone flag prefix, + spaces, and characters that would be a regular expression somewhere else.""" + run_gui(""" + page = app.pages["control"] + for query in ("--", " ", "(", "*", ".*", "re:", "]["): + page.query_var.set(query) + page._apply() + if query.strip() in ("--", ""): + assert page._count.cget("text") == "", (query, page._count.cget("text")) + assert not page._marks, query + # whatever it finds, it must not raise and must not leave the page + # marked once it is cleared + page.clear() + assert not page._marks and page._count.cget("text") == "" + """) + + +def test_two_hits_never_fight_over_one_widget(): + """Measured on real Tk: "ruch" matches the traffic SECTION and the dropdown + inside it, and a dropdown has no text of its own so it marks the same header. + The header was painted current and then repainted as an ordinary match by the + later hit, so the count said "1 / 5" with nothing filled anywhere.""" + run_gui(""" + page = app.pages["control"] + page.query_var.set("ruch") + page._apply() + widgets = [m[0] for m in page._marks] + assert len(widgets) == len(set(id(w) for w in widgets)), "a widget marked twice" + assert len(page._marks) == len(page._targets), "count and marks disagree" + filled = [m[0] for m in page._marks if "HitDim" not in str(m[0].cget("style"))] + assert len(filled) == 1, "exactly one hit is the current one: %d" % len(filled) + assert filled[0] is page._marks[page._at][0] + """) diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index f09089b..a675c22 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -499,11 +499,19 @@ "test": "test_every_error_reads_like_a_sentence", }, { - "label": "keyboard: Ctrl+F is bound on the entry, not on the window", - "file": "beantester/gui/pages/conns.py", - "old": " app.root.bind(\"\", self._focus_search)", - "new": " entry.bind(\"\", self._focus_search)", - "test": "test_the_table_is_reachable_and_readable_without_a_mouse", + # Replaced 2026-08-18, and the reason is worth more than the entry was. + # This used to break the Connections page's Ctrl+F by moving its binding + # from the root onto the entry. Since the Control page grew a search box + # of its own, BOTH pages bind the same dispatcher on the root - so losing + # one of the two bindings changes nothing a user can see, and the old + # mutation SURVIVED without anything being wrong. What is worth guarding + # now is the dispatcher's decision: the shortcut must reach the box on the + # page you are looking at, not always the table. + "label": "keyboard: Ctrl+F ignores which page is in front", + "file": "beantester/gui/pages/__init__.py", + "old": " page = app.current_page()", + "new": " page = None", + "test": "test_one_ctrl_f_reaches_whichever_search_box_is_in_front", }, { "label": "public: the privacy scan reads an empty file list", @@ -533,6 +541,46 @@ "new": " colours = {var: \"#010203\" for var in registry[\"palette\"]}", "test": "test_the_palette_is_read_out_of_the_theme_module", }, + { + # The regression this feature could most easily cause: a search that + # unfolds the page FOR GOOD. `toggle` runs the accordion's callback, which + # persists the fold state through App.on_sections_changed; `set_open` does + # not, which is the whole reason the reveal path uses it. + "label": "search: revealing a hit writes the fold state to ui.json", + "file": "beantester/gui/pages/control.py", + "old": " if not panel.is_open:\n panel.set_open(True)", + "new": " if not panel.is_open:\n panel.toggle()", + "test": "test_a_hit_in_a_folded_section_is_opened_but_never_remembered", + }, + { + # The owner's report: with several matches, nothing said which one Enter + # had taken you to. If every hit is painted the same the count is a + # promise the page does not keep. + "label": "search: every match is painted as the current one", + "file": "beantester/gui/pages/control.py", + "old": " widget.configure(style=current if index == self._at else other)", + "new": " widget.configure(style=current)", + "test": "test_the_hit_you_are_on_looks_different_from_the_rest", + }, + { + # Measured on real Tk: a dropdown marks its section header, and that + # header is often a hit itself - the second claim repainted the first and + # the current hit vanished. + "label": "search: two hits may claim the same widget again", + "file": "beantester/gui/pages/control.py", + "old": " if mark is None or any(mark[0] is widget for widget in seen):", + "new": " if mark is None:", + "test": "test_two_hits_never_fight_over_one_widget", + }, + { + # Half a feature, and the half nobody can diagnose from outside: Polish + # labels carry diacritics and people type without them. + "label": "search: matching stops ignoring Polish diacritics", + "file": "beantester/gui/form_search.py", + "old": " decomposed = unicodedata.normalize(\"NFKD\", str(text or \"\"))", + "new": " decomposed = str(text or \"\")", + "test": "test_an_accented_label_is_reachable_without_its_accents", + }, { "label": "licence: the notices name a WinDivert file that is not shipped", "file": "THIRD-PARTY-NOTICES.md",