"""reASITIC interactive GUI — a single-window layout viewer + console.
Mirrors the original ASITIC X11 front-end (``decomp/output/asitic_repl.c``):
* A 2-D top-down layout view (``xui_render_layout_view``) with pan/zoom,
chip outline (``xui_draw_chip_outline``) and a substrate grid
(``xui_draw_grid_or_ruler``).
* A status bar showing current zoom and world cursor coordinates.
* An embedded REPL pane that drives :class:`reasitic.cli.Repl` exactly
the way the original binary's terminal-side readline did, so every
one of the 117 binary commands works in the GUI.
* Mouse interactions: drag to pan, scroll wheel to zoom around the
cursor, click on a shape to select it (highlights its bounding box,
matching ``xui_draw_zoom_box_around_current_shape``).
"""
from __future__ import annotations
import io
from contextlib import redirect_stderr, redirect_stdout
from reasitic.cli import Repl
from reasitic.gui.renderer import (
LAYOUT_TAG,
render_all,
)
from reasitic.gui.viewport import Viewport
[docs]
class GuiApp:
"""The reASITIC graphical workspace.
The GUI is a thin presentation layer over an embedded
:class:`~reasitic.cli.Repl`. Each command typed into the console
pane is forwarded verbatim to ``repl.execute`` (with stdout / stderr
captured into the console widget) and then the layout view is
redrawn from ``repl.shapes``. This means *every* command that works
in the headless CLI also works in the GUI, with identical output.
"""
# ------------------------------------------------------------------
# Construction
# ------------------------------------------------------------------
[docs]
def __init__(self, *, repl: Repl | None = None,
width: int = 1100, height: int = 720,
title: str = "reASITIC") -> None:
"""Build the Tk window. Tk imports are deferred so the rest of
``reasitic.gui`` stays importable on headless boxes."""
import tkinter as tk
from tkinter import scrolledtext
self._tk = tk
self.repl = repl or Repl()
self.viewport = Viewport(canvas_width=width, canvas_height=int(height * 0.65))
self.selected: str | None = None
# Default grid spacing — overridden by the SETGRID command and
# by tech files via the SNAPGRID option.
self.grid_step_um: float = 0.0
self._drag_origin: tuple[int, int] | None = None
self.root = tk.Tk()
self.root.title(title)
self.root.geometry(f"{width}x{height}")
self.root.configure(bg="#181818")
# ----- Menu bar ------------------------------------------------
self._build_menu()
# ----- Toolbar -------------------------------------------------
toolbar = tk.Frame(self.root, bg="#202020", height=32)
toolbar.pack(side=tk.TOP, fill=tk.X)
for label, cb in (
("Fit", self.action_fit),
("Zoom +", lambda: self._zoom_centre(1.25)),
("Zoom −", lambda: self._zoom_centre(0.8)),
("Reset", self.action_reset_view),
("Grid", self.action_toggle_grid),
("Redraw", self.refresh_view),
):
tk.Button(toolbar, text=label, command=cb, bg="#303030",
fg="#dddddd", relief=tk.FLAT, padx=10).pack(
side=tk.LEFT, padx=2, pady=4)
# ----- Vertical splitter: canvas (top) + console (bottom) -----
paned = tk.PanedWindow(self.root, orient=tk.VERTICAL,
sashrelief=tk.RAISED, bg="#181818")
paned.pack(fill=tk.BOTH, expand=True)
# Canvas
canvas_frame = tk.Frame(paned, bg="#101010")
self.canvas = tk.Canvas(canvas_frame,
bg="#101010",
highlightthickness=0)
self.canvas.pack(fill=tk.BOTH, expand=True)
paned.add(canvas_frame, stretch="always")
# Console
console_frame = tk.Frame(paned, bg="#101010")
self.console = scrolledtext.ScrolledText(
console_frame, wrap=tk.WORD, height=10,
bg="#101010", fg="#dddddd",
insertbackground="#dddddd",
font=("TkFixedFont", 10),
)
self.console.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
self.console.configure(state=tk.DISABLED)
prompt_row = tk.Frame(console_frame, bg="#181818")
prompt_row.pack(fill=tk.X)
tk.Label(prompt_row, text="reASITIC>", bg="#181818",
fg="#9cdcfe", font=("TkFixedFont", 10)).pack(side=tk.LEFT)
self.entry = tk.Entry(prompt_row, bg="#101010", fg="#dddddd",
insertbackground="#dddddd",
font=("TkFixedFont", 10),
relief=tk.FLAT)
self.entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4)
self.entry.bind("<Return>", self._on_entry_return)
self.entry.bind("<Up>", self._on_history_prev)
self.entry.bind("<Down>", self._on_history_next)
self._history: list[str] = []
self._history_idx: int = 0
paned.add(console_frame, stretch="never")
# ----- Status bar ---------------------------------------------
self.status = tk.Label(self.root, text="", bg="#202020",
fg="#bbbbbb", anchor="w",
font=("TkDefaultFont", 9))
self.status.pack(side=tk.BOTTOM, fill=tk.X)
# ----- Bindings -----------------------------------------------
self.canvas.bind("<Configure>", self._on_canvas_resize)
self.canvas.bind("<ButtonPress-1>", self._on_lmb_press)
self.canvas.bind("<B1-Motion>", self._on_lmb_drag)
self.canvas.bind("<ButtonRelease-1>", self._on_lmb_release)
self.canvas.bind("<Motion>", self._on_motion)
# Linux scroll wheel comes through as Button-4 / Button-5
self.canvas.bind("<Button-4>",
lambda e: self._zoom_at_event(e, 1.2))
self.canvas.bind("<Button-5>",
lambda e: self._zoom_at_event(e, 1 / 1.2))
# Windows/macOS deliver <MouseWheel> with delta sign
self.canvas.bind("<MouseWheel>",
lambda e: self._zoom_at_event(
e, 1.2 if e.delta > 0 else 1 / 1.2))
self.root.bind("<g>", lambda _e: self.action_toggle_grid())
self.root.bind("<f>", lambda _e: self.action_fit())
self.root.bind("<r>", lambda _e: self.refresh_view())
self.root.bind("<Escape>", lambda _e: self._clear_selection())
self._println("reASITIC GUI ready. Type 'help' for help.")
# ------------------------------------------------------------------
# Menu
# ------------------------------------------------------------------
def _build_menu(self) -> None:
tk = self._tk
menubar = tk.Menu(self.root)
m_file = tk.Menu(menubar, tearoff=False)
m_file.add_command(label="Load tech…", command=self._menu_load_tech)
m_file.add_command(label="Load session…", command=self._menu_load_session)
m_file.add_command(label="Save session…", command=self._menu_save_session)
m_file.add_separator()
m_file.add_command(label="Quit", accelerator="Ctrl+Q",
command=self.root.destroy)
menubar.add_cascade(label="File", menu=m_file)
m_view = tk.Menu(menubar, tearoff=False)
m_view.add_command(label="Fit", accelerator="F",
command=self.action_fit)
m_view.add_command(label="Reset view", command=self.action_reset_view)
m_view.add_command(label="Toggle grid", accelerator="G",
command=self.action_toggle_grid)
m_view.add_command(label="Redraw", accelerator="R",
command=self.refresh_view)
menubar.add_cascade(label="View", menu=m_view)
m_help = tk.Menu(menubar, tearoff=False)
m_help.add_command(label="REPL help", command=lambda: self._run("help"))
m_help.add_command(label="About reASITIC",
command=lambda: self._run("version"))
menubar.add_cascade(label="Help", menu=m_help)
self.root.config(menu=menubar)
self.root.bind("<Control-q>", lambda _e: self.root.destroy())
def _menu_load_tech(self) -> None:
from tkinter import filedialog
path = filedialog.askopenfilename(
title="Load tech file",
filetypes=[("Tek tech file", "*.tek"), ("All files", "*.*")])
if path:
self._run(f"load-tech {path}")
def _menu_load_session(self) -> None:
from tkinter import filedialog
path = filedialog.askopenfilename(
title="Load session",
filetypes=[("JSON session", "*.json"), ("All files", "*.*")])
if path:
self._run(f"load {path}")
self.action_fit()
def _menu_save_session(self) -> None:
from tkinter import filedialog
path = filedialog.asksaveasfilename(
title="Save session",
defaultextension=".json",
filetypes=[("JSON session", "*.json")])
if path:
self._run(f"save {path}")
# ------------------------------------------------------------------
# Console — capture stdout/stderr, drive Repl.execute, refresh view
# ------------------------------------------------------------------
def _println(self, s: str) -> None:
self.console.configure(state=self._tk.NORMAL)
self.console.insert(self._tk.END, s if s.endswith("\n") else s + "\n")
self.console.see(self._tk.END)
self.console.configure(state=self._tk.DISABLED)
def _run(self, line: str) -> None:
"""Run a command in the embedded Repl and pipe output to the console."""
if not line.strip():
return
self._println(f"reASITIC> {line}")
out = io.StringIO()
try:
with redirect_stdout(out), redirect_stderr(out):
cont = self.repl.execute(line)
except Exception as exc:
self._println(f"error: {exc}")
return
text = out.getvalue()
if text:
self._println(text.rstrip("\n"))
self.refresh_view()
if not cont:
self.root.after(50, self.root.destroy)
def _on_entry_return(self, _event: object) -> None:
line = self.entry.get()
self.entry.delete(0, self._tk.END)
if line.strip():
self._history.append(line)
self._history_idx = len(self._history)
self._run(line)
def _on_history_prev(self, _event: object) -> str:
if self._history and self._history_idx > 0:
self._history_idx -= 1
self.entry.delete(0, self._tk.END)
self.entry.insert(0, self._history[self._history_idx])
return "break"
def _on_history_next(self, _event: object) -> str:
if self._history_idx < len(self._history) - 1:
self._history_idx += 1
self.entry.delete(0, self._tk.END)
self.entry.insert(0, self._history[self._history_idx])
else:
self._history_idx = len(self._history)
self.entry.delete(0, self._tk.END)
return "break"
# ------------------------------------------------------------------
# Mouse / keyboard
# ------------------------------------------------------------------
def _on_canvas_resize(self, event: object) -> None:
self.viewport.canvas_width = self.canvas.winfo_width()
self.viewport.canvas_height = self.canvas.winfo_height()
self.refresh_view()
def _on_lmb_press(self, event: object) -> None:
self._drag_origin = (event.x, event.y) # type: ignore[attr-defined]
self._drag_pan_start = (self.viewport.pan_x, self.viewport.pan_y)
# If we click on a shape, select it. We test the first hit using
# Tk's find_closest with a small tolerance band.
cid = self.canvas.find_closest(event.x, event.y, halo=3) # type: ignore[attr-defined]
if cid:
tags = self.canvas.gettags(cid[0])
for t in tags:
if t.startswith("shape:"):
name = t.split(":", 1)[1]
self.selected = name
self.repl.selected_shape = name
self.refresh_view()
self._println(f"Selected: {name}")
return
def _on_lmb_drag(self, event: object) -> None:
if self._drag_origin is None:
return
x0, y0 = self._drag_origin
dx = event.x - x0 # type: ignore[attr-defined]
dy = event.y - y0 # type: ignore[attr-defined]
if abs(dx) + abs(dy) < 2:
return
# Pan from the original position
self.viewport.pan_x = self._drag_pan_start[0] + dx / self.viewport.zoom
self.viewport.pan_y = self._drag_pan_start[1] - dy / self.viewport.zoom
self.refresh_view()
def _on_lmb_release(self, _event: object) -> None:
self._drag_origin = None
def _on_motion(self, event: object) -> None:
wx, wy = self.viewport.screen_to_world(
event.x, event.y) # type: ignore[attr-defined]
sel = f" selected={self.selected}" if self.selected else ""
self.status.configure(
text=(f"x={wx:8.2f} μm y={wy:8.2f} μm "
f"zoom={self.viewport.zoom:.3f} px/μm{sel}"))
def _zoom_at_event(self, event: object, factor: float) -> None:
self.viewport.zoom_at_screen(
event.x, event.y, factor) # type: ignore[attr-defined]
self.refresh_view()
def _zoom_centre(self, factor: float) -> None:
self.viewport.zoom_at_screen(
self.viewport.canvas_width / 2,
self.viewport.canvas_height / 2, factor)
self.refresh_view()
def _clear_selection(self) -> None:
self.selected = None
self.repl.selected_shape = None
self.refresh_view()
# ------------------------------------------------------------------
# View actions
# ------------------------------------------------------------------
[docs]
def action_fit(self) -> None:
"""Frame all shapes — or the chip outline if there are none."""
if not self.repl.shapes:
if self.repl.tech is not None:
cx = self.repl.tech.chip.chipx or 1000.0
cy = self.repl.tech.chip.chipy or 1000.0
self.viewport.fit_bbox(0.0, 0.0, cx, cy)
else:
self.viewport.reset()
self.refresh_view()
return
x0 = y0 = float("+inf")
x1 = y1 = float("-inf")
for sh in self.repl.shapes.values():
bx0, by0, bx1, by1 = sh.bounding_box()
if bx0 == bx1 == by0 == by1 == 0.0:
continue
x0 = min(x0, bx0)
y0 = min(y0, by0)
x1 = max(x1, bx1)
y1 = max(y1, by1)
if x0 == float("+inf"):
self.viewport.reset()
else:
self.viewport.fit_bbox(x0, y0, x1, y1)
self.refresh_view()
[docs]
def action_reset_view(self) -> None:
"""Restore identity zoom (1 px/μm) and zero pan."""
self.viewport.reset()
self.refresh_view()
[docs]
def action_toggle_grid(self) -> None:
"""Switch the substrate grid overlay on/off."""
if self.grid_step_um > 0:
self.grid_step_um = 0.0
else:
# Pull from REPL viewport (matches binary's GRID setting),
# otherwise fall back to chip / 32.
g = float(self.repl.viewport.get("grid", 0.0) or 0.0)
if g > 0:
self.grid_step_um = g
elif self.repl.tech and self.repl.tech.chip.chipx:
self.grid_step_um = self.repl.tech.chip.chipx / 32.0
else:
self.grid_step_um = 50.0
self.refresh_view()
[docs]
def refresh_view(self) -> None:
"""Wipe the canvas and re-render chip / grid / shapes / selection."""
render_all(
self.canvas,
self.repl.tech,
self.repl.shapes,
self.viewport,
grid_step_um=self.grid_step_um,
selected=self.selected or self.repl.selected_shape,
)
# Move the layout group below the selection rectangle so the
# selection highlight stays visible.
self.canvas.tag_lower(LAYOUT_TAG)
# ------------------------------------------------------------------
# Entry point
# ------------------------------------------------------------------
[docs]
def mainloop(self) -> None:
"""Run the Tk event loop; returns when the window is closed."""
# Initial fit — best-effort once Tk has computed widget geometry
self.root.update_idletasks()
self.viewport.canvas_width = max(self.canvas.winfo_width(), 200)
self.viewport.canvas_height = max(self.canvas.winfo_height(), 200)
self.action_fit()
self.root.mainloop()
[docs]
def run(*, tech_path: str | None = None,
session_path: str | None = None) -> None:
"""Spawn the GUI. Optionally load a tech file and/or session on startup."""
app = GuiApp()
if tech_path:
app._run(f"load-tech {tech_path}")
if session_path:
app._run(f"load {session_path}")
app.mainloop()