tksearchengine 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,213 @@
1
+ Metadata-Version: 2.4
2
+ Name: tksearchengine
3
+ Version: 1.0.0
4
+ Summary: A reusable Tkinter search widget with fuzzy multi-word matching
5
+ Author: WhatIsMyRealName
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Environment :: X11 Applications
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: User Interfaces
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+
20
+ # tksearchengine
21
+
22
+ `tksearchengine` provides a reusable Tkinter search field with fuzzy,
23
+ multi-word suggestions. It combines an entry, a search button, and a listbox
24
+ in a single configurable widget.
25
+
26
+ ## Features
27
+
28
+ - Placeholder text while the empty entry is not focused.
29
+ - Live suggestions while the entry is focused.
30
+ - Mouse selection and keyboard navigation with Up and Down.
31
+ - Tab accepts the highlighted suggestion, or the first suggestion by default.
32
+ - Return and the search button invoke the same callback.
33
+ - Case-insensitive, accent-insensitive, multi-word fuzzy matching.
34
+ - `-` and `'` are treated as word separators.
35
+ - Missing spaces and spaces accidentally replaced by `n` or `b` are handled.
36
+ - Exact words, exact prefixes, approximate prefixes, and approximate complete
37
+ words are ranked in that order.
38
+ - No third-party runtime dependencies.
39
+
40
+ ## Requirements
41
+
42
+ - Python 3.10 or newer.
43
+ - Tkinter, which is included with many Python installations but may require a
44
+ separate operating-system package on Linux. For example, Debian and Ubuntu provide it in the `python3-tk` package.
45
+
46
+ ## Installation
47
+
48
+ From PyPI:
49
+
50
+ ```console
51
+ python -m pip install tksearchengine
52
+ ```
53
+
54
+ From the project directory:
55
+
56
+ ```console
57
+ python -m pip install .
58
+ ```
59
+
60
+ For editable development:
61
+
62
+ ```console
63
+ python -m pip install -e .
64
+ ```
65
+
66
+ ## Basic usage
67
+
68
+ ```python
69
+ import tkinter as tk
70
+
71
+ from tksearchengine import SearchEngine
72
+
73
+
74
+ def run_search(text: str, selected_item: str | None) -> None:
75
+ print("Search text:", text)
76
+ print("Selected suggestion:", selected_item)
77
+
78
+
79
+ root = tk.Tk()
80
+
81
+ search = SearchEngine(
82
+ root,
83
+ items=["Paris", "Marseille", "Lyon", "Orléans"],
84
+ command=run_search,
85
+ placeholder="Search for a city",
86
+ max_visible_results=6,
87
+ )
88
+ search.pack(fill="x", padx=20, pady=20)
89
+
90
+ root.mainloop()
91
+ ```
92
+
93
+ ## Configuration
94
+
95
+ The constructor accepts:
96
+
97
+ - `items`: searchable strings.
98
+ - `command`: callback receiving the current text and the selected suggestion. The second argument is `None` when the user submits free text without accepting a suggestion.
99
+ - `placeholder`: text displayed when the unfocused entry is empty.
100
+ - `button_text`: label or symbol displayed on the search button.
101
+ - `search_function`: optional replacement for the complete ranking function.
102
+ - `max_distance_function`: optional function returning the accepted edit
103
+ distance for a given reference-word length.
104
+ - `max_visible_results`: maximum visible listbox rows.
105
+ - `placeholder_color` and `text_color`: entry text colors.
106
+ - `entry_options`, `button_options`, and `listbox_options`: mappings forwarded
107
+ to the corresponding Tkinter widgets.
108
+ - Any remaining keyword arguments are forwarded to the containing `tk.Frame`.
109
+
110
+ Here is the `__init__` shape:
111
+ ```python
112
+ def __init__(
113
+ self,
114
+ master: tk.Misc | None = None,
115
+ *,
116
+ items: Sequence[str] = (),
117
+ command: SearchCallback | None = None,
118
+ placeholder: str = "Search…",
119
+ button_text: str = "🔍",
120
+ search_function: SearchFunction | None = None,
121
+ max_distance_function: MaxDistanceFunction = _default_max_distance,
122
+ max_visible_results: int = 8,
123
+ placeholder_color: str = "grey",
124
+ text_color: str = "black",
125
+ entry_options: Mapping[str, Any] | None = None,
126
+ button_options: Mapping[str, Any] | None = None,
127
+ listbox_options: Mapping[str, Any] | None = None,
128
+ **frame_options: Any,
129
+ ) -> None:
130
+ ```
131
+
132
+ Example with widget customization:
133
+
134
+ ```python
135
+ search = SearchEngine(
136
+ root,
137
+ items=["Paris", "Marseille", "Lyon", "Orléans"],
138
+ entry_options={
139
+ "width": 32,
140
+ "font": ("Segoe UI", 11),
141
+ "relief": "solid",
142
+ },
143
+ button_options={
144
+ "text": "Search",
145
+ "font": ("Segoe UI", 10, "bold"),
146
+ "padx": 12,
147
+ },
148
+ listbox_options={
149
+ "font": ("Segoe UI", 10),
150
+ "height": 6,
151
+ "activestyle": "dotbox",
152
+ },
153
+ )
154
+ ```
155
+
156
+ These mappings are passed directly to `tk.Entry`, `tk.Button`, and `tk.Listbox`.
157
+ `SearchEngine` still owns the entry text variable, the button command, the
158
+ listbox selection behavior, and the visible result count.
159
+
160
+ A custom search function has this shape:
161
+
162
+ ```python
163
+ from collections.abc import Sequence
164
+
165
+
166
+ def custom_search(items: Sequence[str], query: str) -> Sequence[str]:
167
+ normalized_query = query.lower()
168
+ return [item for item in items if normalized_query in item.lower()]
169
+ ```
170
+
171
+ Pass it with `search_function=custom_search`. When a custom search function is
172
+ provided, it is responsible for filtering and ordering all results.
173
+
174
+ ## Public methods
175
+
176
+ - `get()` returns the current search text without the placeholder.
177
+ - `set(text)` replaces the current search text.
178
+ - `set_items(items)` replaces the searchable collection.
179
+ - `invoke()` runs the configured callback.
180
+ - `selected_item` returns the highlighted or accepted suggestion, if any.
181
+
182
+ ## Matching behavior
183
+
184
+ The default matcher normalizes text to lowercase, replaces common accented
185
+ letters with their unaccented forms, and treats `-` and `'` as spaces. Results are
186
+ ranked by:
187
+
188
+ 1. number of unmatched query words;
189
+ 2. match type, from exact word to approximate complete word;
190
+ 3. normalized Levenshtein distance;
191
+ 4. query-word order;
192
+ 5. original item order.
193
+
194
+ The default maximum edit distance is `(word_length + 2) // 5`. Override
195
+ `max_distance_function(word_length)` when an application needs stricter or more permissive
196
+ matching.
197
+
198
+ ## Running the example
199
+
200
+ The example contains 1,000 French commune names:
201
+
202
+ ```console
203
+ python examples/communes.py
204
+ ```
205
+ Filtering the 1,000 sample names should feel immediate on a typical desktop Python installation.
206
+
207
+ ## Running the tests
208
+
209
+ After an editable installation:
210
+
211
+ ```console
212
+ python -m unittest discover -s tests -v
213
+ ```
@@ -0,0 +1,194 @@
1
+ # tksearchengine
2
+
3
+ `tksearchengine` provides a reusable Tkinter search field with fuzzy,
4
+ multi-word suggestions. It combines an entry, a search button, and a listbox
5
+ in a single configurable widget.
6
+
7
+ ## Features
8
+
9
+ - Placeholder text while the empty entry is not focused.
10
+ - Live suggestions while the entry is focused.
11
+ - Mouse selection and keyboard navigation with Up and Down.
12
+ - Tab accepts the highlighted suggestion, or the first suggestion by default.
13
+ - Return and the search button invoke the same callback.
14
+ - Case-insensitive, accent-insensitive, multi-word fuzzy matching.
15
+ - `-` and `'` are treated as word separators.
16
+ - Missing spaces and spaces accidentally replaced by `n` or `b` are handled.
17
+ - Exact words, exact prefixes, approximate prefixes, and approximate complete
18
+ words are ranked in that order.
19
+ - No third-party runtime dependencies.
20
+
21
+ ## Requirements
22
+
23
+ - Python 3.10 or newer.
24
+ - Tkinter, which is included with many Python installations but may require a
25
+ separate operating-system package on Linux. For example, Debian and Ubuntu provide it in the `python3-tk` package.
26
+
27
+ ## Installation
28
+
29
+ From PyPI:
30
+
31
+ ```console
32
+ python -m pip install tksearchengine
33
+ ```
34
+
35
+ From the project directory:
36
+
37
+ ```console
38
+ python -m pip install .
39
+ ```
40
+
41
+ For editable development:
42
+
43
+ ```console
44
+ python -m pip install -e .
45
+ ```
46
+
47
+ ## Basic usage
48
+
49
+ ```python
50
+ import tkinter as tk
51
+
52
+ from tksearchengine import SearchEngine
53
+
54
+
55
+ def run_search(text: str, selected_item: str | None) -> None:
56
+ print("Search text:", text)
57
+ print("Selected suggestion:", selected_item)
58
+
59
+
60
+ root = tk.Tk()
61
+
62
+ search = SearchEngine(
63
+ root,
64
+ items=["Paris", "Marseille", "Lyon", "Orléans"],
65
+ command=run_search,
66
+ placeholder="Search for a city",
67
+ max_visible_results=6,
68
+ )
69
+ search.pack(fill="x", padx=20, pady=20)
70
+
71
+ root.mainloop()
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ The constructor accepts:
77
+
78
+ - `items`: searchable strings.
79
+ - `command`: callback receiving the current text and the selected suggestion. The second argument is `None` when the user submits free text without accepting a suggestion.
80
+ - `placeholder`: text displayed when the unfocused entry is empty.
81
+ - `button_text`: label or symbol displayed on the search button.
82
+ - `search_function`: optional replacement for the complete ranking function.
83
+ - `max_distance_function`: optional function returning the accepted edit
84
+ distance for a given reference-word length.
85
+ - `max_visible_results`: maximum visible listbox rows.
86
+ - `placeholder_color` and `text_color`: entry text colors.
87
+ - `entry_options`, `button_options`, and `listbox_options`: mappings forwarded
88
+ to the corresponding Tkinter widgets.
89
+ - Any remaining keyword arguments are forwarded to the containing `tk.Frame`.
90
+
91
+ Here is the `__init__` shape:
92
+ ```python
93
+ def __init__(
94
+ self,
95
+ master: tk.Misc | None = None,
96
+ *,
97
+ items: Sequence[str] = (),
98
+ command: SearchCallback | None = None,
99
+ placeholder: str = "Search…",
100
+ button_text: str = "🔍",
101
+ search_function: SearchFunction | None = None,
102
+ max_distance_function: MaxDistanceFunction = _default_max_distance,
103
+ max_visible_results: int = 8,
104
+ placeholder_color: str = "grey",
105
+ text_color: str = "black",
106
+ entry_options: Mapping[str, Any] | None = None,
107
+ button_options: Mapping[str, Any] | None = None,
108
+ listbox_options: Mapping[str, Any] | None = None,
109
+ **frame_options: Any,
110
+ ) -> None:
111
+ ```
112
+
113
+ Example with widget customization:
114
+
115
+ ```python
116
+ search = SearchEngine(
117
+ root,
118
+ items=["Paris", "Marseille", "Lyon", "Orléans"],
119
+ entry_options={
120
+ "width": 32,
121
+ "font": ("Segoe UI", 11),
122
+ "relief": "solid",
123
+ },
124
+ button_options={
125
+ "text": "Search",
126
+ "font": ("Segoe UI", 10, "bold"),
127
+ "padx": 12,
128
+ },
129
+ listbox_options={
130
+ "font": ("Segoe UI", 10),
131
+ "height": 6,
132
+ "activestyle": "dotbox",
133
+ },
134
+ )
135
+ ```
136
+
137
+ These mappings are passed directly to `tk.Entry`, `tk.Button`, and `tk.Listbox`.
138
+ `SearchEngine` still owns the entry text variable, the button command, the
139
+ listbox selection behavior, and the visible result count.
140
+
141
+ A custom search function has this shape:
142
+
143
+ ```python
144
+ from collections.abc import Sequence
145
+
146
+
147
+ def custom_search(items: Sequence[str], query: str) -> Sequence[str]:
148
+ normalized_query = query.lower()
149
+ return [item for item in items if normalized_query in item.lower()]
150
+ ```
151
+
152
+ Pass it with `search_function=custom_search`. When a custom search function is
153
+ provided, it is responsible for filtering and ordering all results.
154
+
155
+ ## Public methods
156
+
157
+ - `get()` returns the current search text without the placeholder.
158
+ - `set(text)` replaces the current search text.
159
+ - `set_items(items)` replaces the searchable collection.
160
+ - `invoke()` runs the configured callback.
161
+ - `selected_item` returns the highlighted or accepted suggestion, if any.
162
+
163
+ ## Matching behavior
164
+
165
+ The default matcher normalizes text to lowercase, replaces common accented
166
+ letters with their unaccented forms, and treats `-` and `'` as spaces. Results are
167
+ ranked by:
168
+
169
+ 1. number of unmatched query words;
170
+ 2. match type, from exact word to approximate complete word;
171
+ 3. normalized Levenshtein distance;
172
+ 4. query-word order;
173
+ 5. original item order.
174
+
175
+ The default maximum edit distance is `(word_length + 2) // 5`. Override
176
+ `max_distance_function(word_length)` when an application needs stricter or more permissive
177
+ matching.
178
+
179
+ ## Running the example
180
+
181
+ The example contains 1,000 French commune names:
182
+
183
+ ```console
184
+ python examples/communes.py
185
+ ```
186
+ Filtering the 1,000 sample names should feel immediate on a typical desktop Python installation.
187
+
188
+ ## Running the tests
189
+
190
+ After an editable installation:
191
+
192
+ ```console
193
+ python -m unittest discover -s tests -v
194
+ ```
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tksearchengine"
7
+ version = "1.0.0"
8
+ description = "A reusable Tkinter search widget with fuzzy multi-word matching"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "WhatIsMyRealName" }]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: X11 Applications",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: User Interfaces",
24
+ ]
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.setuptools.package-data]
30
+ tksearchengine = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """A reusable fuzzy search widget for Tkinter."""
2
+
3
+ from .search_engine import SearchEngine
4
+
5
+ __all__ = ["SearchEngine"]
@@ -0,0 +1,447 @@
1
+ """Reusable Tkinter search widget with autocomplete suggestions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tkinter as tk
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ from fractions import Fraction
8
+ from functools import lru_cache
9
+ from typing import Any, TypeAlias
10
+
11
+ SearchCallback: TypeAlias = Callable[[str, str | None], None]
12
+ SearchFunction: TypeAlias = Callable[[Sequence[str], str], Sequence[str]]
13
+ MaxDistanceFunction: TypeAlias = Callable[[int], int]
14
+
15
+ __version__ = "1.0.0"
16
+ __author__ = "WhatIsMyRealName"
17
+ __created_with__ = "Codex"
18
+ __created_on__ = "2026-07-17"
19
+
20
+ __all__ = ["SearchEngine"]
21
+
22
+ _NORMALIZATION_TABLE = str.maketrans(
23
+ {
24
+ "à": "a",
25
+ "á": "a",
26
+ "â": "a",
27
+ "ä": "a",
28
+ "ã": "a",
29
+ "å": "a",
30
+ "æ": "ae",
31
+ "ç": "c",
32
+ "é": "e",
33
+ "è": "e",
34
+ "ê": "e",
35
+ "ë": "e",
36
+ "ì": "i",
37
+ "í": "i",
38
+ "î": "i",
39
+ "ï": "i",
40
+ "ñ": "n",
41
+ "ò": "o",
42
+ "ó": "o",
43
+ "ô": "o",
44
+ "ö": "o",
45
+ "õ": "o",
46
+ "œ": "oe",
47
+ "ù": "u",
48
+ "ú": "u",
49
+ "û": "u",
50
+ "ü": "u",
51
+ "ý": "y",
52
+ "ÿ": "y",
53
+ "-": " ",
54
+ "'": " ",
55
+ }
56
+ )
57
+
58
+
59
+ @lru_cache(maxsize=8192)
60
+ def _normalize(text: str) -> str:
61
+ return text.lower().translate(_NORMALIZATION_TABLE) # ajouter la suppression de plusieurs espaces consécutifs, en début et fin de chaîne comme au début ? *( )+* => * *
62
+
63
+
64
+ def _default_max_distance(length: int) -> int:
65
+ return (length + 2) // 5
66
+
67
+
68
+ @lru_cache(maxsize=8192)
69
+ def _levenshtein_distance(first: str, second: str) -> int:
70
+ """Return the Levenshtein edit distance between two strings.
71
+
72
+ The distance is the minimum number of insertions, deletions and
73
+ substitutions required to transform one string into the other.
74
+ """
75
+ if len(first) < len(second):
76
+ first, second = second, first
77
+
78
+ previous_row = list(range(len(second) + 1))
79
+ for first_index, first_character in enumerate(first, start=1):
80
+ current_row = [first_index]
81
+ for second_index, second_character in enumerate(second, start=1):
82
+ insertion = current_row[second_index - 1] + 1
83
+ deletion = previous_row[second_index] + 1
84
+ substitution = previous_row[second_index - 1] + (
85
+ first_character != second_character
86
+ )
87
+ current_row.append(min(insertion, deletion, substitution))
88
+ previous_row = current_row
89
+
90
+ return previous_row[-1]
91
+
92
+
93
+ def _candidate_variants(words: Sequence[str]) -> list[list[str]]:
94
+ variants = [list(words)]
95
+ for boundary in range(len(words) - 1):
96
+ before = list(words[:boundary])
97
+ after = list(words[boundary + 2 :])
98
+ for replacement in ("", "n", "b"):
99
+ merged = words[boundary] + replacement + words[boundary + 1]
100
+ variants.append([*before, merged, *after])
101
+ return variants
102
+
103
+
104
+ def _word_match_score(
105
+ query_word: str,
106
+ candidate_word: str,
107
+ max_distance_function: MaxDistanceFunction,
108
+ ) -> tuple[int, Fraction] | None:
109
+ if query_word == candidate_word:
110
+ return 0, Fraction(0)
111
+
112
+ if len(query_word) < len(candidate_word):
113
+ candidate_prefix = candidate_word[: len(query_word)]
114
+ prefix_distance = _levenshtein_distance(query_word, candidate_prefix)
115
+ if prefix_distance == 0:
116
+ return 1, Fraction(0)
117
+ if prefix_distance <= max_distance_function(len(query_word)):
118
+ return 2, Fraction(prefix_distance, len(query_word) or 1)
119
+
120
+ reference_length = len(candidate_word)
121
+ maximum_distance = max_distance_function(reference_length)
122
+ if abs(len(query_word) - reference_length) > maximum_distance:
123
+ return None
124
+
125
+ distance = _levenshtein_distance(query_word, candidate_word)
126
+ if distance > maximum_distance:
127
+ return None
128
+
129
+ normalization_length = max(len(query_word), reference_length)
130
+ return 3, Fraction(distance, normalization_length or 1)
131
+
132
+
133
+ def _score_variant(
134
+ query_words: Sequence[str],
135
+ candidate_words: Sequence[str],
136
+ max_distance_function: MaxDistanceFunction,
137
+ ) -> tuple[int, int, Fraction, int]:
138
+ match_scores: list[list[tuple[int, Fraction] | None]] = []
139
+ for query_word in query_words:
140
+ row: list[tuple[int, Fraction] | None] = []
141
+ for candidate_word in candidate_words:
142
+ row.append(
143
+ _word_match_score(
144
+ query_word, candidate_word, max_distance_function
145
+ )
146
+ )
147
+ match_scores.append(row)
148
+
149
+ @lru_cache(maxsize=None)
150
+ def find_best(
151
+ query_index: int, used_candidates: int
152
+ ) -> tuple[int, int, Fraction, int]:
153
+ if query_index == len(query_words):
154
+ return 0, 0, Fraction(0), 0
155
+
156
+ unmatched, match_priority, distance_sum, inversions = find_best(
157
+ query_index + 1, used_candidates
158
+ )
159
+ best = unmatched + 1, match_priority, distance_sum, inversions
160
+
161
+ for candidate_index, match_score in enumerate(match_scores[query_index]):
162
+ candidate_bit = 1 << candidate_index
163
+ if match_score is None or used_candidates & candidate_bit:
164
+ continue
165
+ priority, normalized_distance = match_score
166
+ child = find_best(query_index + 1, used_candidates | candidate_bit)
167
+ preceding_inversions = sum(
168
+ 1
169
+ for previous_index in range(candidate_index + 1, len(candidate_words))
170
+ if used_candidates & (1 << previous_index)
171
+ )
172
+ score = (
173
+ child[0],
174
+ child[1] + priority,
175
+ child[2] + normalized_distance,
176
+ child[3] + preceding_inversions,
177
+ )
178
+ if score < best:
179
+ best = score
180
+
181
+ return best
182
+
183
+ return find_best(0, 0)
184
+
185
+
186
+ def _fuzzy_search(
187
+ items: Sequence[str],
188
+ query: str,
189
+ max_distance_function: MaxDistanceFunction,
190
+ ) -> list[str]:
191
+ query_words = _normalize(query).split()
192
+ if not query_words:
193
+ return list(items)
194
+
195
+ ranked_items: list[tuple[tuple[int, int, Fraction, int, int], str]] = []
196
+ for original_index, item in enumerate(items):
197
+ candidate_words = _normalize(item).split()
198
+ if not candidate_words:
199
+ continue
200
+ best_score = min(
201
+ _score_variant(query_words, variant, max_distance_function)
202
+ for variant in _candidate_variants(candidate_words)
203
+ )
204
+ unmatched_words, match_priority, distance_sum, inversions = best_score
205
+ if unmatched_words == len(query_words):
206
+ continue
207
+ ranked_items.append(
208
+ (
209
+ (
210
+ unmatched_words,
211
+ match_priority,
212
+ distance_sum,
213
+ inversions,
214
+ original_index,
215
+ ),
216
+ item,
217
+ )
218
+ )
219
+
220
+ ranked_items.sort(key=lambda ranked_item: ranked_item[0])
221
+ return [item for _score, item in ranked_items]
222
+
223
+
224
+ class SearchEngine(tk.Frame):
225
+ """A search field with a button and a filtered suggestion list.
226
+
227
+ ``command`` is called with the current text and the selected suggestion
228
+ (or ``None``). It is invoked both by Return and by the search button.
229
+
230
+ Widget-specific Tk options can be passed through ``entry_options``,
231
+ ``button_options`` and ``listbox_options``. Remaining keyword arguments
232
+ are applied to the containing :class:`tk.Frame`.
233
+ """
234
+
235
+ def __init__(
236
+ self,
237
+ master: tk.Misc | None = None,
238
+ *,
239
+ items: Sequence[str] = (),
240
+ command: SearchCallback | None = None,
241
+ placeholder: str = "Search…",
242
+ button_text: str = "🔍",
243
+ search_function: SearchFunction | None = None,
244
+ max_distance_function: MaxDistanceFunction = _default_max_distance,
245
+ max_visible_results: int = 8,
246
+ placeholder_color: str = "grey",
247
+ text_color: str = "black",
248
+ entry_options: Mapping[str, Any] | None = None,
249
+ button_options: Mapping[str, Any] | None = None,
250
+ listbox_options: Mapping[str, Any] | None = None,
251
+ **frame_options: Any,
252
+ ) -> None:
253
+ super().__init__(master, **frame_options)
254
+
255
+ if max_visible_results < 1:
256
+ raise ValueError("max_visible_results must be greater than or equal to 1")
257
+ if search_function is not None and not callable(search_function):
258
+ raise TypeError("search_function must be callable or None")
259
+ if not callable(max_distance_function):
260
+ raise TypeError("max_distance_function must be callable")
261
+ if command is not None and not callable(command):
262
+ raise TypeError("command must be callable or None")
263
+
264
+ self._items = list(items)
265
+ self._command = command
266
+ self._placeholder = placeholder
267
+ self._search_function = search_function
268
+ self._max_distance_function = max_distance_function
269
+ self._max_visible_results = max_visible_results
270
+ self._placeholder_color = placeholder_color
271
+ self._text_color = text_color
272
+ self._showing_placeholder = False
273
+ self._filtered_items: list[str] = []
274
+ self._accepted_item: str | None = None
275
+
276
+ self.variable = tk.StringVar(self)
277
+
278
+ entry_config = dict(entry_options or {})
279
+ entry_config["textvariable"] = self.variable
280
+ self.entry = tk.Entry(self, **entry_config)
281
+
282
+ button_config = dict(button_options or {})
283
+ button_config.setdefault("text", button_text)
284
+ button_config["command"] = self.invoke
285
+ self.button = tk.Button(self, **button_config)
286
+
287
+ listbox_config = dict(listbox_options or {})
288
+ listbox_config.setdefault("exportselection", False)
289
+ listbox_config.setdefault("height", 1)
290
+ self.listbox = tk.Listbox(self, **listbox_config)
291
+
292
+ self.entry.grid(row=0, column=0, sticky="ew")
293
+ self.button.grid(row=0, column=1, sticky="ns")
294
+ self.columnconfigure(0, weight=1)
295
+
296
+ self.entry.bind("<FocusIn>", self._on_focus_in)
297
+ self.entry.bind("<FocusOut>", self._on_focus_out)
298
+ self.entry.bind("<KeyRelease>", self._on_key_release)
299
+ self.entry.bind("<Return>", self._on_submit)
300
+ self.entry.bind("<KP_Enter>", self._on_submit)
301
+ self.entry.bind("<Tab>", self._on_tab)
302
+ self.entry.bind("<Down>", self._on_down)
303
+ self.entry.bind("<Up>", self._on_up)
304
+ self.listbox.bind("<ButtonRelease-1>", self._on_listbox_click)
305
+ self.listbox.bind("<Return>", self._on_listbox_accept)
306
+
307
+ self._display_placeholder()
308
+
309
+ def get(self) -> str:
310
+ """Return the search text, excluding the visual placeholder."""
311
+ return "" if self._showing_placeholder else self.variable.get()
312
+
313
+ def set(self, text: str) -> None:
314
+ """Replace the search text and refresh suggestions when focused."""
315
+ self._accepted_item = None
316
+ self._showing_placeholder = False
317
+ self.entry.configure(fg=self._text_color)
318
+ self.variable.set(text)
319
+ if self.entry.focus_get() == self.entry:
320
+ self._refresh_results()
321
+ elif not text:
322
+ self._display_placeholder()
323
+
324
+ def set_items(self, items: Sequence[str]) -> None:
325
+ """Replace the searchable values."""
326
+ self._items = list(items)
327
+ if self.entry.focus_get() == self.entry:
328
+ self._refresh_results()
329
+
330
+ def invoke(self) -> None:
331
+ """Run the configured search callback."""
332
+ if self._command is not None:
333
+ self._command(self.get(), self.selected_item)
334
+
335
+ @property
336
+ def selected_item(self) -> str | None:
337
+ """Return the highlighted suggestion, if any."""
338
+ selection = self.listbox.curselection()
339
+ if not selection:
340
+ return self._accepted_item
341
+ index = selection[0]
342
+ if index >= len(self._filtered_items):
343
+ return None
344
+ return self._filtered_items[index]
345
+
346
+ def _display_placeholder(self) -> None:
347
+ if self.variable.get() or self.entry.focus_get() == self.entry:
348
+ return
349
+ self._showing_placeholder = True
350
+ self.entry.configure(fg=self._placeholder_color)
351
+ self.variable.set(self._placeholder)
352
+
353
+ def _hide_results(self) -> None:
354
+ self.listbox.grid_remove()
355
+
356
+ def _refresh_results(self) -> None:
357
+ query = self.get()
358
+ if self._search_function is None:
359
+ results = _fuzzy_search(
360
+ self._items, query, self._max_distance_function
361
+ )
362
+ else:
363
+ results = self._search_function(self._items, query)
364
+ self._filtered_items = list(results)
365
+
366
+ self.listbox.delete(0, tk.END)
367
+ for item in self._filtered_items:
368
+ self.listbox.insert(tk.END, item)
369
+
370
+ if not self._filtered_items:
371
+ self._hide_results()
372
+ return
373
+
374
+ self.listbox.configure(
375
+ height=min(len(self._filtered_items), self._max_visible_results)
376
+ )
377
+ self.listbox.grid(row=1, column=0, columnspan=2, sticky="ew")
378
+
379
+ def _on_focus_in(self, _event: tk.Event[tk.Misc]) -> None:
380
+ if self._showing_placeholder:
381
+ self.variable.set("")
382
+ self._showing_placeholder = False
383
+ self.entry.configure(fg=self._text_color)
384
+ self._refresh_results()
385
+
386
+ def _on_focus_out(self, _event: tk.Event[tk.Misc]) -> None:
387
+ self.after_idle(self._finish_focus_out)
388
+
389
+ def _finish_focus_out(self) -> None:
390
+ focused = self.focus_get()
391
+ if focused == self.listbox:
392
+ return
393
+ self._hide_results()
394
+ self._display_placeholder()
395
+
396
+ def _on_key_release(self, event: tk.Event[tk.Misc]) -> None:
397
+ if event.keysym not in {"Up", "Down", "Tab", "Return", "KP_Enter"}:
398
+ self._accepted_item = None
399
+ self._refresh_results()
400
+
401
+ def _on_submit(self, _event: tk.Event[tk.Misc]) -> str:
402
+ self.invoke()
403
+ return "break"
404
+
405
+ def _on_tab(self, _event: tk.Event[tk.Misc]) -> str | None:
406
+ if not self._filtered_items:
407
+ return None
408
+ selection = self.listbox.curselection()
409
+ self._accept_index(selection[0] if selection else 0)
410
+ return "break"
411
+
412
+ def _move_selection(self, step: int) -> str:
413
+ if not self._filtered_items:
414
+ return "break"
415
+ selection = self.listbox.curselection()
416
+ current = selection[0] if selection else (-1 if step > 0 else 0)
417
+ target = (current + step) % len(self._filtered_items)
418
+ self.listbox.selection_clear(0, tk.END)
419
+ self.listbox.selection_set(target)
420
+ self.listbox.activate(target)
421
+ self.listbox.see(target)
422
+ return "break"
423
+
424
+ def _on_down(self, _event: tk.Event[tk.Misc]) -> str:
425
+ return self._move_selection(1)
426
+
427
+ def _on_up(self, _event: tk.Event[tk.Misc]) -> str:
428
+ return self._move_selection(-1)
429
+
430
+ def _accept_index(self, index: int) -> None:
431
+ accepted_item = self._filtered_items[index]
432
+ self.set(accepted_item)
433
+ self._accepted_item = accepted_item
434
+ self.entry.icursor(tk.END)
435
+ self.entry.focus_set()
436
+ self._hide_results()
437
+
438
+ def _on_listbox_click(self, _event: tk.Event[tk.Misc]) -> None:
439
+ selection = self.listbox.curselection()
440
+ if selection:
441
+ self._accept_index(selection[0])
442
+
443
+ def _on_listbox_accept(self, _event: tk.Event[tk.Misc]) -> str:
444
+ selection = self.listbox.curselection()
445
+ if selection:
446
+ self._accept_index(selection[0])
447
+ return "break"
@@ -0,0 +1,213 @@
1
+ Metadata-Version: 2.4
2
+ Name: tksearchengine
3
+ Version: 1.0.0
4
+ Summary: A reusable Tkinter search widget with fuzzy multi-word matching
5
+ Author: WhatIsMyRealName
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Environment :: X11 Applications
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: User Interfaces
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+
20
+ # tksearchengine
21
+
22
+ `tksearchengine` provides a reusable Tkinter search field with fuzzy,
23
+ multi-word suggestions. It combines an entry, a search button, and a listbox
24
+ in a single configurable widget.
25
+
26
+ ## Features
27
+
28
+ - Placeholder text while the empty entry is not focused.
29
+ - Live suggestions while the entry is focused.
30
+ - Mouse selection and keyboard navigation with Up and Down.
31
+ - Tab accepts the highlighted suggestion, or the first suggestion by default.
32
+ - Return and the search button invoke the same callback.
33
+ - Case-insensitive, accent-insensitive, multi-word fuzzy matching.
34
+ - `-` and `'` are treated as word separators.
35
+ - Missing spaces and spaces accidentally replaced by `n` or `b` are handled.
36
+ - Exact words, exact prefixes, approximate prefixes, and approximate complete
37
+ words are ranked in that order.
38
+ - No third-party runtime dependencies.
39
+
40
+ ## Requirements
41
+
42
+ - Python 3.10 or newer.
43
+ - Tkinter, which is included with many Python installations but may require a
44
+ separate operating-system package on Linux. For example, Debian and Ubuntu provide it in the `python3-tk` package.
45
+
46
+ ## Installation
47
+
48
+ From PyPI:
49
+
50
+ ```console
51
+ python -m pip install tksearchengine
52
+ ```
53
+
54
+ From the project directory:
55
+
56
+ ```console
57
+ python -m pip install .
58
+ ```
59
+
60
+ For editable development:
61
+
62
+ ```console
63
+ python -m pip install -e .
64
+ ```
65
+
66
+ ## Basic usage
67
+
68
+ ```python
69
+ import tkinter as tk
70
+
71
+ from tksearchengine import SearchEngine
72
+
73
+
74
+ def run_search(text: str, selected_item: str | None) -> None:
75
+ print("Search text:", text)
76
+ print("Selected suggestion:", selected_item)
77
+
78
+
79
+ root = tk.Tk()
80
+
81
+ search = SearchEngine(
82
+ root,
83
+ items=["Paris", "Marseille", "Lyon", "Orléans"],
84
+ command=run_search,
85
+ placeholder="Search for a city",
86
+ max_visible_results=6,
87
+ )
88
+ search.pack(fill="x", padx=20, pady=20)
89
+
90
+ root.mainloop()
91
+ ```
92
+
93
+ ## Configuration
94
+
95
+ The constructor accepts:
96
+
97
+ - `items`: searchable strings.
98
+ - `command`: callback receiving the current text and the selected suggestion. The second argument is `None` when the user submits free text without accepting a suggestion.
99
+ - `placeholder`: text displayed when the unfocused entry is empty.
100
+ - `button_text`: label or symbol displayed on the search button.
101
+ - `search_function`: optional replacement for the complete ranking function.
102
+ - `max_distance_function`: optional function returning the accepted edit
103
+ distance for a given reference-word length.
104
+ - `max_visible_results`: maximum visible listbox rows.
105
+ - `placeholder_color` and `text_color`: entry text colors.
106
+ - `entry_options`, `button_options`, and `listbox_options`: mappings forwarded
107
+ to the corresponding Tkinter widgets.
108
+ - Any remaining keyword arguments are forwarded to the containing `tk.Frame`.
109
+
110
+ Here is the `__init__` shape:
111
+ ```python
112
+ def __init__(
113
+ self,
114
+ master: tk.Misc | None = None,
115
+ *,
116
+ items: Sequence[str] = (),
117
+ command: SearchCallback | None = None,
118
+ placeholder: str = "Search…",
119
+ button_text: str = "🔍",
120
+ search_function: SearchFunction | None = None,
121
+ max_distance_function: MaxDistanceFunction = _default_max_distance,
122
+ max_visible_results: int = 8,
123
+ placeholder_color: str = "grey",
124
+ text_color: str = "black",
125
+ entry_options: Mapping[str, Any] | None = None,
126
+ button_options: Mapping[str, Any] | None = None,
127
+ listbox_options: Mapping[str, Any] | None = None,
128
+ **frame_options: Any,
129
+ ) -> None:
130
+ ```
131
+
132
+ Example with widget customization:
133
+
134
+ ```python
135
+ search = SearchEngine(
136
+ root,
137
+ items=["Paris", "Marseille", "Lyon", "Orléans"],
138
+ entry_options={
139
+ "width": 32,
140
+ "font": ("Segoe UI", 11),
141
+ "relief": "solid",
142
+ },
143
+ button_options={
144
+ "text": "Search",
145
+ "font": ("Segoe UI", 10, "bold"),
146
+ "padx": 12,
147
+ },
148
+ listbox_options={
149
+ "font": ("Segoe UI", 10),
150
+ "height": 6,
151
+ "activestyle": "dotbox",
152
+ },
153
+ )
154
+ ```
155
+
156
+ These mappings are passed directly to `tk.Entry`, `tk.Button`, and `tk.Listbox`.
157
+ `SearchEngine` still owns the entry text variable, the button command, the
158
+ listbox selection behavior, and the visible result count.
159
+
160
+ A custom search function has this shape:
161
+
162
+ ```python
163
+ from collections.abc import Sequence
164
+
165
+
166
+ def custom_search(items: Sequence[str], query: str) -> Sequence[str]:
167
+ normalized_query = query.lower()
168
+ return [item for item in items if normalized_query in item.lower()]
169
+ ```
170
+
171
+ Pass it with `search_function=custom_search`. When a custom search function is
172
+ provided, it is responsible for filtering and ordering all results.
173
+
174
+ ## Public methods
175
+
176
+ - `get()` returns the current search text without the placeholder.
177
+ - `set(text)` replaces the current search text.
178
+ - `set_items(items)` replaces the searchable collection.
179
+ - `invoke()` runs the configured callback.
180
+ - `selected_item` returns the highlighted or accepted suggestion, if any.
181
+
182
+ ## Matching behavior
183
+
184
+ The default matcher normalizes text to lowercase, replaces common accented
185
+ letters with their unaccented forms, and treats `-` and `'` as spaces. Results are
186
+ ranked by:
187
+
188
+ 1. number of unmatched query words;
189
+ 2. match type, from exact word to approximate complete word;
190
+ 3. normalized Levenshtein distance;
191
+ 4. query-word order;
192
+ 5. original item order.
193
+
194
+ The default maximum edit distance is `(word_length + 2) // 5`. Override
195
+ `max_distance_function(word_length)` when an application needs stricter or more permissive
196
+ matching.
197
+
198
+ ## Running the example
199
+
200
+ The example contains 1,000 French commune names:
201
+
202
+ ```console
203
+ python examples/communes.py
204
+ ```
205
+ Filtering the 1,000 sample names should feel immediate on a typical desktop Python installation.
206
+
207
+ ## Running the tests
208
+
209
+ After an editable installation:
210
+
211
+ ```console
212
+ python -m unittest discover -s tests -v
213
+ ```
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/tksearchengine/__init__.py
4
+ src/tksearchengine/search_engine.py
5
+ src/tksearchengine.egg-info/PKG-INFO
6
+ src/tksearchengine.egg-info/SOURCES.txt
7
+ src/tksearchengine.egg-info/dependency_links.txt
8
+ src/tksearchengine.egg-info/top_level.txt
9
+ tests/test_search_engine.py
@@ -0,0 +1 @@
1
+ tksearchengine
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import types
5
+ import unittest
6
+ from collections.abc import Sequence
7
+
8
+ try:
9
+ import tkinter # noqa: F401
10
+ except ModuleNotFoundError:
11
+ tkinter_stub = types.ModuleType("tkinter")
12
+ tkinter_stub.Frame = object
13
+ tkinter_stub.END = "end"
14
+ sys.modules["tkinter"] = tkinter_stub
15
+
16
+ import tksearchengine
17
+ from tksearchengine import SearchEngine, search_engine
18
+
19
+
20
+ class LevenshteinTests(unittest.TestCase):
21
+ def test_common_distances_and_symmetry(self) -> None:
22
+ cases = {
23
+ ("", ""): 0,
24
+ ("", "abc"): 3,
25
+ ("chat", "chat"): 0,
26
+ ("chat", "chats"): 1,
27
+ ("kitten", "sitting"): 3,
28
+ }
29
+ for (first, second), expected in cases.items():
30
+ with self.subTest(first=first, second=second):
31
+ self.assertEqual(
32
+ search_engine._levenshtein_distance(first, second), expected
33
+ )
34
+ self.assertEqual(
35
+ search_engine._levenshtein_distance(second, first), expected
36
+ )
37
+
38
+ def test_default_threshold_boundaries(self) -> None:
39
+ expected = {2: 0, 3: 1, 7: 1, 8: 2, 10: 2, 12: 2, 13: 3}
40
+ self.assertEqual(
41
+ {length: search_engine._default_max_distance(length) for length in expected},
42
+ expected,
43
+ )
44
+
45
+
46
+ class FuzzySearchTests(unittest.TestCase):
47
+ def search(self, items: list[str], query: str) -> list[str]:
48
+ return search_engine._fuzzy_search(
49
+ items, query, search_engine._default_max_distance
50
+ )
51
+
52
+ def test_empty_query_preserves_every_item(self) -> None:
53
+ items = ["Premier", "", "Troisième"]
54
+ self.assertEqual(self.search(items, " "), items)
55
+
56
+ def test_case_and_accents_are_ignored(self) -> None:
57
+ self.assertEqual(self.search(["École élémentaire"], "ecole ELEMENTAIRE"), [
58
+ "École élémentaire"
59
+ ])
60
+
61
+ def test_hyphens_are_treated_as_spaces(self) -> None:
62
+ self.assertEqual(
63
+ self.search(["Saint-Étienne"], "saint etienne"),
64
+ ["Saint-Étienne"],
65
+ )
66
+
67
+ def test_incomplete_word_matches_an_exact_prefix(self) -> None:
68
+ self.assertEqual(self.search(["Orléans", "Paris"], "orl"), ["Orléans"])
69
+
70
+ def test_one_letter_matches_only_exact_prefixes(self) -> None:
71
+ self.assertEqual(
72
+ self.search(["Orléans", "Orange", "Paris"], "o"),
73
+ ["Orléans", "Orange"],
74
+ )
75
+
76
+ def test_approximate_prefix_is_supported(self) -> None:
77
+ self.assertEqual(self.search(["Orléans"], "orx"), ["Orléans"])
78
+
79
+ def test_exact_word_precedes_exact_prefix(self) -> None:
80
+ self.assertEqual(
81
+ self.search(["Orléans", "Or"], "or"),
82
+ ["Or", "Orléans"],
83
+ )
84
+
85
+ def test_more_matched_keywords_have_priority(self) -> None:
86
+ items = ["alpha", "alphi beto"]
87
+ self.assertEqual(self.search(items, "alpha beta"), ["alphi beto", "alpha"])
88
+
89
+ def test_distance_then_word_order_then_original_order_break_ties(self) -> None:
90
+ items = ["beta alpha", "alpha beta", "alpha beta"]
91
+ self.assertEqual(
92
+ self.search(items, "alpha beta"),
93
+ ["alpha beta", "alpha beta", "beta alpha"],
94
+ )
95
+
96
+ def test_one_candidate_word_cannot_match_two_keywords(self) -> None:
97
+ items = ["chat", "chat shat"]
98
+ self.assertEqual(self.search(items, "chat chat"), ["chat shat", "chat"])
99
+
100
+ def test_missing_or_replaced_space_is_recognized(self) -> None:
101
+ items = ["Jean Dupont"]
102
+ for query in ("Jean Dupont", "JeanDupont", "JeannDupont", "JeanbDupont"):
103
+ with self.subTest(query=query):
104
+ self.assertEqual(self.search(items, query), items)
105
+
106
+ def test_only_one_boundary_is_repaired(self) -> None:
107
+ self.assertEqual(
108
+ self.search(["un deux trois"], "unbdeuxbtrois"),
109
+ [],
110
+ )
111
+
112
+ def test_items_without_a_match_are_excluded(self) -> None:
113
+ self.assertEqual(self.search(["Paris", "Londres"], "zzzz"), [])
114
+
115
+ def test_custom_threshold_is_used(self) -> None:
116
+ strict = lambda _length: 0
117
+ self.assertEqual(search_engine._fuzzy_search(["chat"], "chut", strict), [])
118
+
119
+
120
+ class _ListboxStub:
121
+ def delete(self, _first: object, _last: object) -> None:
122
+ pass
123
+
124
+ def insert(self, _index: object, _item: str) -> None:
125
+ pass
126
+
127
+ def configure(self, **_options: object) -> None:
128
+ pass
129
+
130
+ def grid(self, **_options: object) -> None:
131
+ pass
132
+
133
+ def grid_remove(self) -> None:
134
+ pass
135
+
136
+
137
+ class WidgetFilteringTests(unittest.TestCase):
138
+ def test_each_search_receives_the_complete_item_list(self) -> None:
139
+ queries = iter(("a", "ab"))
140
+ received_items: list[list[str]] = []
141
+
142
+ def search(items: Sequence[str], query: str) -> list[str]:
143
+ received_items.append(list(items))
144
+ return [item for item in items if query in item]
145
+
146
+ widget = types.SimpleNamespace(
147
+ get=lambda: next(queries),
148
+ _items=["a", "ab", "b"],
149
+ _search_function=search,
150
+ _max_distance_function=search_engine._default_max_distance,
151
+ _filtered_items=[],
152
+ _max_visible_results=8,
153
+ listbox=_ListboxStub(),
154
+ _hide_results=lambda: None,
155
+ )
156
+
157
+ SearchEngine._refresh_results(widget)
158
+ SearchEngine._refresh_results(widget)
159
+
160
+ self.assertEqual(received_items, [["a", "ab", "b"], ["a", "ab", "b"]])
161
+
162
+ def test_only_search_engine_is_exported(self) -> None:
163
+ self.assertIs(tksearchengine.SearchEngine, SearchEngine)
164
+ self.assertEqual(tksearchengine.__all__, ["SearchEngine"])
165
+ self.assertEqual(search_engine.__all__, ["SearchEngine"])
166
+
167
+
168
+ if __name__ == "__main__":
169
+ unittest.main()