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 ADDED
@@ -0,0 +1,3 @@
1
+ from .clicknick import main
2
+
3
+ __all__ = ["main"]
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