SXXXXXXX_PyUCC/pyucc/gui/file_viewer.py
VALLONGOL 4fdd646d60 Chore: Stop tracking files based on .gitignore update.
Untracked files matching the following rules:
- Rule "*.zip": 1 file
2025-11-24 10:15:59 +01:00

217 lines
7.3 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 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("<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_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("<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