From 97e5d25d123bf342e96b64b12d509f2af6f20dee Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 18 Aug 2026 10:35:22 +0200 Subject: [PATCH 1/4] feat(gui): match a typed query against the field registry The Control page renders the whole registry - a dozen sections, forty-odd fields, several folded away on a fresh install - and there was no way to reach a setting by name. This is the pure half of that: `build_index` turns the registry plus the current translation into entries in page order, `find` matches a query against them, `summarise` separates what this page can jump to from what lives in the Settings window. Matching covers field and section NAMES plus CLI flags, deliberately not tooltip bodies: searching the tips would make one word light up half the page, which is a symptom index rather than a way to find a field. `fold()` strips diacritics through NFKD before comparing. Polish labels carry them and people type without them, so without this "opoznienie" finds nothing while the accented spelling works - a half-broken feature nobody can diagnose from the outside. The index is built after the language is set rather than at import time: a language switch rebuilds the UI precisely so that nothing keeps the old words, and an import-time index would keep answering in them. Co-Authored-By: Claude Opus 5 --- README.md | 1 + README.pl.md | 1 + beantester/gui/form_search.py | 148 ++++++++++++++++++++++++++++++++++ tests/test_form_search.py | 143 ++++++++++++++++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 beantester/gui/form_search.py create mode 100644 tests/test_form_search.py diff --git a/README.md b/README.md index de40e2e..6eef26c 100644 --- a/README.md +++ b/README.md @@ -1182,6 +1182,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..0f47a07 100644 --- a/README.pl.md +++ b/README.pl.md @@ -1037,6 +1037,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_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/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") From 7f43a79f18f57c9f5179a9508f267cc83494f60f Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 18 Aug 2026 11:26:48 +0200 Subject: [PATCH 2/4] feat(gui): find a setting on the Control page by name The page renders the whole registry and there was no way to reach a setting except by opening every section and reading. A box at the top now marks what matches and scrolls to it: Enter walks the matches, Escape clears, Ctrl+F puts the caret in the box. It marks and jumps rather than filtering. Hiding what does not match would fight the page's own layout - fields are packed left to right in rows, so a hidden field leaves a hole, and sections are spread over two columns by weights computed at build time, so sections disappearing rebalances the page under the reader. Revealing a hit uses set_open, never toggle. Toggling runs the accordion's callback, which persists the fold state, so searching would permanently unfold the sections a user had chosen to keep closed. The folds are snapshotted once per search - not per keystroke, or the second letter would record what the first letter opened - and restored when the box is cleared. A field that renders in the Settings window is indexed but never offered as a jump: the page says where it lives instead of coming back empty. The query survives a rebuild (a language switch rebuilds the UI, the two-column threshold rebuilds the form) and dies with the process. ControlForm gained on_rebuilt so the marks can be put back on widgets that no longer exist. One dispatcher now owns Ctrl+F. Both pages bind it on the root, and a root binding without add= REPLACES the one before it, so the page built second would otherwise have taken the shortcut away from the other. From a page with no search box it keeps the older behaviour and brings the connection table forward. The mutation that used to break the table's Ctrl+F survives now for a good reason - two bindings mean losing one changes nothing - so it was replaced by one that breaks the dispatcher's decision instead. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++ beantester/gui/form.py | 10 +- beantester/gui/pages/__init__.py | 29 ++++- beantester/gui/pages/conns.py | 13 +- beantester/gui/pages/control.py | 216 ++++++++++++++++++++++++++++++- beantester/gui/theme.py | 12 ++ lang/en.json | 3 + lang/pl.json | 3 + tests/test_conns_columns.py | 2 +- tests/test_gui_layout.py | 148 +++++++++++++++++++++ tests/test_mutation_registry.py | 38 +++++- 11 files changed, 471 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1083edb..05df499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ## [Unreleased] +### Added + +- **You can now search for a setting on the Control page.** Type part of a field or section + name - or the command-line flag, such as `--loss` - and the page highlights what matches and + scrolls to it. `Enter` goes to the next match, `Escape` clears the box, `Ctrl+F` puts the caret + in it. A folded section opens while the search lasts and folds itself back afterwards, so the + sections you keep closed stay closed. Accents are optional: typing `opoznienie` finds + `Opóźnienie`. If the setting lives in the Settings window, the search says so instead of coming + back empty. + ### 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/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/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..f7e8e47 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,205 @@ 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 the arrows walk, in page order + self._at = 0 # which one is current + self._marked = [] # (widget, the style it had before) + self._folds_before = None # sections the user had closed, to put back + 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): + bar = ttk.Frame(self.frame) + bar.pack(side="top", fill="x", padx=scaled(10), pady=(scaled(7), 0)) + ttk.Label(bar, text=T("fields.search")).pack(side="left") + self.query_var = tk.StringVar(value=_LAST_QUERY[0]) + entry = ttk.Entry(bar, textvariable=self.query_var, width=26) + entry.pack(side="left", padx=(scaled(4), scaled(8))) + entry.bind("", self._on_key) + 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 + # 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)) + # One line for both answers: "3 / 7" while there is something to walk, and + # the sentence naming another window when the only match lives there. + self._verdict = ttk.Label(bar, text="", style="Muted.TLabel") + self._verdict.pack(side="left") + + 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._jump_targets(here) + self._at = 0 + if not query.strip(): + self._restore_folds() + self._verdict.config(text="") + return + if self._folds_before is None: + # Snapshot ONCE per search, not per keystroke: the second letter would + # otherwise record the sections the first letter had just opened, and + # clearing the box would leave the page unfolded for good. + self._folds_before = [sid for sid, panel in self.form.sections.items() + if not panel.is_open] + for entry in here: + self._mark(entry) + if self._targets: + self._reveal(self._targets[0]) + self._verdict.config(text=self._verdict_text(elsewhere)) + + def _jump_targets(self, here): + """What the arrows walk: fields, plus a section that has no field of its own. + + A section title matches its own fields too (they carry it in their + haystack), so counting both would report "3" for a group with two fields + and mark two - a number that does not match what the eye can see. Sections + with no fields of their own (Profiles) still have to be reachable, so they + stay in. + """ + with_fields = {e.section_id for e in here if e.kind == search.FIELD} + return [e for e in here + if e.kind == search.FIELD or e.section_id not in with_fields] + + def _verdict_text(self, elsewhere): + if self._targets: + return "%d / %d" % (self._at + 1, len(self._targets)) + 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 T("fields.search_none") + + def _step(self, delta): + """Next hit, wrapping. Enter is the only key people try for this.""" + if not self._targets: + return + self._at = (self._at + delta) % len(self._targets) + self._reveal(self._targets[self._at]) + self._verdict.config(text="%d / %d" % (self._at + 1, len(self._targets))) + + 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): + """Bold the words that matched. Which widget carries them comes from the + REGISTRY, not from asking the widget what it is: a checkbox field has no + separate label because its text IS the checkbox (gui/form.py::_place_one), + and a filter dropdown has no text of its own at all - for that one the + scroll is the whole answer. + """ + if entry.kind != search.FIELD: + return + label = self.form.labels.get(entry.key) + if label is not None: + self._swap_style(label, "Hit.TLabel") + return + field = FIELDS.get(entry.key) + if field is not None and field.kind == BOOL: + widget = self.form.entries.get(entry.key) + if widget is not None: + self._swap_style(widget, "Hit.TCheckbutton") + + def _swap_style(self, widget, hit_style): + with crashlog.quiet("gui.pages.control"): + self._marked.append((widget, str(widget.cget("style") or ""))) + widget.configure(style=hit_style) + + def _unmark(self): + for widget, style in self._marked: + # 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=style) + self._marked = [] + + 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. The page opens them for the length of the search and puts + them back 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) + widget = self._widget_for(entry) or panel.frame + try: + self.frame.after_idle(lambda: self.scroll.ensure_visible(widget)) + except Exception: + self.scroll.ensure_visible(widget) + + def _restore_folds(self): + if self._folds_before is None: + return + for section_id in self._folds_before: + panel = self.form.sections.get(section_id) + if panel is not None and panel.is_open: + panel.set_open(False) + self._folds_before = None + + def _reapply(self): + """The form rebuilt itself (column switch): the marks referred to dead widgets.""" + self._marked = [] + self._folds_before = None + 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..73fd0f1 100644 --- a/beantester/gui/theme.py +++ b/beantester/gui/theme.py @@ -108,6 +108,12 @@ 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)) + # A search hit on the Control page. Deliberately the SAME accent the section + # headers already use, plus bold: the page is dense, and a brand-new colour + # for one transient state would read as a status (error? warning?) rather than + # as "this is the word you typed". The scroll does the locating; the colour + # only confirms it once the eye arrives. + s.configure("Hit.TLabel", background=BG2, foreground=ACC, 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. @@ -214,6 +220,12 @@ 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. + s.configure("Hit.TCheckbutton", background=BG2, foreground=ACC, + font=(FONT, 9, "bold"), focuscolor=ACC, + padding=(0, scaled(2)), borderwidth=0) 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_gui_layout.py b/tests/test_gui_layout.py index bbca32f..67d6cb9 100644 --- a/tests/test_gui_layout.py +++ b/tests/test_gui_layout.py @@ -395,3 +395,151 @@ 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._verdict.cget("text").startswith("1 / "), page._verdict.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._verdict.cget("text") == "", page._verdict.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._verdict.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._marked, "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._verdict.cget("text") == T("fields.search_none"), page._verdict.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 + """) diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index f09089b..7cc93b0 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,26 @@ "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", + }, + { + # 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", From 0af7f62abc1ed4574f9609c3369b5350835b55ba Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 18 Aug 2026 11:38:34 +0200 Subject: [PATCH 3/4] docs(readme): describe the field search and its shortcut Both READMEs describe the window as it is, so a visible feature that is not in them reads as one that does not exist. The shortcut table gained Ctrl+F, which now means "search" on both pages that have a box. Co-Authored-By: Claude Opus 5 --- README.md | 8 ++++++++ README.pl.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/README.md b/README.md index 6eef26c..72691d0 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`. Matches are +highlighted and the page scrolls to the first one. `Enter` goes to the next, `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 diff --git a/README.pl.md b/README.pl.md index 0f47a07..fb31d27 100644 --- a/README.pl.md +++ b/README.pl.md @@ -152,6 +152,14 @@ 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`. Trafienia są +podświetlane, a strona przewija się do pierwszego z nich. `Enter` przechodzi do następnego, +`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 From 44bc85dfc7830f35bae67b22264e1285f176d454 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 18 Aug 2026 13:34:13 +0200 Subject: [PATCH 4/4] fix(gui): make the current match findable, and move the box to the right Three things the search got wrong, all reported after using it. With several matches nothing said which one you were on: Enter moved the page and every hit looked the same, so the counter pointed at a place the eye could not pick out. Marking is two-strength now - the current match is filled, the rest are tinted - and Shift+Enter walks back. The highlight was the page accent, which is the colour of every section header, link and "?" button, so it disappeared into the page. Hits are amber now: the one meaning this page had left, with blue for the accent, green for running, red for a fault and pink for the support button. The bar moved to the right, against the page margin. The row is built so nothing shifts while typing: the count is pinned to the margin at a fixed width, the box and its label sit left of it, and the sentence naming another window is a separate label further left, free to grow into empty space. Found on real Tk while checking those: two hits can claim the SAME widget - a dropdown has no text of its own so it marks its section header, and that header is often a hit itself. The header was painted current and then repainted as an ordinary match, so "1 / 5" showed with nothing filled anywhere. The second claim on a widget is dropped, which is also what keeps the count honest: as many marks as it says, each in its own place. The fake tkinter cannot catch this class - one widget class for everything, no style validation. Two more edge cases: the page now tracks what the SEARCH opened instead of snapshotting what was closed when it started, so a section the user opens by hand mid-search stays open; and unmarking hands the last word to apply_overrides, so a style that changed while a field was marked cannot be restored into a stale one. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 +- README.md | 6 +- README.pl.md | 7 +- beantester/gui/pages/control.py | 208 +++++++++++++++++++++----------- beantester/gui/theme.py | 46 +++++-- tests/test_gui_layout.py | 140 ++++++++++++++++++++- tests/test_mutation_registry.py | 20 +++ 7 files changed, 341 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05df499..3d65861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ### Added -- **You can now search for a setting on the Control page.** Type part of a field or section - name - or the command-line flag, such as `--loss` - and the page highlights what matches and - scrolls to it. `Enter` goes to the next match, `Escape` clears the box, `Ctrl+F` puts the caret - in it. A folded section opens while the search lasts and folds itself back afterwards, so the - sections you keep closed stay closed. Accents are optional: typing `opoznienie` finds - `Opóźnienie`. If the setting lives in the Settings window, the search says so instead of coming - back empty. +- **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 diff --git a/README.md b/README.md index 72691d0..13a13eb 100644 --- a/README.md +++ b/README.md @@ -202,9 +202,9 @@ effect. | `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`. Matches are -highlighted and the page scrolls to the first one. `Enter` goes to the next, `Escape` clears the -box. A folded section opens while the search lasts and folds itself back afterwards. Accents are +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. diff --git a/README.pl.md b/README.pl.md index fb31d27..d399e8a 100644 --- a/README.pl.md +++ b/README.pl.md @@ -155,9 +155,10 @@ Pole, które przejęło inne ustawienie, jest **wyszarzone razem z etykietą**, | `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`. Trafienia są -podświetlane, a strona przewija się do pierwszego z nich. `Enter` przechodzi do następnego, -`Escape` czyści pole. Zwinięta sekcja rozwija się na czas szukania i sama się zwija, kiedy +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. diff --git a/beantester/gui/pages/control.py b/beantester/gui/pages/control.py index f7e8e47..68a6d35 100644 --- a/beantester/gui/pages/control.py +++ b/beantester/gui/pages/control.py @@ -52,10 +52,10 @@ def __init__(self, app, parent): 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 the arrows walk, in page order + self._targets = [] # entries Enter walks, in page order self._at = 0 # which one is current - self._marked = [] # (widget, the style it had before) - self._folds_before = None # sections the user had closed, to put back + 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)) @@ -75,17 +75,39 @@ def __init__(self, app, parent): # -- 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(10), pady=(scaled(7), 0)) - ttk.Label(bar, text=T("fields.search")).pack(side="left") + 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=26) - entry.pack(side="left", padx=(scaled(4), scaled(8))) + 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 @@ -94,10 +116,6 @@ def _build_search_bar(self): from . import focus_search as _dispatch self.app.root.bind("", lambda e: _dispatch(self.app)) self.app.root.bind("", lambda e: _dispatch(self.app)) - # One line for both answers: "3 / 7" while there is something to walk, and - # the sentence naming another window when the only match lives there. - self._verdict = ttk.Label(bar, text="", style="Muted.TLabel") - self._verdict.pack(side="left") def focus_search(self): """Put the caret in the box (the Ctrl+F path, see gui/app.py).""" @@ -126,53 +144,64 @@ def _apply(self): self._unmark() hits = search.find(self._index, query) here, elsewhere = search.summarise(hits) - self._targets = self._jump_targets(here) + self._targets, self._marks = self._claim(here) self._at = 0 if not query.strip(): self._restore_folds() - self._verdict.config(text="") + self._say("", "") return - if self._folds_before is None: - # Snapshot ONCE per search, not per keystroke: the second letter would - # otherwise record the sections the first letter had just opened, and - # clearing the box would leave the page unfolded for good. - self._folds_before = [sid for sid, panel in self.form.sections.items() - if not panel.is_open] - for entry in here: - self._mark(entry) if self._targets: + self._paint() self._reveal(self._targets[0]) - self._verdict.config(text=self._verdict_text(elsewhere)) + self._say(self._count_text(), self._note_text(elsewhere)) - def _jump_targets(self, here): - """What the arrows walk: fields, plus a section that has no field of its own. + def _claim(self, here): + """Pair every hit with the widget that will show it - one widget, one hit. - A section title matches its own fields too (they carry it in their - haystack), so counting both would report "3" for a group with two fields - and mark two - a number that does not match what the eye can see. Sections - with no fields of their own (Profiles) still have to be reachable, so they - stay in. + 🔴 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. """ - with_fields = {e.section_id for e in here if e.kind == search.FIELD} - return [e for e in here - if e.kind == search.FIELD or e.section_id not in with_fields] + 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 _verdict_text(self, elsewhere): - if self._targets: - return "%d / %d" % (self._at + 1, len(self._targets)) + 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 T("fields.search_none") + return "" if self._targets else T("fields.search_none") def _step(self, delta): - """Next hit, wrapping. Enter is the only key people try for this.""" + """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._verdict.config(text="%d / %d" % (self._at + 1, len(self._targets))) + self._count.config(text=self._count_text()) def _widget_for(self, entry): if entry.kind == search.SECTION: @@ -183,36 +212,63 @@ def _widget_for(self, entry): return self.form.labels.get(entry.key) or self.form.entries.get(entry.key) def _mark(self, entry): - """Bold the words that matched. Which widget carries them comes from the - REGISTRY, not from asking the widget what it is: a checkbox field has no - separate label because its text IS the checkbox (gui/form.py::_place_one), - and a filter dropdown has no text of its own at all - for that one the - scroll is the whole answer. - """ - if entry.kind != search.FIELD: - return - label = self.form.labels.get(entry.key) - if label is not None: - self._swap_style(label, "Hit.TLabel") - return - field = FIELDS.get(entry.key) - if field is not None and field.kind == BOOL: - widget = self.form.entries.get(entry.key) - if widget is not None: - self._swap_style(widget, "Hit.TCheckbutton") + """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: - def _swap_style(self, widget, hit_style): + * 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"): - self._marked.append((widget, str(widget.cget("style") or ""))) - widget.configure(style=hit_style) + 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, style in self._marked: + 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=style) - self._marked = [] + 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. @@ -220,33 +276,39 @@ def _reveal(self, entry): 🔴 `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. The page opens them for the length of the search and puts - them back in `_restore_folds`. + 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 - try: + with crashlog.quiet("gui.pages.control"): self.frame.after_idle(lambda: self.scroll.ensure_visible(widget)) - except Exception: - self.scroll.ensure_visible(widget) def _restore_folds(self): - if self._folds_before is None: - return - for section_id in self._folds_before: + 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._folds_before = None + self._opened = set() def _reapply(self): - """The form rebuilt itself (column switch): the marks referred to dead widgets.""" - self._marked = [] - self._folds_before = None + """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() diff --git a/beantester/gui/theme.py b/beantester/gui/theme.py index 73fd0f1..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,12 +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)) - # A search hit on the Control page. Deliberately the SAME accent the section - # headers already use, plus bold: the page is dense, and a brand-new colour - # for one transient state would read as a status (error? warning?) rather than - # as "this is the word you typed". The scroll does the locating; the colour - # only confirms it once the eye arrives. - s.configure("Hit.TLabel", background=BG2, foreground=ACC, font=(FONT, 9, "bold")) + # 🔴 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. @@ -197,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 @@ -222,10 +244,18 @@ def _style_checkbuttons(s): 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. - s.configure("Hit.TCheckbutton", background=BG2, foreground=ACC, + # 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/tests/test_gui_layout.py b/tests/test_gui_layout.py index 67d6cb9..10adb66 100644 --- a/tests/test_gui_layout.py +++ b/tests/test_gui_layout.py @@ -409,7 +409,7 @@ def test_searching_marks_what_matched_and_counts_it(): label = app.form.labels["loss"] assert label.cget("style") == "Hit.TLabel", label.cget("style") - assert page._verdict.cget("text").startswith("1 / "), page._verdict.cget("text") + 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"] @@ -429,7 +429,7 @@ def test_clearing_the_search_puts_every_style_back(): page.clear() assert app.form.labels["loss"].cget("style") == before, ( app.form.labels["loss"].cget("style")) - assert page._verdict.cget("text") == "", page._verdict.cget("text") + assert page._note.cget("text") == "" and page._count.cget("text") == "" """) @@ -469,10 +469,10 @@ def test_a_field_that_lives_in_the_settings_window_says_so(): page = app.pages["control"] page.query_var.set("row_limit") # renders in the Settings window page._apply() - text = page._verdict.cget("text") + 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._marked, "nothing on this page may be marked" + assert not page._marks, "nothing on this page may be marked" """) @@ -482,7 +482,7 @@ def test_a_query_that_matches_nothing_says_so_instead_of_going_quiet(): page = app.pages["control"] page.query_var.set("zzzznothing") page._apply() - assert page._verdict.cget("text") == T("fields.search_none"), page._verdict.cget("text") + assert page._note.cget("text") == T("fields.search_none"), page._note.cget("text") """) @@ -543,3 +543,133 @@ def test_one_ctrl_f_reaches_whichever_search_box_is_in_front(): 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 7cc93b0..a675c22 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -552,6 +552,26 @@ "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.