Coverage for gui/src/version_finder_gui/widgets.py: 37%
224 statements
« prev ^ index » next coverage.py v7.7.0, created at 2025-03-18 10:30 +0000
« prev ^ index » next coverage.py v7.7.0, created at 2025-03-18 10:30 +0000
1"""Custom widgets used in the GUI application."""
3from typing import List
4import customtkinter as ctk
5from tkinter import messagebox
6import logging
7import tkinter
8from version_finder.version_finder import (
9 Commit,
10)
12logger = logging.getLogger(__name__)
15def center_window(window: ctk.CTkToplevel):
16 """Center the window on the screen"""
17 window.update()
18 width = window.winfo_width()
19 height = window.winfo_height()
20 screen_width = window.winfo_screenwidth()
21 screen_height = window.winfo_screenheight()
23 x = (screen_width - width) // 2
24 y = (screen_height - height) // 2
26 window.geometry(f"{width}x{height}+{x}+{y}")
29class CommitListWindow(ctk.CTkToplevel):
30 def __init__(self, parent, title: str, commits: List[Commit]):
31 super().__init__(parent)
32 self.title(title)
33 self.geometry("800x600")
35 center_window(self)
36 # Create scrollable frame
37 self.scroll_frame = ctk.CTkScrollableFrame(self)
38 self.scroll_frame.pack(fill="both", expand=True, padx=10, pady=10)
40 # Create headers
41 header_frame = ctk.CTkFrame(self.scroll_frame)
42 header_frame.pack(fill="x", pady=(0, 10))
44 ctk.CTkLabel(header_frame, text="Commit Hash", width=100).pack(side="left", padx=5)
45 ctk.CTkLabel(header_frame, text="Subject", width=500).pack(side="left", padx=5)
47 # Add commits
48 for commit in commits:
49 self._add_commit_row(commit)
51 def _create_styled_button(self, parent, text, width=None, command=None):
52 return ctk.CTkButton(
53 parent,
54 text=text,
55 width=width, # Can be None or 0 for expandable buttons
56 command=command,
57 fg_color="transparent",
58 border_width=1,
59 border_color=("gray70", "gray30"),
60 hover_color=("gray90", "gray20"),
61 text_color=("gray10", "gray90"),
62 anchor="w"
63 )
65 def _add_commit_row(self, commit: Commit):
66 row = ctk.CTkFrame(self.scroll_frame)
67 row.pack(fill="x", pady=2)
69 # Configure the row to expand the second column (subject)
70 row.grid_columnconfigure(1, weight=1)
72 # Hash button (fixed width)
73 hash_btn = self._create_styled_button(
74 row,
75 text=commit.sha[:8],
76 width=100,
77 command=lambda: self._copy_to_clipboard(commit.sha)
78 )
79 hash_btn.grid(row=0, column=0, padx=5) # Changed from pack to grid
81 # Subject button (expandable)
82 subject_btn = self._create_styled_button(
83 row,
84 text=commit.subject,
85 width=0, # Set width to 0 to allow expansion
86 command=lambda: self._toggle_commit_details_card(subject_btn, commit)
87 )
88 subject_btn.grid(row=0, column=1, padx=5, sticky="ew") # sticky="ew" makes it expand horizontally
90 def _toggle_commit_details_card(self, button: ctk.CTkButton, commit: Commit):
91 # Check if the card already exists
92 if hasattr(self, "card") and self.card.winfo_exists():
93 self.card.destroy() # Close the card if it's already open
94 return
96 # Get the button's position
97 x = button.winfo_rootx() - self.winfo_rootx()
98 y = button.winfo_rooty() - self.winfo_rooty() + button.winfo_height()
99 button_width = button.winfo_width()
101 # Check if the card would be cut off
102 card_height = 200 # Default height for the card before content calculation
103 app_height = self.winfo_height()
105 if y + card_height > app_height: # If the card would be cut off
106 y = button.winfo_rooty() - self.winfo_rooty() - card_height
108 # Create the card
109 self.card = ctk.CTkFrame(self, corner_radius=15, fg_color="white", width=button_width)
110 self.card.place(x=x, y=y) # Place it on top of the button
112 # Split message into first line and rest
113 message_lines = (commit.message or "").split('\n', 1)
114 first_line = message_lines[0]
115 rest_of_message = message_lines[1] if len(message_lines) > 1 else ""
117 # Add first line in bold
118 first_line_label = ctk.CTkLabel(self.card, text=first_line, font=("Arial", 14, "bold"), anchor="w", justify=tkinter.LEFT)
119 first_line_label.pack(pady=5, padx=10, anchor="w")
121 # Add rest of message if it exists
122 if rest_of_message:
123 message_label = ctk.CTkLabel(
124 self.card, text=rest_of_message, font=(
125 "Arial", 12), wraplength=button_width - 20, anchor="w", justify=tkinter.LEFT)
126 message_label.pack(pady=5, padx=10, anchor="w")
128 # Author with label
129 author_label = ctk.CTkLabel(self.card, text=f"Author: {commit.author}", font=("Arial", 12), anchor="w", justify=tkinter.LEFT)
130 author_label.pack(pady=5, padx=10, anchor="w")
132 # Convert timestamp to human readable format
133 from datetime import datetime
134 try:
135 timestamp_dt = datetime.fromtimestamp(float(commit.timestamp))
136 formatted_time = timestamp_dt.strftime("%B %d, %Y at %I:%M %p")
137 timestamp_label = ctk.CTkLabel(self.card, text=formatted_time, font=("Arial", 12), anchor="w", justify=tkinter.LEFT)
138 timestamp_label.pack(pady=5, padx=10, anchor="w")
139 except (ValueError, TypeError):
140 # Fallback in case timestamp conversion fails
141 timestamp_label = ctk.CTkLabel(self.card, text=str(commit.timestamp), font=("Arial", 12), anchor="w", justify=tkinter.LEFT)
142 timestamp_label.pack(pady=5, padx=10, anchor="w")
144 # Version with label (if exists)
145 if commit.version:
146 version_label = ctk.CTkLabel(self.card, text=f"Version: {commit.version}", font=("Arial", 12), anchor="w", justify=tkinter.LEFT)
147 version_label.pack(pady=5, padx=10, anchor="w")
149 close_button = ctk.CTkButton(self.card, text="Close", command=self.card.destroy)
150 close_button.pack(pady=5)
152 # Update the card height dynamically based on content
153 self.card.update_idletasks()
154 card_height = self.card.winfo_reqheight()
155 self.card.configure(height=card_height)
157 def _copy_to_clipboard(self, text: str):
158 self.clipboard_clear()
159 self.clipboard_append(text)
160 messagebox.showinfo("Success", "Commit hash copied to clipboard!")
163class AutocompleteEntry(ctk.CTkEntry):
164 """A customtkinter entry widget with autocomplete functionality."""
166 def __init__(self, *args, placeholder_text: str = '', callback: callable = None, **kwargs) -> None:
167 super().__init__(*args, **kwargs)
169 self._suggestions: list[str] = []
170 self._placeholder_text: str = placeholder_text
171 self._placeholder_shown: bool = True
172 self.callback: callable | None = callback
173 self.suggestion_window: ctk.CTkToplevel | None = None
174 self._text_color = "black"
175 self._temp_selection: str = ''
177 # Bind events
178 self.bind('<KeyRelease>', self._on_key_release)
179 self.bind('<FocusOut>', self._on_focus_out)
180 self.bind('<FocusIn>', self._on_focus_in)
182 # Show initial placeholder
183 self._show_placeholder()
185 @property
186 def suggestions(self) -> list[str]:
187 return self._suggestions
189 @suggestions.setter
190 def suggestions(self, value: list[str] | None) -> None:
191 self._suggestions = value if value is not None else []
193 @property
194 def placeholder_text(self) -> str:
195 return self._placeholder_text
197 @placeholder_text.setter
198 def placeholder_text(self, value: str) -> None:
199 self._placeholder_text = value
201 def _show_placeholder(self) -> None:
202 super().delete(0, "end")
203 if self.placeholder_text:
204 super().insert(0, self._placeholder_text)
205 self.configure(text_color='gray')
206 self._placeholder_shown = True
208 def get(self) -> str:
209 if self._placeholder_shown or self.cget("state") == "disabled":
210 return ''
211 return super().get()
213 def insert(self, index: str, string: str) -> None:
214 if self._placeholder_shown:
215 super().delete(0, "end")
216 self.configure(text_color=self._text_color)
217 self._placeholder_shown = False
218 super().insert(index, string)
220 def _get_filtered_suggestions(self) -> list[str]:
221 text = self.get().lower()
222 exact_matches = [s for s in self._suggestions if s.lower().startswith(text)]
223 contains_matches = [s for s in self._suggestions if text in s.lower() and not s.lower().startswith(text)]
224 return sorted(exact_matches) + sorted(contains_matches)
226 def _show_suggestions(self) -> None:
227 suggestions = self._get_filtered_suggestions()
228 if not suggestions:
229 if self.suggestion_window:
230 self.suggestion_window.destroy()
231 self.suggestion_window = None
232 return
234 if self.suggestion_window:
235 self.suggestion_window.destroy()
237 self.suggestion_window = ctk.CTkToplevel()
238 self.suggestion_window.withdraw()
239 self.suggestion_window.overrideredirect(True)
241 suggestion_frame = ctk.CTkScrollableFrame(self.suggestion_window)
242 suggestion_frame.pack(fill="both", expand=True)
244 for suggestion in suggestions:
245 btn = ctk.CTkButton(
246 suggestion_frame,
247 text=suggestion,
248 command=lambda s=suggestion: self._select_suggestion(s)
249 )
250 btn.pack(fill="x", padx=2, pady=1)
252 x = self.winfo_rootx()
253 y = self.winfo_rooty() + self.winfo_height()
254 self.suggestion_window.geometry(f"{self.winfo_width()}x200+{x}+{y}")
255 self.suggestion_window.deiconify()
257 def _select_suggestion(self, suggestion: str) -> None:
258 self.delete(0, "end")
259 self.insert(0, suggestion)
260 self._temp_selection = suggestion # Set the current selection so previous text is not reinserted
261 if self.suggestion_window:
262 self.suggestion_window.destroy()
263 self.suggestion_window = None
264 # Move focus to parent window
265 self.master.focus_set()
266 if self.callback:
267 self.callback(suggestion)
269 def _on_key_release(self, event: 'tkinter.Event') -> str | None:
270 self._show_suggestions()
272 def _on_focus_out(self, event: 'tkinter.Event') -> None:
273 if self.suggestion_window:
274 self.after(100, self._destroy_suggestion_window)
276 if self.get() and not self._placeholder_shown:
277 self.configure(text_color=self._text_color)
278 else:
279 self._show_placeholder()
281 self._temp_selection = ''
283 def _on_focus_in(self, event: 'tkinter.Event') -> str | None:
284 self._temp_selection = self.get()
285 super().delete(0, "end")
286 self.configure(text_color="black")
287 self._placeholder_shown = False
288 self._show_suggestions()
290 def _destroy_suggestion_window(self) -> None:
291 if self.suggestion_window:
292 self.suggestion_window.destroy()
293 self.suggestion_window = None
296class LoadingSpinner(ctk.CTkFrame):
297 """A loading spinner widget that shows animation during long operations"""
299 def __init__(self, master, text="Loading...", size=30, thickness=3, color=None, text_color=None, **kwargs):
300 super().__init__(master, **kwargs)
302 # Configure the frame
303 self.configure(fg_color=("gray90", "gray10"))
305 # Set default colors if not provided
306 if color is None:
307 color = ctk.ThemeManager.theme["CTkButton"]["fg_color"]
308 if text_color is None:
309 text_color = ctk.ThemeManager.theme["CTkLabel"]["text_color"]
311 # Store parameters
312 self.size = size
313 self.thickness = thickness
314 self.color = color
315 self.angle = 0
316 self.is_running = False
317 self.after_id = None
319 # Create canvas for spinner
320 self.canvas = ctk.CTkCanvas(
321 self,
322 width=size,
323 height=size,
324 bg=self.cget("fg_color")[0 if ctk.get_appearance_mode() == "Light" else 1],
325 highlightthickness=0
326 )
327 self.canvas.grid(row=0, column=0, padx=10, pady=10)
329 # Create label for text
330 self.label = ctk.CTkLabel(
331 self,
332 text=text,
333 text_color=text_color
334 )
335 self.label.grid(row=0, column=1, padx=10, pady=10)
337 # Draw initial spinner
338 self._draw_spinner()
340 def _draw_spinner(self):
341 """Draw the spinner at the current angle"""
342 self.canvas.delete("spinner")
344 # Calculate coordinates
345 x0 = y0 = self.thickness
346 x1 = y1 = self.size - self.thickness
348 # Draw arc with gradient color
349 start_angle = self.angle
350 extent = 120 # Arc length in degrees
352 # Draw the spinner arc
353 self.canvas.create_arc(
354 x0, y0, x1, y1,
355 start=start_angle,
356 extent=extent,
357 outline=self.color if isinstance(self.color, str) else self.color[1],
358 width=self.thickness,
359 style="arc",
360 tags="spinner"
361 )
363 def _update_spinner(self):
364 """Update the spinner animation"""
365 if not self.is_running:
366 return
368 # Update angle
369 self.angle = (self.angle + 10) % 360
370 self._draw_spinner()
372 # Schedule next update
373 self.after_id = self.after(50, self._update_spinner)
375 def start(self):
376 """Start the spinner animation"""
377 if not self.is_running:
378 self.is_running = True
379 self.grid() # Make sure the spinner is visible
380 self._update_spinner()
382 def stop(self):
383 """Stop the spinner animation"""
384 self.is_running = False
385 if self.after_id:
386 self.after_cancel(self.after_id)
387 self.after_id = None
388 self.grid_remove() # Hide the spinner