217 lines
7.3 KiB
Python
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
|