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.
- tksearchengine-1.0.0/PKG-INFO +213 -0
- tksearchengine-1.0.0/README.md +194 -0
- tksearchengine-1.0.0/pyproject.toml +30 -0
- tksearchengine-1.0.0/setup.cfg +4 -0
- tksearchengine-1.0.0/src/tksearchengine/__init__.py +5 -0
- tksearchengine-1.0.0/src/tksearchengine/search_engine.py +447 -0
- tksearchengine-1.0.0/src/tksearchengine.egg-info/PKG-INFO +213 -0
- tksearchengine-1.0.0/src/tksearchengine.egg-info/SOURCES.txt +9 -0
- tksearchengine-1.0.0/src/tksearchengine.egg-info/dependency_links.txt +1 -0
- tksearchengine-1.0.0/src/tksearchengine.egg-info/top_level.txt +1 -0
- tksearchengine-1.0.0/tests/test_search_engine.py +169 -0
|
@@ -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,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
|
+
|
|
@@ -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()
|