clicknick 0.1.1__py3-none-any.whl
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.
- clicknick/__init__.py +3 -0
- clicknick/clicknick.py +405 -0
- clicknick/combobox_autocomplete.py +91 -0
- clicknick/combobox_filter.py +90 -0
- clicknick/nickname_manager.py +152 -0
- clicknick/py.typed +0 -0
- clicknick/shared.ahk +166 -0
- clicknick/shared_ahk.py +16 -0
- clicknick/widgets.py +326 -0
- clicknick/window_detector.py +160 -0
- clicknick/window_mapping.py +196 -0
- clicknick-0.1.1.dist-info/METADATA +90 -0
- clicknick-0.1.1.dist-info/RECORD +16 -0
- clicknick-0.1.1.dist-info/WHEEL +4 -0
- clicknick-0.1.1.dist-info/entry_points.txt +2 -0
- clicknick-0.1.1.dist-info/licenses/LICENSE +661 -0
clicknick/__init__.py
ADDED
clicknick/clicknick.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import tkinter as tk
|
|
2
|
+
from ctypes import windll
|
|
3
|
+
from tkinter import filedialog, ttk
|
|
4
|
+
|
|
5
|
+
from .nickname_manager import NicknameManager
|
|
6
|
+
from .widgets import NicknamePopup
|
|
7
|
+
from .window_detector import ClickWindowDetector
|
|
8
|
+
from .window_mapping import CLICK_PLC_WINDOW_MAPPING
|
|
9
|
+
|
|
10
|
+
# Set DPI awareness for better UI rendering
|
|
11
|
+
windll.shcore.SetProcessDpiAwareness(1)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ClickNickApp:
|
|
15
|
+
"""Main application for the ClickNick App."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
# Initialize core components
|
|
19
|
+
self.nickname_manager = NicknameManager()
|
|
20
|
+
self.detector = ClickWindowDetector(CLICK_PLC_WINDOW_MAPPING, self)
|
|
21
|
+
|
|
22
|
+
# Create main window
|
|
23
|
+
self.root = tk.Tk()
|
|
24
|
+
self.root.title("ClickNick App")
|
|
25
|
+
self.root.geometry("500x400")
|
|
26
|
+
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
|
|
27
|
+
|
|
28
|
+
# Setup variables
|
|
29
|
+
self.setup_variables()
|
|
30
|
+
|
|
31
|
+
# Setup UI styles
|
|
32
|
+
self.setup_styles()
|
|
33
|
+
|
|
34
|
+
# Create UI components
|
|
35
|
+
self.create_widgets()
|
|
36
|
+
|
|
37
|
+
# Combobox popup (initialized when needed)
|
|
38
|
+
self.popup = None
|
|
39
|
+
|
|
40
|
+
# Monitoring state
|
|
41
|
+
self.monitoring = False
|
|
42
|
+
self.monitor_task_id = None
|
|
43
|
+
|
|
44
|
+
# Connected Click.exe instance
|
|
45
|
+
self.connected_click_pid = None
|
|
46
|
+
self.connected_click_title = None
|
|
47
|
+
self.connected_click_filename = None
|
|
48
|
+
|
|
49
|
+
def setup_variables(self):
|
|
50
|
+
"""Initialize Tkinter variables."""
|
|
51
|
+
self.csv_path_var = tk.StringVar()
|
|
52
|
+
self.status_var = tk.StringVar(value="Not connected")
|
|
53
|
+
self.search_var = tk.StringVar(value="none")
|
|
54
|
+
self.fuzzy_threshold_var = tk.IntVar(value=60) # Default threshold value
|
|
55
|
+
self.threshold_display_var = tk.StringVar(value="60") # Display value
|
|
56
|
+
self.click_instances = [] # Will store (id, title, filename) tuples
|
|
57
|
+
|
|
58
|
+
def setup_styles(self):
|
|
59
|
+
"""Configure ttk styles for the application."""
|
|
60
|
+
style = ttk.Style()
|
|
61
|
+
|
|
62
|
+
# Configure common styles
|
|
63
|
+
style.configure("TButton", padding=6)
|
|
64
|
+
style.configure("TLabel", padding=2)
|
|
65
|
+
|
|
66
|
+
# Status label styles
|
|
67
|
+
style.configure("Status.TLabel", foreground="blue")
|
|
68
|
+
style.configure("Error.TLabel", foreground="red")
|
|
69
|
+
style.configure("Connected.TLabel", foreground="green")
|
|
70
|
+
|
|
71
|
+
def create_widgets(self):
|
|
72
|
+
"""Create all UI widgets."""
|
|
73
|
+
# Main frame to contain everything
|
|
74
|
+
main_frame = ttk.Frame(self.root, padding="10 10 10 10")
|
|
75
|
+
|
|
76
|
+
# Create all widgets
|
|
77
|
+
self.create_csv_section(main_frame)
|
|
78
|
+
self.create_click_instances_section(main_frame)
|
|
79
|
+
self.create_options_section(main_frame)
|
|
80
|
+
self.create_status_section(main_frame)
|
|
81
|
+
|
|
82
|
+
# Pack the main frame
|
|
83
|
+
main_frame.pack(fill=tk.BOTH, expand=True)
|
|
84
|
+
|
|
85
|
+
# Initial refresh of Click instances
|
|
86
|
+
self.refresh_click_instances()
|
|
87
|
+
|
|
88
|
+
def create_csv_section(self, parent):
|
|
89
|
+
"""Create the CSV file selection section."""
|
|
90
|
+
csv_frame = ttk.Frame(parent)
|
|
91
|
+
|
|
92
|
+
# Create widgets
|
|
93
|
+
csv_label = ttk.Label(csv_frame, text="Nickname CSV:")
|
|
94
|
+
csv_entry = ttk.Entry(csv_frame, textvariable=self.csv_path_var, width=30)
|
|
95
|
+
csv_button = ttk.Button(csv_frame, text="Browse...", command=self.browse_csv)
|
|
96
|
+
|
|
97
|
+
# Layout widgets
|
|
98
|
+
csv_label.pack(side=tk.LEFT)
|
|
99
|
+
csv_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
|
|
100
|
+
csv_button.pack(side=tk.LEFT)
|
|
101
|
+
|
|
102
|
+
# Pack the frame
|
|
103
|
+
csv_frame.pack(fill=tk.X, pady=5)
|
|
104
|
+
|
|
105
|
+
def create_click_instances_section(self, parent):
|
|
106
|
+
"""Create the Click.exe instances section."""
|
|
107
|
+
instances_frame = ttk.LabelFrame(parent, text="Click PLC Instances")
|
|
108
|
+
|
|
109
|
+
# Create list box for instances
|
|
110
|
+
self.instances_listbox = tk.Listbox(instances_frame, height=8)
|
|
111
|
+
self.instances_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5)
|
|
112
|
+
|
|
113
|
+
# Add scrollbar
|
|
114
|
+
scrollbar = ttk.Scrollbar(
|
|
115
|
+
instances_frame, orient="vertical", command=self.instances_listbox.yview
|
|
116
|
+
)
|
|
117
|
+
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
118
|
+
self.instances_listbox.configure(yscrollcommand=scrollbar.set)
|
|
119
|
+
|
|
120
|
+
# Buttons frame
|
|
121
|
+
buttons_frame = ttk.Frame(instances_frame)
|
|
122
|
+
|
|
123
|
+
# Create buttons
|
|
124
|
+
refresh_button = ttk.Button(
|
|
125
|
+
buttons_frame, text="Refresh", command=self.refresh_click_instances
|
|
126
|
+
)
|
|
127
|
+
connect_button = ttk.Button(buttons_frame, text="Connect", command=self.connect_to_selected)
|
|
128
|
+
|
|
129
|
+
# Layout buttons
|
|
130
|
+
refresh_button.pack(side=tk.LEFT, padx=5, pady=5)
|
|
131
|
+
connect_button.pack(side=tk.LEFT, padx=5, pady=5)
|
|
132
|
+
|
|
133
|
+
# Pack buttons frame
|
|
134
|
+
buttons_frame.pack(fill=tk.X, padx=5, pady=5)
|
|
135
|
+
|
|
136
|
+
# Pack the main frame
|
|
137
|
+
instances_frame.pack(fill=tk.BOTH, expand=True, pady=10)
|
|
138
|
+
|
|
139
|
+
def create_options_section(self, parent):
|
|
140
|
+
"""Create the options section."""
|
|
141
|
+
options_frame = ttk.LabelFrame(parent, text="Search Options")
|
|
142
|
+
|
|
143
|
+
# Search mode widgets
|
|
144
|
+
filter_frame = ttk.Frame(options_frame)
|
|
145
|
+
filter_label = ttk.Label(filter_frame, text="Filter Mode:")
|
|
146
|
+
none_radio = ttk.Radiobutton(
|
|
147
|
+
filter_frame, text="None", variable=self.search_var, value="none"
|
|
148
|
+
)
|
|
149
|
+
prefix_radio = ttk.Radiobutton(
|
|
150
|
+
filter_frame, text="Prefix Only", variable=self.search_var, value="prefix"
|
|
151
|
+
)
|
|
152
|
+
contains_radio = ttk.Radiobutton(
|
|
153
|
+
filter_frame, text="Contains", variable=self.search_var, value="contains"
|
|
154
|
+
)
|
|
155
|
+
fuzzy_radio = ttk.Radiobutton(
|
|
156
|
+
filter_frame, text="Fuzzy Match", variable=self.search_var, value="fuzzy"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Layout filter widgets
|
|
160
|
+
filter_label.pack(side=tk.LEFT)
|
|
161
|
+
none_radio.pack(side=tk.LEFT, padx=5)
|
|
162
|
+
prefix_radio.pack(side=tk.LEFT, padx=5)
|
|
163
|
+
contains_radio.pack(side=tk.LEFT, padx=5)
|
|
164
|
+
fuzzy_radio.pack(side=tk.LEFT, padx=5)
|
|
165
|
+
filter_frame.pack(fill=tk.X, pady=5)
|
|
166
|
+
|
|
167
|
+
# Fuzzy threshold slider
|
|
168
|
+
fuzzy_frame = ttk.Frame(options_frame)
|
|
169
|
+
fuzzy_label = ttk.Label(fuzzy_frame, text="Fuzzy Threshold:")
|
|
170
|
+
fuzzy_slider = ttk.Scale(
|
|
171
|
+
fuzzy_frame,
|
|
172
|
+
from_=30,
|
|
173
|
+
to=90,
|
|
174
|
+
orient=tk.HORIZONTAL,
|
|
175
|
+
variable=self.fuzzy_threshold_var,
|
|
176
|
+
command=self._update_threshold_display, # Add callback to format the display
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Create a StringVar for formatted display
|
|
180
|
+
self.threshold_display_var = tk.StringVar(value=str(self.fuzzy_threshold_var.get()))
|
|
181
|
+
fuzzy_value_label = ttk.Label(fuzzy_frame, textvariable=self.threshold_display_var)
|
|
182
|
+
|
|
183
|
+
# Layout fuzzy threshold widgets
|
|
184
|
+
fuzzy_label.pack(side=tk.LEFT)
|
|
185
|
+
fuzzy_slider.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
|
|
186
|
+
fuzzy_value_label.pack(side=tk.LEFT, padx=5)
|
|
187
|
+
fuzzy_frame.pack(fill=tk.X, pady=5)
|
|
188
|
+
|
|
189
|
+
# Pack the main frame
|
|
190
|
+
options_frame.pack(fill=tk.X, pady=5)
|
|
191
|
+
|
|
192
|
+
def _update_threshold_display(self, value):
|
|
193
|
+
"""Update the displayed threshold value to an integer."""
|
|
194
|
+
# Convert to integer (this will round down)
|
|
195
|
+
int_value = int(float(value))
|
|
196
|
+
|
|
197
|
+
# Make sure it's a multiple of 5
|
|
198
|
+
int_value = round(int_value / 5) * 5
|
|
199
|
+
|
|
200
|
+
# Update both the display and the actual variable
|
|
201
|
+
self.threshold_display_var.set(str(int_value))
|
|
202
|
+
self.fuzzy_threshold_var.set(int_value)
|
|
203
|
+
|
|
204
|
+
def create_status_section(self, parent):
|
|
205
|
+
"""Create the status and control section."""
|
|
206
|
+
status_frame = ttk.Frame(parent)
|
|
207
|
+
|
|
208
|
+
# Create widgets
|
|
209
|
+
self.status_label = ttk.Label(
|
|
210
|
+
status_frame, textvariable=self.status_var, style="Status.TLabel"
|
|
211
|
+
)
|
|
212
|
+
self.start_button = ttk.Button(status_frame, text="Start", command=self.toggle_monitoring)
|
|
213
|
+
|
|
214
|
+
# Layout widgets
|
|
215
|
+
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
216
|
+
self.start_button.pack(side=tk.RIGHT)
|
|
217
|
+
|
|
218
|
+
# Pack the frame
|
|
219
|
+
status_frame.pack(fill=tk.X, pady=10)
|
|
220
|
+
|
|
221
|
+
def browse_csv(self):
|
|
222
|
+
"""Open file dialog to select CSV file."""
|
|
223
|
+
filepath = filedialog.askopenfilename(
|
|
224
|
+
title="Select Nickname CSV",
|
|
225
|
+
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
|
226
|
+
)
|
|
227
|
+
if filepath:
|
|
228
|
+
self.csv_path_var.set(filepath)
|
|
229
|
+
|
|
230
|
+
def refresh_click_instances(self):
|
|
231
|
+
"""Refresh the list of running Click.exe instances."""
|
|
232
|
+
# Clear current list
|
|
233
|
+
self.instances_listbox.delete(0, tk.END)
|
|
234
|
+
self.click_instances = []
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
# Get all Click.exe instances from detector
|
|
238
|
+
click_instances = self.detector.get_click_instances()
|
|
239
|
+
|
|
240
|
+
if not click_instances:
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
# Update UI with instances
|
|
244
|
+
self.click_instances = click_instances
|
|
245
|
+
for _, _, filename in click_instances:
|
|
246
|
+
self.instances_listbox.insert(tk.END, filename)
|
|
247
|
+
|
|
248
|
+
except Exception as e:
|
|
249
|
+
print(f"Error refreshing Click instances: {e}")
|
|
250
|
+
self._update_status(f"Error: {e!s}", "error")
|
|
251
|
+
|
|
252
|
+
def connect_to_selected(self):
|
|
253
|
+
"""Connect to the selected Click.exe instance."""
|
|
254
|
+
selected_idx = self.instances_listbox.curselection()
|
|
255
|
+
if not selected_idx:
|
|
256
|
+
self._update_status("No Click instance selected", "error")
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
# Get the selected instance
|
|
260
|
+
idx = selected_idx[0]
|
|
261
|
+
if idx >= len(self.click_instances):
|
|
262
|
+
self._update_status("Invalid selection", "error")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# Store the connected instance
|
|
266
|
+
self.connected_click_pid = self.click_instances[idx][0]
|
|
267
|
+
self.connected_click_title = self.click_instances[idx][1]
|
|
268
|
+
self.connected_click_filename = self.click_instances[idx][2]
|
|
269
|
+
|
|
270
|
+
# Update status
|
|
271
|
+
self._update_status(f"Connected to {self.click_instances[idx][2]}", "connected")
|
|
272
|
+
|
|
273
|
+
# Auto-start monitoring if CSV is loaded
|
|
274
|
+
if self.csv_path_var.get() and not self.monitoring:
|
|
275
|
+
self.start_monitoring()
|
|
276
|
+
|
|
277
|
+
def toggle_monitoring(self):
|
|
278
|
+
"""Start or stop monitoring."""
|
|
279
|
+
if self.monitoring:
|
|
280
|
+
self.stop_monitoring()
|
|
281
|
+
else:
|
|
282
|
+
self.start_monitoring()
|
|
283
|
+
|
|
284
|
+
def start_monitoring(self):
|
|
285
|
+
"""Start monitoring for window changes."""
|
|
286
|
+
# Validate CSV file
|
|
287
|
+
csv_path = self.csv_path_var.get()
|
|
288
|
+
if not csv_path:
|
|
289
|
+
self._update_status("Error: No CSV file selected", "error")
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
# Validate connection to Click instance
|
|
293
|
+
if not self.connected_click_pid:
|
|
294
|
+
self._update_status("Error: Not connected to Click instance", "error")
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
if not self.nickname_manager.load_csv(csv_path):
|
|
298
|
+
self._update_status("Error: Failed to load CSV", "error")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# Start monitoring using after
|
|
302
|
+
self.monitoring = True
|
|
303
|
+
self._monitor_task()
|
|
304
|
+
|
|
305
|
+
# Update UI
|
|
306
|
+
self._update_status(f"Monitoring active for {self.connected_click_filename}", "connected")
|
|
307
|
+
self.start_button.configure(text="Stop")
|
|
308
|
+
|
|
309
|
+
def stop_monitoring(self):
|
|
310
|
+
"""Stop monitoring."""
|
|
311
|
+
self.monitoring = False
|
|
312
|
+
|
|
313
|
+
# Cancel scheduled task if it exists
|
|
314
|
+
if self.monitor_task_id:
|
|
315
|
+
self.root.after_cancel(self.monitor_task_id)
|
|
316
|
+
self.monitor_task_id = None
|
|
317
|
+
|
|
318
|
+
# Destroy popup if it exists
|
|
319
|
+
if self.popup:
|
|
320
|
+
self.popup.withdraw()
|
|
321
|
+
self.popup = None
|
|
322
|
+
|
|
323
|
+
self._update_status("Monitoring stopped", "status")
|
|
324
|
+
self.start_button.configure(text="Start")
|
|
325
|
+
|
|
326
|
+
def _update_status(self, message, style="normal"):
|
|
327
|
+
"""Update status message with appropriate style."""
|
|
328
|
+
self.status_var.set(message)
|
|
329
|
+
if style == "error":
|
|
330
|
+
self.status_label.configure(style="Error.TLabel")
|
|
331
|
+
elif style == "connected":
|
|
332
|
+
self.status_label.configure(style="Connected.TLabel")
|
|
333
|
+
else:
|
|
334
|
+
self.status_label.configure(style="Status.TLabel")
|
|
335
|
+
|
|
336
|
+
def _monitor_task(self):
|
|
337
|
+
"""Monitor task that runs every 100ms using after."""
|
|
338
|
+
if not self.monitoring:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
# Check if connected Click.exe still exists
|
|
342
|
+
if not self.detector.check_window_exists(self.connected_click_pid):
|
|
343
|
+
self._update_status("Connected Click instance closed", "error")
|
|
344
|
+
self.stop_monitoring()
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
# Skip detection if popup is visible and being managed
|
|
348
|
+
if self.popup and self.popup.is_active():
|
|
349
|
+
self.monitor_task_id = self.root.after(100, self._monitor_task)
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
# Check for popups belonging to our parent Click.exe
|
|
353
|
+
child_info = self.detector.detect_child_window(self.connected_click_pid)
|
|
354
|
+
if child_info:
|
|
355
|
+
window_id, window_class, edit_control = child_info
|
|
356
|
+
if not self.detector.field_has_text(edit_control, window_id):
|
|
357
|
+
self._handle_popup_window(window_id, window_class, edit_control)
|
|
358
|
+
else:
|
|
359
|
+
# Hide popup if no valid popup window is detected
|
|
360
|
+
if self.popup:
|
|
361
|
+
self.popup.withdraw()
|
|
362
|
+
|
|
363
|
+
# Schedule next check
|
|
364
|
+
self.monitor_task_id = self.root.after(100, self._monitor_task)
|
|
365
|
+
|
|
366
|
+
def _handle_popup_window(self, window_id, window_class, edit_control):
|
|
367
|
+
"""Handle the detected popup window by showing or updating the nickname popup."""
|
|
368
|
+
try:
|
|
369
|
+
# Create popup if it doesn't exist
|
|
370
|
+
if not self.popup:
|
|
371
|
+
self.popup = NicknamePopup(
|
|
372
|
+
self.root,
|
|
373
|
+
self.nickname_manager,
|
|
374
|
+
search_var=self.search_var,
|
|
375
|
+
fuzzy_threshold_var=self.fuzzy_threshold_var,
|
|
376
|
+
)
|
|
377
|
+
self.popup.set_target_window(window_id, window_class, edit_control)
|
|
378
|
+
else:
|
|
379
|
+
# Update target window info
|
|
380
|
+
self.popup.set_target_window(window_id, window_class, edit_control)
|
|
381
|
+
|
|
382
|
+
# Get allowed types for this window/control
|
|
383
|
+
_, allowed_addresses = self.detector.update_window_info(window_class, edit_control)
|
|
384
|
+
|
|
385
|
+
# Show the popup with filtered nicknames
|
|
386
|
+
self.popup.show_combobox(allowed_addresses)
|
|
387
|
+
|
|
388
|
+
except Exception as e:
|
|
389
|
+
print(f"Error showing popup: {e}")
|
|
390
|
+
|
|
391
|
+
def on_closing(self):
|
|
392
|
+
"""Handle application shutdown."""
|
|
393
|
+
if self.monitoring:
|
|
394
|
+
self.stop_monitoring()
|
|
395
|
+
self.root.destroy()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def main() -> None:
|
|
399
|
+
"""Entry point for the application."""
|
|
400
|
+
app = ClickNickApp()
|
|
401
|
+
app.root.mainloop()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
if __name__ == "__main__":
|
|
405
|
+
main() # Call the main function when run directly
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import tkinter as tk
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PrefixAutocomplete:
|
|
5
|
+
"""Handles autocomplete logic for text widgets."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, widget):
|
|
8
|
+
self.widget = widget
|
|
9
|
+
self._completion_list = []
|
|
10
|
+
self._hits = []
|
|
11
|
+
self._hit_index = 0
|
|
12
|
+
self.position = 0
|
|
13
|
+
self.finalized = False
|
|
14
|
+
|
|
15
|
+
def set_completion_list(self, completion_list):
|
|
16
|
+
"""Set the list of completion options."""
|
|
17
|
+
self._completion_list = completion_list
|
|
18
|
+
|
|
19
|
+
def _autocomplete(self, delta: int = 0):
|
|
20
|
+
"""Perform autocompletion on the widget based on the current input."""
|
|
21
|
+
if delta:
|
|
22
|
+
# Delete text from current position to end
|
|
23
|
+
self.widget.delete(self.position, tk.END)
|
|
24
|
+
else:
|
|
25
|
+
# Set the position to the length of the current input text
|
|
26
|
+
self.position = len(self.widget.get())
|
|
27
|
+
|
|
28
|
+
prefix = self.widget.get().lower()
|
|
29
|
+
|
|
30
|
+
hits = [
|
|
31
|
+
element
|
|
32
|
+
for element in self._completion_list
|
|
33
|
+
if str(element).lower().startswith(prefix)
|
|
34
|
+
]
|
|
35
|
+
print(f"Hits: {hits}")
|
|
36
|
+
|
|
37
|
+
if hits:
|
|
38
|
+
closest_match = min(hits, key=lambda x: len(str(x)))
|
|
39
|
+
closest_match_str = str(closest_match)
|
|
40
|
+
if prefix != closest_match_str.lower():
|
|
41
|
+
# Insert the closest match at the beginning, move the cursor to the end
|
|
42
|
+
self.widget.delete(0, tk.END)
|
|
43
|
+
self.widget.insert(0, closest_match_str)
|
|
44
|
+
self.widget.icursor(len(closest_match_str))
|
|
45
|
+
|
|
46
|
+
# Highlight the remaining text after the closest match
|
|
47
|
+
self.widget.select_range(self.position, tk.END)
|
|
48
|
+
|
|
49
|
+
if len(hits) == 1 and closest_match_str.lower() != prefix:
|
|
50
|
+
# If there is only one hit and it's not equal to the lowercase prefix,
|
|
51
|
+
# open dropdown
|
|
52
|
+
self.widget.event_generate("<Down>")
|
|
53
|
+
# self.widget.event_generate("<<ComboboxSelected>>")
|
|
54
|
+
|
|
55
|
+
else:
|
|
56
|
+
# If there are no hits, move the cursor to the current position
|
|
57
|
+
self.widget.icursor(self.position)
|
|
58
|
+
|
|
59
|
+
return hits
|
|
60
|
+
|
|
61
|
+
def autocomplete(self, delta: int = 0) -> None:
|
|
62
|
+
"""Perform autocompletion based on the current input."""
|
|
63
|
+
self._hits = self._autocomplete(delta)
|
|
64
|
+
self._hit_index = 0
|
|
65
|
+
|
|
66
|
+
def reset(self) -> None:
|
|
67
|
+
"""Reset the autocomplete state."""
|
|
68
|
+
self._hits = []
|
|
69
|
+
self._hit_index = 0
|
|
70
|
+
self.position = 0
|
|
71
|
+
self.finalized = False
|
|
72
|
+
|
|
73
|
+
def handle_keyrelease(self, event) -> None:
|
|
74
|
+
"""Handle key release event for autocompletion and navigation."""
|
|
75
|
+
if event.keysym == "BackSpace":
|
|
76
|
+
self.widget.delete(self.position, tk.END)
|
|
77
|
+
# Position stays the same
|
|
78
|
+
elif event.keysym == "Left":
|
|
79
|
+
if self.position < self.widget.index(tk.END):
|
|
80
|
+
self.widget.delete(self.position, tk.END)
|
|
81
|
+
else:
|
|
82
|
+
self.position -= 1
|
|
83
|
+
self.widget.delete(self.position, tk.END)
|
|
84
|
+
elif event.keysym == "Right":
|
|
85
|
+
self.position = self.widget.index(tk.END)
|
|
86
|
+
elif event.keysym == "Return":
|
|
87
|
+
self.widget.icursor(tk.END)
|
|
88
|
+
self.widget.selection_clear()
|
|
89
|
+
return
|
|
90
|
+
elif len(event.keysym) == 1 or event.char == "_":
|
|
91
|
+
self.autocomplete()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
|
|
3
|
+
from rapidfuzz import fuzz
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FilterBase:
|
|
7
|
+
"""Base class for autocomplete strategies"""
|
|
8
|
+
|
|
9
|
+
def filter_matches(self, completion_list, current_text):
|
|
10
|
+
"""Filter completion list based on current text"""
|
|
11
|
+
raise NotImplementedError()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NoneFilter(FilterBase):
|
|
15
|
+
"""Simple prefix matching strategy"""
|
|
16
|
+
|
|
17
|
+
def filter_matches(self, completion_list, current_text):
|
|
18
|
+
return completion_list
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PrefixFilter(FilterBase):
|
|
22
|
+
"""Simple prefix matching strategy"""
|
|
23
|
+
|
|
24
|
+
def filter_matches(self, completion_list, current_text):
|
|
25
|
+
if not current_text:
|
|
26
|
+
return completion_list
|
|
27
|
+
|
|
28
|
+
current_text = current_text.lower()
|
|
29
|
+
return [item for item in completion_list if item.lower().startswith(current_text)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ContainsFilter(FilterBase):
|
|
33
|
+
"""Contains matching strategy"""
|
|
34
|
+
|
|
35
|
+
def filter_matches(self, completion_list, current_text):
|
|
36
|
+
if not current_text:
|
|
37
|
+
return completion_list
|
|
38
|
+
|
|
39
|
+
current_text = current_text.lower()
|
|
40
|
+
return [item for item in completion_list if current_text in item.lower()]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FuzzyFilter(FilterBase):
|
|
44
|
+
"""Fuzzy matching strategy using rapidfuzz with fallback"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, threshold=60):
|
|
47
|
+
self.threshold = threshold
|
|
48
|
+
|
|
49
|
+
def filter_matches(self, completion_list, current_text):
|
|
50
|
+
if not current_text:
|
|
51
|
+
return completion_list
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
# Try using rapidfuzz with partial_ratio for better substring matching
|
|
55
|
+
matches = []
|
|
56
|
+
for item in completion_list:
|
|
57
|
+
# Calculate partial similarity ratio
|
|
58
|
+
ratio = fuzz.partial_ratio(current_text.lower(), item.lower())
|
|
59
|
+
if ratio > self.threshold: # Use configurable threshold
|
|
60
|
+
matches.append((item, ratio))
|
|
61
|
+
|
|
62
|
+
# Sort by similarity ratio (highest first)
|
|
63
|
+
matches.sort(key=lambda x: x[1], reverse=True)
|
|
64
|
+
return [item for item, _ in matches]
|
|
65
|
+
|
|
66
|
+
except ImportError:
|
|
67
|
+
# Prefilter: must contain at least first 3 chars (or all if less than 3)
|
|
68
|
+
min_chars = min(2, len(current_text))
|
|
69
|
+
prefilter_text = current_text[:min_chars].lower()
|
|
70
|
+
|
|
71
|
+
# Prefilter candidates for difflib fallback
|
|
72
|
+
candidates = [item for item in completion_list if prefilter_text in item.lower()]
|
|
73
|
+
|
|
74
|
+
if not candidates:
|
|
75
|
+
# Fall back to contains matching if no matches
|
|
76
|
+
return [item for item in completion_list if current_text.lower() in item.lower()]
|
|
77
|
+
|
|
78
|
+
# Fall back to difflib if rapidfuzz not available
|
|
79
|
+
# Adjust cutoff based on threshold (convert from 0-100 to 0-1 scale)
|
|
80
|
+
cutoff = max(0.3, self.threshold / 100.0 - 0.1) # Ensure minimum cutoff of 0.3
|
|
81
|
+
matches = difflib.get_close_matches(current_text, candidates, n=10, cutoff=cutoff)
|
|
82
|
+
|
|
83
|
+
# Add any remaining candidates that contain the search text
|
|
84
|
+
remaining = [
|
|
85
|
+
item
|
|
86
|
+
for item in candidates
|
|
87
|
+
if item not in matches and current_text.lower() in item.lower()
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
return matches + remaining
|