SXXXXXXX_PyUCC/pyucc/gui/file_viewer.py

209 lines
7.1 KiB
Python

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("<Destroy>", 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("<Configure>", 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