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

1"""Custom widgets used in the GUI application.""" 

2 

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) 

11 

12logger = logging.getLogger(__name__) 

13 

14 

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() 

22 

23 x = (screen_width - width) // 2 

24 y = (screen_height - height) // 2 

25 

26 window.geometry(f"{width}x{height}+{x}+{y}") 

27 

28 

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") 

34 

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) 

39 

40 # Create headers 

41 header_frame = ctk.CTkFrame(self.scroll_frame) 

42 header_frame.pack(fill="x", pady=(0, 10)) 

43 

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) 

46 

47 # Add commits 

48 for commit in commits: 

49 self._add_commit_row(commit) 

50 

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 ) 

64 

65 def _add_commit_row(self, commit: Commit): 

66 row = ctk.CTkFrame(self.scroll_frame) 

67 row.pack(fill="x", pady=2) 

68 

69 # Configure the row to expand the second column (subject) 

70 row.grid_columnconfigure(1, weight=1) 

71 

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 

80 

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 

89 

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 

95 

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() 

100 

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() 

104 

105 if y + card_height > app_height: # If the card would be cut off 

106 y = button.winfo_rooty() - self.winfo_rooty() - card_height 

107 

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 

111 

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 "" 

116 

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") 

120 

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") 

127 

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") 

131 

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") 

143 

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") 

148 

149 close_button = ctk.CTkButton(self.card, text="Close", command=self.card.destroy) 

150 close_button.pack(pady=5) 

151 

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) 

156 

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!") 

161 

162 

163class AutocompleteEntry(ctk.CTkEntry): 

164 """A customtkinter entry widget with autocomplete functionality.""" 

165 

166 def __init__(self, *args, placeholder_text: str = '', callback: callable = None, **kwargs) -> None: 

167 super().__init__(*args, **kwargs) 

168 

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 = '' 

176 

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) 

181 

182 # Show initial placeholder 

183 self._show_placeholder() 

184 

185 @property 

186 def suggestions(self) -> list[str]: 

187 return self._suggestions 

188 

189 @suggestions.setter 

190 def suggestions(self, value: list[str] | None) -> None: 

191 self._suggestions = value if value is not None else [] 

192 

193 @property 

194 def placeholder_text(self) -> str: 

195 return self._placeholder_text 

196 

197 @placeholder_text.setter 

198 def placeholder_text(self, value: str) -> None: 

199 self._placeholder_text = value 

200 

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 

207 

208 def get(self) -> str: 

209 if self._placeholder_shown or self.cget("state") == "disabled": 

210 return '' 

211 return super().get() 

212 

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) 

219 

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) 

225 

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 

233 

234 if self.suggestion_window: 

235 self.suggestion_window.destroy() 

236 

237 self.suggestion_window = ctk.CTkToplevel() 

238 self.suggestion_window.withdraw() 

239 self.suggestion_window.overrideredirect(True) 

240 

241 suggestion_frame = ctk.CTkScrollableFrame(self.suggestion_window) 

242 suggestion_frame.pack(fill="both", expand=True) 

243 

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) 

251 

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() 

256 

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) 

268 

269 def _on_key_release(self, event: 'tkinter.Event') -> str | None: 

270 self._show_suggestions() 

271 

272 def _on_focus_out(self, event: 'tkinter.Event') -> None: 

273 if self.suggestion_window: 

274 self.after(100, self._destroy_suggestion_window) 

275 

276 if self.get() and not self._placeholder_shown: 

277 self.configure(text_color=self._text_color) 

278 else: 

279 self._show_placeholder() 

280 

281 self._temp_selection = '' 

282 

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() 

289 

290 def _destroy_suggestion_window(self) -> None: 

291 if self.suggestion_window: 

292 self.suggestion_window.destroy() 

293 self.suggestion_window = None 

294 

295 

296class LoadingSpinner(ctk.CTkFrame): 

297 """A loading spinner widget that shows animation during long operations""" 

298 

299 def __init__(self, master, text="Loading...", size=30, thickness=3, color=None, text_color=None, **kwargs): 

300 super().__init__(master, **kwargs) 

301 

302 # Configure the frame 

303 self.configure(fg_color=("gray90", "gray10")) 

304 

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"] 

310 

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 

318 

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) 

328 

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) 

336 

337 # Draw initial spinner 

338 self._draw_spinner() 

339 

340 def _draw_spinner(self): 

341 """Draw the spinner at the current angle""" 

342 self.canvas.delete("spinner") 

343 

344 # Calculate coordinates 

345 x0 = y0 = self.thickness 

346 x1 = y1 = self.size - self.thickness 

347 

348 # Draw arc with gradient color 

349 start_angle = self.angle 

350 extent = 120 # Arc length in degrees 

351 

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 ) 

362 

363 def _update_spinner(self): 

364 """Update the spinner animation""" 

365 if not self.is_running: 

366 return 

367 

368 # Update angle 

369 self.angle = (self.angle + 10) % 360 

370 self._draw_spinner() 

371 

372 # Schedule next update 

373 self.after_id = self.after(50, self._update_spinner) 

374 

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() 

381 

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