Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions README.pl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion beantester/gui/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
148 changes: 148 additions & 0 deletions beantester/gui/form_search.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 28 additions & 1 deletion beantester/gui/pages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
13 changes: 9 additions & 4 deletions beantester/gui/pages/conns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<Control-f>", self._focus_search)
app.root.bind("<Control-F>", self._focus_search)
from . import focus_search as _dispatch # one shortcut, two boxes
app.root.bind("<Control-f>", lambda e: _dispatch(app))
app.root.bind("<Control-F>", 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
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading