import tkinter as tk from tkinter import ttk from tkinter.scrolledtext import ScrolledText from pathlib import Path from typing import Optional try: from pygments import lex from pygments.lexers import get_lexer_for_filename, TextLexer from pygments.token import Token _HAS_PYGMENTS = True except Exception: _HAS_PYGMENTS = False class FileViewer(tk.Toplevel): """Toplevel window to show file contents with optional syntax highlighting and a simple minimap indicating code/comment/blank lines. """ COLORS = { "code": "#1f77b4", "comment": "#2ca02c", "blank": "#d3d3d3", } def __init__(self, parent, path: str): super().__init__(parent) self.title(f"Viewer - {Path(path).name}") self.geometry("900x600") self.path = Path(path) # Main panes: Text on the left, minimap on the right main = ttk.Frame(self) main.pack(fill="both", expand=True) text_frame = ttk.Frame(main) text_frame.pack(side="left", fill="both", expand=True) self.text = ScrolledText(text_frame, wrap="none", undo=True) self.text.pack(fill="both", expand=True) self.text.config(state="disabled") minimap_frame = ttk.Frame(main, width=120) minimap_frame.pack(side="right", fill="y") self.canvas = tk.Canvas(minimap_frame, width=120, bg="#ffffff") self.canvas.pack(fill="y", expand=True) # Legend under the minimap showing color mapping legend_frame = ttk.Frame(minimap_frame) legend_frame.pack(side="bottom", fill="x", padx=4, pady=4) # Ordered legend entries for key, label_text in ( ("code", "Code"), ("comment", "Comment"), ("blank", "Blank"), ): color = self.COLORS.get(key, "#cccccc") sw = tk.Label(legend_frame, bg=color, width=2, relief="ridge") sw.pack(side="left", padx=(2, 4)) lbl = ttk.Label(legend_frame, text=label_text) lbl.pack(side="left", padx=(0, 8)) self._load_file() def _load_file(self): try: text = self.path.read_text(errors="ignore") except Exception as e: self._set_text(f"Error opening file: {e}") return # Insert text self._set_text(text) # Apply color-coded highlighting based on line type classification # This matches the minimap colors for verification self._apply_line_type_highlighting(text) # Build minimap self._build_minimap(text.splitlines()) # start periodic viewport updater self._viewport_job = None self._schedule_viewport_update() # cancel the job when window is closed self.bind("", lambda e: self._cancel_viewport_update()) def _set_text(self, text: str): self.text.config(state="normal") self.text.delete("1.0", "end") self.text.insert("1.0", text) self.text.config(state="disabled") def _apply_line_type_highlighting(self, text: str): """Apply background colors to lines based on their classification. This creates a visual correspondence with the minimap colors for verification. Matches pygount's classification logic: only # are comments, docstrings are code. """ # Configure tags with colors matching the minimap self.text.tag_configure( "line_code", background="#e6f2ff" ) # Light blue background for code self.text.tag_configure( "line_comment", background="#e6f7e6" ) # Light green background for comments self.text.tag_configure( "line_blank", background="#f5f5f5" ) # Light gray background for blank lines self.text.config(state="normal") for i, line in enumerate(text.splitlines(), start=1): s = line.strip() typ = "code" if not s: typ = "blank" elif s.startswith("#"): # Only # comments are considered documentation by pygount in Python # Docstrings (""") are counted as code/strings typ = "comment" # Apply background tag to the entire line start = f"{i}.0" end = f"{i}.end" self.text.tag_add(f"line_{typ}", start, end) self.text.config(state="disabled") def _build_minimap(self, lines): self.canvas.delete("all") h = max(1, len(lines)) width = int(self.canvas.winfo_reqwidth()) or 120 # determine per-line rectangle height based on canvas height and number of lines canvas_h = max(1, self.canvas.winfo_height() or 600) rect_h = max(1, int(max(2, min(8, canvas_h / h)))) y = 0 # store for viewport computations self._minimap_line_height = rect_h self._minimap_total_lines = h for line in lines: typ = "code" s = line.strip() if not s: typ = "blank" elif s.startswith("#"): # Only # comments are considered documentation by pygount in Python # Docstrings (""") are counted as code/strings typ = "comment" color = self.COLORS.get(typ, "#cccccc") self.canvas.create_rectangle( 2, y, width - 2, y + rect_h, fill=color, outline=color ) y += rect_h + 1 # create viewport rectangle overlay (on top) # remove any existing viewport and create a fresh one try: self.canvas.delete("viewport") except Exception: pass self.canvas.create_rectangle( 0, 0, 0, 0, outline="#ff0000", width=1, tags=("viewport",) ) # if canvas size changes, rebuild minimap self.canvas.bind("", lambda e: self._build_minimap(lines)) def _schedule_viewport_update(self, interval_ms: int = 200): # schedule periodic viewport updates def _job(): try: self._update_viewport_rect() finally: self._viewport_job = self.after(interval_ms, _job) # cancel any existing job then start self._cancel_viewport_update() self._viewport_job = self.after(interval_ms, _job) def _cancel_viewport_update(self): if getattr(self, "_viewport_job", None): try: self.after_cancel(self._viewport_job) except Exception: pass self._viewport_job = None def _update_viewport_rect(self): # get top/bottom fractions from text widget try: top, bottom = self.text.yview() except Exception: return c_h = self.canvas.winfo_height() or 1 y1 = int(top * c_h) y2 = int(bottom * c_h) # ensure reasonable bounds y1 = max(0, min(y1, c_h)) y2 = max(0, min(y2, c_h)) # update the viewport rectangle try: self.canvas.coords("viewport", 1, y1, self.canvas.winfo_width() - 1, y2) except Exception: pass