brain-cleaner 1.2.0

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.
package/app.py ADDED
@@ -0,0 +1,709 @@
1
+ import os
2
+ import sys
3
+
4
+ # macOS Compatibility fixes (handled by system environment)
5
+ import customtkinter as ctk
6
+
7
+ import threading
8
+ import subprocess
9
+ from scanner import BrainScanner
10
+ from pathlib import Path
11
+ from PIL import Image
12
+
13
+ ctk.set_appearance_mode("Dark")
14
+ ctk.set_default_color_theme("blue")
15
+
16
+
17
+ class BrainCleanerApp(ctk.CTk):
18
+ def __init__(self):
19
+ super().__init__()
20
+
21
+ self.title("Brain Cleaner - AI Residue Removal")
22
+ self.geometry("1100x720")
23
+ self.scanner = BrainScanner()
24
+ self.interrupt_event = threading.Event()
25
+ self.residue_rows = [] # [(wrapper, cat, var, path, children_frame, child_rows)]
26
+ self.filter_buttons = {}
27
+ self.active_filter = "All"
28
+ self.current_scan_path = str(Path.home())
29
+ self.current_loc_type = "Home"
30
+
31
+ # Load Icon
32
+ assets_dir = os.path.join(os.path.dirname(__file__), "assets")
33
+ icon_ui_path = os.path.join(assets_dir, "icon_ui.png")
34
+ self.logo_image = None
35
+ if os.path.exists(icon_ui_path):
36
+ try:
37
+ img = Image.open(icon_ui_path)
38
+ self.logo_image = ctk.CTkImage(light_image=img, dark_image=img, size=(42, 42))
39
+ except Exception:
40
+ pass
41
+
42
+ # Category definitions
43
+ self.AI_CATS = ["Gemini", "Claude", "IDE Agents", "Other Tools"]
44
+ self.NPM_CATS = ["Node Modules"]
45
+
46
+ # Info slider data
47
+ self.info_states = [
48
+ {
49
+ "title": "Delete Consequences",
50
+ "text": "You will free up space, but might lose local chat histories and plugin configurations.",
51
+ },
52
+ {
53
+ "title": "Usage Tips",
54
+ "text": "Scan weekly to keep your system optimized. Use 'Custom Folder' for specific project cleanups.",
55
+ },
56
+ {
57
+ "title": "Pro Optimization",
58
+ "text": "For a full cleanup, close your IDE (Cursor, VSCode) before cleaning temporary files.",
59
+ }
60
+ ]
61
+ self.current_info_index = 0
62
+
63
+ # ── Grid Layout ──────────────────────────────────────────
64
+ self.grid_columnconfigure(1, weight=1)
65
+ self.grid_rowconfigure(0, weight=1)
66
+
67
+ # ── Sidebar ───────────────────────────────────────────────
68
+ self.sidebar = ctk.CTkFrame(self, width=210, corner_radius=0)
69
+ self.sidebar.grid(row=0, column=0, rowspan=4, sticky="nsew")
70
+ self.sidebar.grid_rowconfigure(9, weight=1)
71
+ self.sidebar.grid_columnconfigure(0, weight=1)
72
+
73
+ title_f = ctk.CTkFrame(self.sidebar, fg_color="transparent")
74
+ title_f.grid(row=0, column=0, padx=20, pady=(15, 15), sticky="ew")
75
+
76
+ if self.logo_image:
77
+ ctk.CTkLabel(title_f, text="", image=self.logo_image).pack(pady=(0, 8))
78
+
79
+ ctk.CTkLabel(title_f, text="Brain Cleaner",
80
+ font=ctk.CTkFont(size=19, weight="bold")
81
+ ).pack(pady=(0, 2))
82
+ ctk.CTkLabel(title_f, text="v1.1.0",
83
+ font=ctk.CTkFont(size=11, slant="italic"), text_color="#a1a1a1"
84
+ ).pack()
85
+
86
+ # Location
87
+ ctk.CTkLabel(self.sidebar, text="Scan Scope:", anchor="w",
88
+ font=ctk.CTkFont(size=12, weight="bold")
89
+ ).grid(row=1, column=0, padx=20, pady=(0, 5), sticky="ew")
90
+
91
+ self.home_btn = ctk.CTkButton(self.sidebar, text="Home",
92
+ command=lambda: self.set_location("Home"),
93
+ fg_color="transparent", border_width=1, anchor="w")
94
+ self.home_btn.grid(row=2, column=0, padx=20, pady=3, sticky="ew")
95
+
96
+ self.system_btn = ctk.CTkButton(self.sidebar, text="Full System",
97
+ command=lambda: self.set_location("System"),
98
+ fg_color="transparent", border_width=1, anchor="w")
99
+ self.system_btn.grid(row=3, column=0, padx=20, pady=3, sticky="ew")
100
+
101
+ self.custom_folder_btn = ctk.CTkButton(self.sidebar, text="Custom Folder",
102
+ command=self.select_custom_folder,
103
+ fg_color="transparent", border_width=1, anchor="w")
104
+ self.custom_folder_btn.grid(row=4, column=0, padx=20, pady=3, sticky="ew")
105
+
106
+ self._highlight_loc_btn()
107
+
108
+ # Scan Mode
109
+ ctk.CTkLabel(self.sidebar, text="Scan Mode:", anchor="w",
110
+ font=ctk.CTkFont(size=12, weight="bold")
111
+ ).grid(row=5, column=0, padx=20, pady=(14, 4), sticky="ew")
112
+
113
+ self.scan_mode_var = ctk.StringVar(value="ai")
114
+
115
+ ctk.CTkRadioButton(self.sidebar, text="AI Tools",
116
+ variable=self.scan_mode_var, value="ai",
117
+ font=ctk.CTkFont(size=12), border_color="#1f538d",
118
+ hover_color="#1f538d", fg_color="#1f538d",
119
+ command=self._update_mode_ui
120
+ ).grid(row=6, column=0, padx=24, pady=2, sticky="w")
121
+
122
+ ctk.CTkRadioButton(self.sidebar, text="NPM Modules",
123
+ variable=self.scan_mode_var, value="npm",
124
+ font=ctk.CTkFont(size=12), border_color="#2e7d32",
125
+ hover_color="#2e7d32", fg_color="#2e7d32",
126
+ command=self._update_mode_ui
127
+ ).grid(row=7, column=0, padx=24, pady=(2, 4), sticky="w")
128
+
129
+ # ── Bottom Sidebar Controls ───────────────────────────────
130
+ bottom = ctk.CTkFrame(self.sidebar, fg_color="transparent")
131
+ bottom.grid(row=10, column=0, padx=20, pady=20, sticky="ew")
132
+ bottom.grid_columnconfigure(0, weight=1)
133
+
134
+ self.run_scan_button = ctk.CTkButton(
135
+ bottom, text="START SCAN",
136
+ command=lambda: self.start_scan(self.current_scan_path),
137
+ fg_color="#1f538d", hover_color="#14375e",
138
+ height=130, corner_radius=18,
139
+ font=ctk.CTkFont(size=15, weight="bold"))
140
+ self.run_scan_button.grid(row=0, column=0, pady=(0, 15), sticky="ew")
141
+
142
+ self.stop_scan_button = ctk.CTkButton(
143
+ bottom, text="STOP",
144
+ command=self.stop_scan, fg_color="#d32f2f", hover_color="#b71c1c",
145
+ height=130, corner_radius=18,
146
+ font=ctk.CTkFont(size=15, weight="bold"))
147
+ self.stop_scan_button.grid(row=0, column=0, pady=(0, 15), sticky="ew")
148
+ self.stop_scan_button.grid_remove()
149
+
150
+ self.clean_selected_button = ctk.CTkButton(
151
+ bottom, text="Clean Selected",
152
+ command=self.clean_selected, state="disabled",
153
+ fg_color="#2b71b1", hover_color="#1a4d7d",
154
+ height=38, font=ctk.CTkFont(weight="bold"))
155
+ self.clean_selected_button.grid(row=1, column=0, pady=4, sticky="ew")
156
+
157
+ self.clean_all_button = ctk.CTkButton(
158
+ bottom, text="Clean All (Visible)",
159
+ command=self.clean_all, state="disabled",
160
+ fg_color="transparent", border_width=1,
161
+ text_color=("#d32f2f", "#ff6666"), height=30)
162
+ self.clean_all_button.grid(row=2, column=0, pady=(4, 12), sticky="ew")
163
+
164
+ # Appearance
165
+ ctk.CTkLabel(self.sidebar, text="Appearance:", anchor="w",
166
+ font=ctk.CTkFont(size=10)
167
+ ).grid(row=11, column=0, padx=20, pady=(0, 2), sticky="ew")
168
+ app_menu = ctk.CTkOptionMenu(self.sidebar, values=["Dark", "Light", "System"],
169
+ command=lambda m: ctk.set_appearance_mode(m),
170
+ height=24, font=ctk.CTkFont(size=10))
171
+ app_menu.grid(row=12, column=0, padx=20, pady=(0, 5), sticky="ew")
172
+ app_menu.set("Dark")
173
+
174
+ self.show_logs_var = ctk.BooleanVar(value=False)
175
+ ctk.CTkSwitch(self.sidebar, text="Show Activity Logs",
176
+ variable=self.show_logs_var, command=self.toggle_logs,
177
+ font=ctk.CTkFont(size=10)
178
+ ).grid(row=13, column=0, padx=20, pady=10, sticky="ew")
179
+
180
+ # ── Main Area ─────────────────────────────────────────────
181
+ main = ctk.CTkFrame(self, fg_color="transparent")
182
+ main.grid(row=0, column=1, padx=10, pady=10, sticky="nsew")
183
+ main.grid_columnconfigure(0, weight=1)
184
+ main.grid_rowconfigure(4, weight=1)
185
+ self.main = main
186
+
187
+ # Info Slider
188
+ self.info_bubble = ctk.CTkFrame(main, corner_radius=14, border_width=1)
189
+ self.info_bubble.grid(row=0, column=0, padx=10, pady=(5, 5), sticky="ew")
190
+ self.info_bubble.grid_columnconfigure(1, weight=1)
191
+
192
+ ctk.CTkButton(self.info_bubble, text="❮", width=28, fg_color="transparent",
193
+ hover_color=("#c0c0c0", "#3d3d3d"),
194
+ command=lambda: self.navigate_slider(-1)
195
+ ).grid(row=0, column=0, rowspan=2, padx=(8, 0))
196
+
197
+ self.info_title = ctk.CTkLabel(self.info_bubble, text=self.info_states[0]["title"],
198
+ font=ctk.CTkFont(size=13, weight="bold"), anchor="w")
199
+ self.info_title.grid(row=0, column=1, pady=(8, 0), sticky="ew", padx=(8, 0))
200
+
201
+ self.info_text = ctk.CTkLabel(self.info_bubble, text=self.info_states[0]["text"],
202
+ font=ctk.CTkFont(size=10), wraplength=480,
203
+ justify="left", anchor="w")
204
+ self.info_text.grid(row=1, column=1, pady=(2, 4), sticky="ew", padx=(8, 0))
205
+
206
+ ctk.CTkButton(self.info_bubble, text="❯", width=28, fg_color="transparent",
207
+ hover_color=("#c0c0c0", "#3d3d3d"),
208
+ command=lambda: self.navigate_slider(1)
209
+ ).grid(row=0, column=2, rowspan=2, padx=(0, 8))
210
+
211
+ dots_f = ctk.CTkFrame(self.info_bubble, fg_color="transparent")
212
+ dots_f.grid(row=2, column=1, pady=(0, 6), sticky="w", padx=(8, 0))
213
+ self.dots_labels = []
214
+ for i in range(len(self.info_states)):
215
+ d = ctk.CTkLabel(dots_f, text="●" if i == 0 else "○", font=ctk.CTkFont(size=9))
216
+ d.pack(side="left", padx=2)
217
+ self.dots_labels.append(d)
218
+
219
+ # Filter Bubbles Bar
220
+ filter_bar = ctk.CTkFrame(main, fg_color="transparent")
221
+ filter_bar.grid(row=1, column=0, padx=10, pady=(10, 0), sticky="ew")
222
+
223
+ ctk.CTkLabel(filter_bar, text="Filters:", font=ctk.CTkFont(size=12, weight="bold")
224
+ ).pack(side="left", padx=(5, 10))
225
+
226
+ self.bubbles_container = ctk.CTkFrame(filter_bar, fg_color="transparent")
227
+ self.bubbles_container.pack(side="left", fill="x", expand=True)
228
+
229
+ # Select All / None (right side)
230
+ ctk.CTkButton(filter_bar, text="☑ All", width=62, height=26,
231
+ corner_radius=8, border_width=1, border_color="#757575",
232
+ fg_color="transparent", hover_color=("#d0d0d0", "#3a3a3a"),
233
+ text_color=("#333333", "#cccccc"), font=ctk.CTkFont(size=11),
234
+ command=lambda: self.set_all_visible(True)
235
+ ).pack(side="right", padx=(4, 2))
236
+
237
+ ctk.CTkButton(filter_bar, text="☐ None", width=68, height=26,
238
+ corner_radius=8, border_width=1, border_color="#757575",
239
+ fg_color="transparent", hover_color=("#d0d0d0", "#3a3a3a"),
240
+ text_color=("#333333", "#cccccc"), font=ctk.CTkFont(size=11),
241
+ command=lambda: self.set_all_visible(False)
242
+ ).pack(side="right", padx=(2, 4))
243
+
244
+ # Progress Area
245
+ self.progress_container = ctk.CTkFrame(main, fg_color="transparent")
246
+ self.progress_container.grid(row=2, column=0, padx=10, pady=(8, 0), sticky="ew")
247
+ self.progress_container.grid_columnconfigure(0, weight=1)
248
+ self.progress_container.grid_remove()
249
+
250
+ self.progress_label = ctk.CTkLabel(self.progress_container, text="",
251
+ font=ctk.CTkFont(size=10, slant="italic"))
252
+ self.progress_label.grid(row=0, column=0, pady=(0, 2))
253
+
254
+ self.progress_bar = ctk.CTkProgressBar(self.progress_container, mode="indeterminate",
255
+ height=10, progress_color="#1f538d")
256
+ self.progress_bar.grid(row=1, column=0, padx=10, pady=(0, 5), sticky="ew")
257
+
258
+ # Static Header for Scan Selection
259
+ self.scan_header_frame = ctk.CTkFrame(main, fg_color="#1f538d", corner_radius=8)
260
+ self.scan_header_frame.grid(row=3, column=0, padx=10, pady=(8, 0), sticky="ew")
261
+
262
+ txt_f = ctk.CTkFrame(self.scan_header_frame, fg_color="transparent")
263
+ txt_f.pack(side="left", fill="x", expand=True, padx=12, pady=10)
264
+
265
+ self.scan_header_title = ctk.CTkLabel(txt_f, text="AI Tools Cleanup",
266
+ font=ctk.CTkFont(size=14, weight="bold"),
267
+ text_color="white", anchor="w")
268
+ self.scan_header_title.pack(fill="x")
269
+ self.scan_header_desc = ctk.CTkLabel(txt_f, text="Identify and remove cache, logs, and configs left by AI assistants.",
270
+ font=ctk.CTkFont(size=11), text_color="#e0e0e0", anchor="w")
271
+ self.scan_header_desc.pack(fill="x", pady=(2, 0))
272
+
273
+ self.scan_header_count = ctk.CTkLabel(self.scan_header_frame, text="",
274
+ font=ctk.CTkFont(size=13, weight="bold"), text_color="#ffffff")
275
+ self.scan_header_count.pack(side="right", padx=16, pady=10)
276
+
277
+ # Results Scrollable Frame
278
+ self.results_frame = ctk.CTkScrollableFrame(main)
279
+ self.results_frame.grid(row=4, column=0, padx=10, pady=8, sticky="nsew")
280
+ self.results_frame.grid_columnconfigure(0, weight=1)
281
+
282
+ # Footer
283
+ footer = ctk.CTkFrame(main, fg_color="transparent")
284
+ footer.grid(row=5, column=0, padx=10, pady=(0, 5), sticky="ew")
285
+
286
+ ctk.CTkLabel(footer, text="AI Residue Manager",
287
+ font=ctk.CTkFont(size=14, weight="bold")).pack(side="left", padx=5)
288
+
289
+ self.path_display = ctk.CTkLabel(footer, text=f"Target: {self.current_scan_path}",
290
+ font=ctk.CTkFont(size=10, slant="italic"))
291
+ self.path_display.pack(side="left", padx=15)
292
+
293
+ self.status_label = ctk.CTkLabel(footer, text="Ready to scan",
294
+ font=ctk.CTkFont(size=11))
295
+ self.status_label.pack(side="right", padx=10)
296
+
297
+ # Log Area
298
+ self.log_textbox = ctk.CTkTextbox(main, height=80, font=ctk.CTkFont(size=10))
299
+ self.log_textbox.grid(row=6, column=0, padx=10, pady=(0, 8), sticky="nsew")
300
+ self.log_textbox.insert("0.0", "--- Activity Log ---\n")
301
+ self.log_textbox.configure(state="disabled")
302
+ self.log_textbox.grid_remove()
303
+
304
+ self.create_filter_bubbles(["All"])
305
+
306
+ # Initialize mode UI
307
+ self._update_mode_ui()
308
+
309
+ def _update_mode_ui(self):
310
+ mode = self.scan_mode_var.get()
311
+ if mode == "ai":
312
+ self.scan_header_frame.configure(fg_color="#1f538d")
313
+ self.scan_header_title.configure(text="AI Tools Cleanup")
314
+ self.scan_header_desc.configure(text="Identify and remove cache, logs, and configs left by AI assistants (Gemini, Claude, Cursor...).")
315
+ else:
316
+ self.scan_header_frame.configure(fg_color="#2e7d32")
317
+ self.scan_header_title.configure(text="NPM Modules Cleanup")
318
+ self.scan_header_desc.configure(text="Free up space by removing heavy node_modules directories from web projects.")
319
+ self.scan_header_count.configure(text="")
320
+
321
+ # ── Helpers ───────────────────────────────────────────────────
322
+
323
+ def log(self, text):
324
+ self.log_textbox.configure(state="normal")
325
+ self.log_textbox.insert("end", f"{text}\n")
326
+ self.log_textbox.see("end")
327
+ self.log_textbox.configure(state="disabled")
328
+
329
+ def toggle_logs(self):
330
+ if self.show_logs_var.get():
331
+ self.log_textbox.grid()
332
+ else:
333
+ self.log_textbox.grid_remove()
334
+
335
+ def get_category_color(self, cat):
336
+ return {
337
+ "Gemini": "#1a73e8",
338
+ "Claude": "#d97757",
339
+ "IDE Agents": "#7c4dff",
340
+ "Other Tools": "#546e7a",
341
+ "Node Modules": "#2e7d32"
342
+ }.get(cat, "#757575")
343
+
344
+ # ── Info Slider ───────────────────────────────────────────────
345
+
346
+ def navigate_slider(self, direction):
347
+ self.current_info_index = (self.current_info_index + direction) % len(self.info_states)
348
+ state = self.info_states[self.current_info_index]
349
+ self.info_bubble.configure(border_width=3)
350
+ self.after(150, lambda: self.info_bubble.configure(border_width=1))
351
+ self.info_title.configure(text=state["title"])
352
+ self.info_text.configure(text=state["text"])
353
+ for i, dot in enumerate(self.dots_labels):
354
+ dot.configure(text="●" if i == self.current_info_index else "○")
355
+
356
+ # ── Location ──────────────────────────────────────────────────
357
+
358
+ def set_location(self, loc_type):
359
+ self.current_loc_type = loc_type
360
+ if loc_type == "Home":
361
+ self.current_scan_path = str(Path.home())
362
+ elif loc_type == "System":
363
+ self.current_scan_path = "/"
364
+ self._highlight_loc_btn()
365
+ self.path_display.configure(text=f"Target: {self.current_scan_path}")
366
+
367
+ def _highlight_loc_btn(self):
368
+ mapping = {"Home": self.home_btn, "System": self.system_btn, "Custom": self.custom_folder_btn}
369
+ for name, btn in mapping.items():
370
+ try:
371
+ if name == self.current_loc_type:
372
+ btn.configure(fg_color="#1f538d", border_width=0)
373
+ else:
374
+ btn.configure(fg_color="transparent", border_width=1)
375
+ except Exception:
376
+ pass
377
+
378
+ def select_custom_folder(self):
379
+ path = ctk.filedialog.askdirectory()
380
+ if path:
381
+ self.current_scan_path = path
382
+ self.current_loc_type = "Custom"
383
+ self._highlight_loc_btn()
384
+ self.path_display.configure(text=f"Target: {path}")
385
+ self.log(f"Custom path: {path}")
386
+
387
+ # ── Scan ──────────────────────────────────────────────────────
388
+
389
+ def stop_scan(self):
390
+ self.interrupt_event.set()
391
+ self.stop_scan_button.configure(state="disabled")
392
+ self.log("Stopping scan...")
393
+
394
+ def start_scan(self, path):
395
+ self.interrupt_event.clear()
396
+
397
+ # Reset results
398
+ for frame, *_ in self.residue_rows:
399
+ frame.destroy()
400
+ self.residue_rows = []
401
+
402
+ # UI state
403
+ self.run_scan_button.grid_remove()
404
+ self.stop_scan_button.grid()
405
+ self.stop_scan_button.configure(state="normal")
406
+ self.clean_selected_button.configure(state="disabled")
407
+ self.clean_all_button.configure(state="disabled")
408
+
409
+ self.progress_label.configure(text=f"Scanning {path} ...")
410
+ self.progress_container.grid()
411
+ self.progress_bar.start()
412
+ self.status_label.configure(text="Scanning...")
413
+ self.create_filter_bubbles(["Scanning..."])
414
+
415
+ self.scanning_active = True
416
+ self.scan_icons = ["-", "\\", "|", "/"]
417
+ self.scan_icon_idx = 0
418
+ self._animate_scan()
419
+
420
+ mode = self.scan_mode_var.get()
421
+ self.log(f"Scan: {path} | mode: {mode}")
422
+ threading.Thread(target=self._run_scan, args=(path, mode), daemon=True).start()
423
+
424
+ def _animate_scan(self):
425
+ if getattr(self, "scanning_active", False):
426
+ icon = self.scan_icons[self.scan_icon_idx % len(self.scan_icons)]
427
+ self.stop_scan_button.configure(text=f"{icon} SCANNING...")
428
+ self.scan_icon_idx += 1
429
+ self.after(400, self._animate_scan)
430
+
431
+ def _run_scan(self, path, mode):
432
+ raw = self.scanner.find_residues(path, self.interrupt_event)
433
+ # Filter by mode
434
+ results = {"All": []}
435
+ if mode == "ai":
436
+ for c in self.AI_CATS:
437
+ results[c] = raw.get(c, [])
438
+ results["All"] += results[c]
439
+ elif mode == "npm":
440
+ for c in self.NPM_CATS:
441
+ results[c] = raw.get(c, [])
442
+ results["All"] += results[c]
443
+ self.scanning_active = False
444
+ self.after(0, lambda: self._finish_scan(results, mode))
445
+
446
+ def _finish_scan(self, results, mode):
447
+ self.progress_bar.stop()
448
+ self.progress_container.grid_remove()
449
+ self.stop_scan_button.grid_remove()
450
+ self.run_scan_button.grid()
451
+
452
+ total = len(results.get("All", []))
453
+ total_bytes = sum(item[2] for item in results.get("All", []))
454
+ total_str = self.scanner.format_size(total_bytes)
455
+
456
+ if total == 0:
457
+ self.status_label.configure(text="No residues found.")
458
+ self.log("Scan complete. Nothing found.")
459
+ self.create_filter_bubbles(["No results"])
460
+ return
461
+
462
+ self.status_label.configure(text=f"Found {total} items — {total_str}")
463
+ self.log(f"Scan complete. {total} items found ({total_str}).")
464
+
465
+ found_cats = [c for c in (self.AI_CATS + self.NPM_CATS) if results.get(c)]
466
+ self.create_filter_bubbles(["All"] + found_cats)
467
+
468
+ count = 0
469
+ if mode == "ai":
470
+ section_cats = [c for c in self.AI_CATS if results.get(c)]
471
+ count = sum(len(results[c]) for c in section_cats)
472
+ for cat in section_cats:
473
+ self._render_rows(results[cat], cat)
474
+ elif mode == "npm":
475
+ section_cats = [c for c in self.NPM_CATS if results.get(c)]
476
+ count = sum(len(results[c]) for c in section_cats)
477
+ for cat in section_cats:
478
+ self._render_rows(results[cat], cat)
479
+
480
+ if count > 0:
481
+ self.scan_header_count.configure(text=f"{count} items")
482
+
483
+ self.clean_selected_button.configure(state="normal")
484
+ self.clean_all_button.configure(state="normal")
485
+ self.active_filter = "All"
486
+ self.update_bubble_selection("All")
487
+
488
+ def _render_rows(self, items, cat):
489
+ """Render a list of (path, size_str, size_bytes) result items."""
490
+ for path, size_str, _ in items:
491
+ var = ctk.BooleanVar(value=False)
492
+
493
+ wrapper = ctk.CTkFrame(self.results_frame, fg_color="transparent")
494
+ wrapper.pack(fill="x", padx=4, pady=2)
495
+
496
+ row = ctk.CTkFrame(wrapper, fg_color=("#f5f5f5", "#2b2b2b"), corner_radius=8)
497
+ row.pack(fill="x")
498
+ row.grid_columnconfigure(3, weight=1)
499
+
500
+ ctk.CTkCheckBox(row, text="", variable=var, width=24
501
+ ).grid(row=0, column=0, padx=(10, 4), pady=6)
502
+
503
+ ctk.CTkLabel(row, text=f" {cat} ",
504
+ fg_color=self.get_category_color(cat),
505
+ text_color="white", corner_radius=5,
506
+ font=ctk.CTkFont(size=9, weight="bold")
507
+ ).grid(row=0, column=1, padx=(4, 10), pady=6)
508
+
509
+ size_lbl = ctk.CTkLabel(row, text=size_str, text_color="#FF9500",
510
+ font=ctk.CTkFont(size=11, weight="bold"),
511
+ width=72, anchor="e")
512
+ size_lbl.grid(row=0, column=2, padx=(0, 10), pady=6)
513
+
514
+ path_lbl = ctk.CTkLabel(row, text=path,
515
+ font=ctk.CTkFont(size=11, weight="bold"), anchor="w")
516
+ path_lbl.grid(row=0, column=3, padx=(0, 4), pady=6, sticky="ew")
517
+
518
+ expand_btn = ctk.CTkButton(
519
+ row, text="›", width=28, height=28,
520
+ fg_color=("#e0e0e0", "#3a3a3a"),
521
+ hover_color=("#c8c8c8", "#484848"),
522
+ text_color=("#333333", "#eeeeee"),
523
+ corner_radius=8, border_width=0,
524
+ font=ctk.CTkFont(size=16, weight="bold"))
525
+ expand_btn.grid(row=0, column=4, padx=(0, 8), pady=6)
526
+
527
+ children_frame = ctk.CTkFrame(wrapper, fg_color=("#ececec", "#242424"), corner_radius=8)
528
+ child_rows = []
529
+ expanded = [False]
530
+
531
+ def _sync_children(*_, pv=var, cr=child_rows):
532
+ state = pv.get()
533
+ for _, cv in cr:
534
+ cv.set(state)
535
+
536
+ var.trace_add("write", _sync_children)
537
+
538
+ def _populate_children(p=path, cf=children_frame, cr=child_rows, pv=var):
539
+ if cr:
540
+ return
541
+ try:
542
+ entries = sorted(os.scandir(p), key=lambda e: (not e.is_dir(), e.name.lower()))
543
+ except PermissionError:
544
+ ctk.CTkLabel(cf, text=" ⚠️ Permission denied",
545
+ font=ctk.CTkFont(size=10), text_color="#FF9500"
546
+ ).pack(anchor="w", padx=12, pady=4)
547
+ return
548
+ for entry in entries:
549
+ child_var = ctk.BooleanVar(value=pv.get())
550
+ child_row = ctk.CTkFrame(cf, fg_color="transparent")
551
+ child_row.pack(fill="x", padx=8, pady=1)
552
+ child_row.grid_columnconfigure(1, weight=1)
553
+ ctk.CTkCheckBox(child_row, text="", variable=child_var, width=20
554
+ ).grid(row=0, column=0, padx=(8, 4), pady=3)
555
+ icon = "📁" if entry.is_dir() else "📄"
556
+ ctk.CTkLabel(child_row, text=f"{icon} {entry.name}",
557
+ font=ctk.CTkFont(size=10), anchor="w"
558
+ ).grid(row=0, column=1, padx=2, pady=3, sticky="ew")
559
+ try:
560
+ sz_str = self.scanner.format_size(entry.stat().st_size) if entry.is_file() else ""
561
+ except Exception:
562
+ sz_str = ""
563
+ if sz_str:
564
+ ctk.CTkLabel(child_row, text=sz_str, text_color="#FF9500",
565
+ font=ctk.CTkFont(size=10, weight="bold")
566
+ ).grid(row=0, column=2, padx=(0, 10), pady=3)
567
+ cr.append((entry.path, child_var))
568
+
569
+ def _toggle_expand(cf=children_frame, btn=expand_btn, ex=expanded,
570
+ populate=_populate_children):
571
+ populate()
572
+ if ex[0]:
573
+ cf.pack_forget()
574
+ btn.configure(text="›")
575
+ else:
576
+ cf.pack(fill="x", pady=(2, 0))
577
+ btn.configure(text="⌄")
578
+ ex[0] = not ex[0]
579
+
580
+ expand_btn.configure(command=_toggle_expand)
581
+
582
+ def _toggle(e, v=var):
583
+ v.set(not v.get())
584
+
585
+ for w in (size_lbl, path_lbl, row):
586
+ w.bind("<Button-1>", _toggle)
587
+
588
+ self.residue_rows.append((wrapper, cat, var, path, children_frame, child_rows))
589
+
590
+ # ── Filters ───────────────────────────────────────────────────
591
+
592
+ def set_all_visible(self, state: bool):
593
+ for entry in self.residue_rows:
594
+ wrapper, cat, var = entry[0], entry[1], entry[2]
595
+ if self.active_filter in ("All", cat):
596
+ var.set(state)
597
+
598
+ def create_filter_bubbles(self, categories):
599
+ for btn in self.filter_buttons.values():
600
+ btn.destroy()
601
+ self.filter_buttons = {}
602
+
603
+ for cat in categories:
604
+ btn = ctk.CTkButton(
605
+ self.bubbles_container, text=cat,
606
+ width=max(70, len(cat) * 8), height=28,
607
+ corner_radius=14, border_width=1, border_color="#757575",
608
+ fg_color="transparent", hover_color=("#c0c0c0", "#3d3d3d"),
609
+ text_color=("#333333", "#eeeeee"),
610
+ command=lambda c=cat: self.apply_filter(c))
611
+ btn.pack(side="left", padx=5)
612
+ self.filter_buttons[cat] = btn
613
+
614
+ if self.filter_buttons:
615
+ first = list(self.filter_buttons.keys())[0]
616
+ self.update_bubble_selection(first)
617
+
618
+ def update_bubble_selection(self, selected):
619
+ self.active_filter = selected
620
+ for cat, btn in self.filter_buttons.items():
621
+ if cat == selected:
622
+ btn.configure(fg_color="#1f538d", border_width=0, text_color="white")
623
+ else:
624
+ btn.configure(fg_color="transparent", border_width=1,
625
+ text_color=("#333333", "#eeeeee"))
626
+
627
+ def apply_filter(self, selection):
628
+ if selection in ("Scanning...", "No results", "Everything clean! ✨"):
629
+ return
630
+ self.update_bubble_selection(selection)
631
+ for entry in self.residue_rows:
632
+ wrapper, cat = entry[0], entry[1]
633
+ if selection == "All" or selection == cat:
634
+ wrapper.pack(fill="x", padx=4, pady=2)
635
+ else:
636
+ wrapper.pack_forget()
637
+
638
+ # ── Cleaning ──────────────────────────────────────────────────
639
+
640
+ def clean_selected(self):
641
+ count = 0
642
+ to_remove = []
643
+ for entry in list(self.residue_rows):
644
+ wrapper, cat, var, path = entry[0], entry[1], entry[2], entry[3]
645
+ child_rows = entry[5] if len(entry) > 5 else []
646
+ selected_children = [(cp, cv) for cp, cv in child_rows if cv.get()]
647
+ all_selected = child_rows and all(cv.get() for _, cv in child_rows)
648
+
649
+ if var.get() and (not child_rows or all_selected):
650
+ ok, msg = self.scanner.delete_folder(path)
651
+ self.log(msg)
652
+ if ok:
653
+ wrapper.destroy()
654
+ if len(entry) > 4:
655
+ entry[4].destroy()
656
+ to_remove.append(entry)
657
+ count += 1
658
+ elif selected_children:
659
+ for cp, cv in selected_children:
660
+ ok, msg = self.scanner.delete_folder(cp)
661
+ self.log(msg)
662
+ if ok:
663
+ count += 1
664
+
665
+ for r in to_remove:
666
+ self.residue_rows = [e for e in self.residue_rows if e is not r]
667
+
668
+ if count == 0:
669
+ self.log("No items selected.")
670
+ else:
671
+ self.status_label.configure(text=f"Cleaned {count} items.")
672
+ self._post_clean()
673
+
674
+ def clean_all(self):
675
+ count = 0
676
+ to_remove = []
677
+ for entry in list(self.residue_rows):
678
+ wrapper, cat, var, path = entry[0], entry[1], entry[2], entry[3]
679
+ if self.active_filter not in ("All", cat):
680
+ continue
681
+ ok, msg = self.scanner.delete_folder(path)
682
+ self.log(msg)
683
+ if ok:
684
+ wrapper.destroy()
685
+ if len(entry) > 4:
686
+ entry[4].destroy()
687
+ to_remove.append(entry)
688
+ count += 1
689
+
690
+ for r in to_remove:
691
+ self.residue_rows = [e for e in self.residue_rows if e is not r]
692
+
693
+ self.status_label.configure(text=f"Removed {count} items.")
694
+ self._post_clean()
695
+
696
+ def _post_clean(self):
697
+ if not self.residue_rows:
698
+ self.status_label.configure(text="Everything clean! ✨")
699
+ self.clean_selected_button.configure(state="disabled")
700
+ self.clean_all_button.configure(state="disabled")
701
+ self.create_filter_bubbles(["Everything clean! ✨"])
702
+
703
+
704
+ def main():
705
+ app = BrainCleanerApp()
706
+ app.mainloop()
707
+
708
+ if __name__ == "__main__":
709
+ main()