import tkinter as tk from tkinter import ttk, filedialog, messagebox import csv from pathlib import Path import threading import queue from ..core.countings_impl import analyze_paths, analyze_file_counts from ..core.scanner import find_source_files from ..config.languages import LANGUAGE_EXTENSIONS class CountingsTab(ttk.Frame): """Tab that executes counting (SLOC) and displays results. The CountingsTab relies on a shared TopBar for folder selection and uses `analyze_paths` from the core implementation. """ def __init__(self, parent, topbar, app=None, *args, **kwargs): """Initialize the CountingsTab. Args: parent: The notebook widget containing this tab. topbar: Shared TopBar instance exposing `path_var`. """ super().__init__(parent, *args, **kwargs) self.topbar = topbar self.app = app self.worker = None controls = ttk.Frame(self) controls.grid(row=0, column=0, sticky="ew", padx=8, pady=6) self.run_btn = ttk.Button(controls, text="Run Countings", command=self.start_countings) self.run_btn.grid(row=0, column=0, sticky="w") self.cancel_btn = ttk.Button(controls, text="Cancel", command=self.cancel, state="disabled") self.cancel_btn.grid(row=0, column=1, padx=(8, 0)) self.export_btn = ttk.Button(controls, text="Export CSV", command=self.export_csv, state="disabled") self.export_btn.grid(row=0, column=2, padx=(8, 0)) # help icon with tooltip (explain columns) try: from .tooltip import ToolTip help_txt = ( "Columns:\n" "File: filename\n" "Path: full file path\n" "Code: logical/source lines\n" "Comment: comment lines count\n" "Blank: blank lines count\n" "Total: physical lines in file\n" "Language: detected language" ) help_lbl = tk.Label(controls, text="❓", foreground='#2b6fb2', cursor='question_arrow') help_lbl.grid(row=0, column=10, sticky='e', padx=(8,0)) ToolTip(help_lbl, help_txt, delay=1000) except Exception: # non-fatal: tooltip helper may not be available in some contexts pass # Progress bar: will be set to determinate and updated per-file self.progress = ttk.Progressbar(self, mode="determinate") self.progress.grid(row=1, column=0, sticky="ew", padx=8, pady=(6, 0)) # Counters placed under the progress bar counters = ttk.Frame(self) counters.grid(row=2, column=0, sticky="ew", padx=8, pady=(6, 0)) self._lbl_physical = ttk.Label(counters, text="Physical: 0") self._lbl_code = ttk.Label(counters, text="Code: 0") self._lbl_comment = ttk.Label(counters, text="Comments: 0") self._lbl_blank = ttk.Label(counters, text="Blank: 0") self._lbl_files = ttk.Label(counters, text="Files: 0/0") self._lbl_physical.pack(side="left", padx=(0,10)) self._lbl_code.pack(side="left", padx=(0,10)) self._lbl_comment.pack(side="left", padx=(0,10)) self._lbl_blank.pack(side="left", padx=(0,10)) self._lbl_files.pack(side="right", padx=(0,10)) # Adjust tree positioning (moved down one row) # Treeview for tabular display (name, path, numeric columns) columns = ("name", "path", "code", "comment", "blank", "total", "lang") self.tree = ttk.Treeview(self, columns=columns, show="headings") self.tree.heading("name", text="File", command=lambda: self._sort_by("name", False)) self.tree.heading("path", text="Path", command=lambda: self._sort_by("path", False)) self.tree.heading("code", text="Code", command=lambda: self._sort_by("code", False)) self.tree.heading("comment", text="Comment", command=lambda: self._sort_by("comment", False)) self.tree.heading("blank", text="Blank", command=lambda: self._sort_by("blank", False)) self.tree.heading("total", text="Total", command=lambda: self._sort_by("total", False)) self.tree.heading("lang", text="Language", command=lambda: self._sort_by("lang", False)) # column sizing: numeric columns smaller and right-aligned self.tree.column("name", width=300, anchor="w", stretch=True) self.tree.column("path", width=400, anchor="w", stretch=True) self.tree.column("code", width=80, anchor="e", stretch=False) self.tree.column("comment", width=80, anchor="e", stretch=False) self.tree.column("blank", width=60, anchor="e", stretch=False) self.tree.column("total", width=90, anchor="e", stretch=False) self.tree.column("lang", width=120, anchor="w", stretch=False) # Layout tree and scrollbars (both vertical and horizontal) self.tree.grid(row=3, column=0, sticky="nsew", padx=8, pady=8) vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview) hsb = ttk.Scrollbar(self, orient="horizontal", command=self.tree.xview) self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) # Place vertical scrollbar alongside the tree (same row) vsb.grid(row=3, column=1, sticky="ns") # Place horizontal scrollbar below the tree and vertical scrollbar hsb.grid(row=4, column=0, columnspan=2, sticky="ew", padx=8) # make tree expand to fill available space self.rowconfigure(3, weight=1) self.columnconfigure(0, weight=1) # bind double-click to open file viewer self.tree.bind("", self._on_double_click) # internal state for counts/progress self._cumulative_counts = {"physical": 0, "code": 0, "comment": 0, "blank": 0} self._total_files = 0 self._processed_files = 0 def start_countings(self): """Start counting on the folder(s)/file(s) configured in the shared TopBar or current profile. This now mirrors `ScannerTab` behavior and will operate on all `paths` defined in the selected profile (or the fallback single `path_var`). """ # determine list of paths to analyze: prefer profile.paths if available paths = [] pr = getattr(self.topbar, "current_profile", None) if not pr: pname = getattr(self.topbar, "profile_var", None) if pname: from ..config import profiles as profiles_cfg pr = profiles_cfg.find_profile(pname.get()) if hasattr(pname, "get") else profiles_cfg.find_profile(str(pname)) if pr: paths = pr.get("paths") or [] else: p = self.topbar.path_var.get().strip() if p: paths = [p] if not paths: messagebox.showwarning("Missing path", "Please select a folder or profile to analyze first.") return # log start try: if getattr(self, 'app', None): self.app.log(f"Countings started on: {', '.join(paths)}", "INFO") except Exception: pass # Build allowed extensions and ignore patterns from profile languages allowed_exts = None if pr: langs = pr.get("languages", []) or [] exts = [] for ln in langs: if ln in LANGUAGE_EXTENSIONS: exts.extend(LANGUAGE_EXTENSIONS[ln]) else: val = ln.strip() if val.startswith('.'): exts.append(val.lower()) elif len(val) <= 5 and not val.isalpha(): exts.append(f".{val.lower()}") else: pass if exts: allowed_exts = set(exts) ignore_patterns = pr.get("ignore", []) if pr else None # Aggregate targets from all configured paths targets = [] for pstr in paths: pth = Path(pstr) try: if pth.is_dir(): targets.extend(find_source_files(pth, allowed_extensions=allowed_exts, ignore_patterns=ignore_patterns)) elif pth.is_file(): targets.append(pth) except Exception: # skip problematic entries but continue continue self.run_btn.config(state="disabled") self.cancel_btn.config(state="normal") self.export_btn.config(state="disabled") # clear tree for item in self.tree.get_children(): self.tree.delete(item) # prepare progress and counters using scanner results self._total_files = len(targets) self._processed_files = 0 self._cumulative_counts = {"physical": 0, "code": 0, "comment": 0, "blank": 0} try: self.progress['maximum'] = max(1, self._total_files) self.progress['value'] = 0 except Exception: pass try: self._lbl_files.config(text=f"Files: {self._processed_files}/{self._total_files}") self._lbl_physical.config(text="Physical: 0") self._lbl_code.config(text="Code: 0") self._lbl_comment.config(text="Comments: 0") self._lbl_blank.config(text="Blank: 0") except Exception: pass # Submit per-file work via the central WorkerManager so the GUI stays responsive. try: if not getattr(self, 'app', None) or not getattr(self.app, 'worker', None): # fallback to previous behavior self.worker = threading.Thread(target=self._worker_countings, args=(targets,), daemon=True) self.worker.start() self.after(200, self._poll_queue) return # use the worker manager to map analyze_file_counts over targets # on_progress will be called for each file result; on_done when all finished # Map per-file counting so we can show progress incrementally. self._task_id = self.app.worker.map_iterable( func=analyze_file_counts, items=targets, kind='thread', on_progress=self._on_file_result, on_done=self._on_all_done, ) except Exception: # if worker API not available or mapping failed, fallback self.worker = threading.Thread(target=self._worker_countings, args=(targets,), daemon=True) self.worker.start() self.after(200, self._poll_queue) def cancel(self): """Request cancellation (informational only).""" # Cancellation not yet supported for task submitted via WorkerManager messagebox.showinfo("Cancel", "Cancellation requested but not supported yet.") try: if getattr(self, 'app', None): self.app.log("Countings cancelled by user", "WARNING") except Exception: pass self._finish() def _worker_countings(self, targets): """Legacy fallback worker that runs analyze_paths on the whole list.""" try: results = analyze_paths(targets) # Directly populate via main thread scheduling self.after(0, lambda: self._on_all_done(results)) except Exception as e: self.after(0, lambda: messagebox.showerror("Error", f"Error during countings: {e}")) # New callbacks used by WorkerManager def _on_file_result(self, res): """Called in main thread for each file result emitted by WorkerManager.""" try: if isinstance(res, dict): r = res else: r = res if "error" in r: file = r.get("file") name = Path(file).name if file else "" self.tree.insert("", "end", values=(name, file, 0, 0, 0, 0, r.get("error"))) else: file = r.get("file") name = Path(file).name if file else "" code = r.get("code_lines") or 0 comment = r.get("comment_lines") or 0 blank = r.get("blank_lines") or 0 total = r.get("physical_lines") or 0 lang = r.get("language") or "unknown" self.tree.insert("", "end", values=(name, file, int(code), int(comment), int(blank), int(total), lang)) # update cumulative counters and progress try: self._cumulative_counts['physical'] += int(total) self._cumulative_counts['code'] += int(code) self._cumulative_counts['comment'] += int(comment) self._cumulative_counts['blank'] += int(blank) self._processed_files += 1 # update labels self._lbl_physical.config(text=f"Physical: {self._cumulative_counts['physical']}") self._lbl_code.config(text=f"Code: {self._cumulative_counts['code']}") self._lbl_comment.config(text=f"Comments: {self._cumulative_counts['comment']}") self._lbl_blank.config(text=f"Blank: {self._cumulative_counts['blank']}") self._lbl_files.config(text=f"Files: {self._processed_files}/{self._total_files}") try: self.progress['value'] = self._processed_files except Exception: pass except Exception: pass except Exception: pass def _on_all_done(self, results): """Called in main thread when all results are available.""" try: written = len(self.tree.get_children()) self.export_btn.config(state="normal") try: if getattr(self, 'app', None): self.app.log(f"Countings finished: {written} items", "INFO") except Exception: pass finally: self._finish() # ensure progress reflects completion try: self._processed_files = self._total_files self._lbl_files.config(text=f"Files: {self._processed_files}/{self._total_files}") self.progress['value'] = self._processed_files except Exception: pass def _finish(self): """Finalize UI state after countings finished or cancelled.""" self.progress.stop() self.run_btn.config(state="normal") self.cancel_btn.config(state="disabled") def _sort_by(self, col, descending): """Sort tree contents by given column. Toggle ascending/descending. Args: col: column key (file, code, comment, blank, total, lang) descending: bool whether to sort descending """ col_map = {"name": 0, "path": 1, "code": 2, "comment": 3, "blank": 4, "total": 5, "lang": 6} idx = col_map.get(col, 0) children = list(self.tree.get_children("")) def _key(item): val = self.tree.item(item, "values")[idx] try: return int(val) except Exception: return str(val).lower() children.sort(key=_key, reverse=descending) for index, item in enumerate(children): self.tree.move(item, "", index) # update heading to reverse sort next time self.tree.heading(col, command=lambda c=col: self._sort_by(c, not descending)) def export_csv(self): """Export current table contents to CSV file chosen by the user.""" path = filedialog.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV files", "*.csv"), ("All files", "*")]) if not path: return headers = ["name", "path", "code", "comment", "blank", "total", "language"] from ..utils.csv_exporter import export_rows_to_csv try: rows = (self.tree.item(child, "values") for child in self.tree.get_children()) written = export_rows_to_csv(path, headers, rows) messagebox.showinfo("Export", f"Exported {written} rows to {path}") try: if getattr(self, 'app', None): self.app.log(f"Exported {written} rows to {path}", "INFO") except Exception: pass except Exception as e: messagebox.showerror("Export Error", str(e)) try: if getattr(self, 'app', None): self.app.log(f"Export error: {e}", "ERROR") except Exception: pass def _on_double_click(self, _evt=None): sel = self.tree.selection() if not sel: return item = sel[0] vals = self.tree.item(item, "values") # values: (name, path, ...) if len(vals) >= 2: path = vals[1] from .file_viewer import FileViewer FileViewer(self, path)