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 highlighting if _HAS_PYGMENTS: self._apply_pygments_highlighting(text) else: self._apply_simple_highlighting() # 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_pygments_highlighting(self, text: str): for tag in list(self.text.tag_names()): self.text.tag_delete(tag) try: lexer = get_lexer_for_filename(str(self.path)) except Exception: lexer = TextLexer() token_map = { Token.Comment: "comment", Token.String: "string", Token.Keyword: "keyword", Token.Name: "name", } self.text.tag_configure("comment", foreground="#2ca02c") self.text.tag_configure("keyword", foreground="#d62728") self.text.tag_configure("string", foreground="#9467bd") pos = 0 for ttype, value in lex(text, lexer): length = len(value) if length == 0: continue start_idx = self._index_from_pos(pos) end_idx = self._index_from_pos(pos + length) tag = None for key, tname in token_map.items(): if ttype in key: tag = tname break if tag: try: self.text.tag_add(tag, start_idx, end_idx) except Exception: pass pos += length def _index_from_pos(self, pos: int) -> str: return f"1.0 + {pos} chars" def _apply_simple_highlighting(self): self.text.tag_configure("comment", foreground=self.COLORS["comment"]) self.text.config(state="normal") for i, line in enumerate(self.text.get("1.0", "end").splitlines(), start=1): s = line.lstrip() if not s: continue if s.startswith("#") or s.startswith("//") or s.startswith("/*"): start = f"{i}.0" end = f"{i}.end" self.text.tag_add("comment", 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("#") or s.startswith("//") or s.startswith("/*"): 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