Files
km-plot/gui.py
2025-12-28 14:10:37 -05:00

472 lines
22 KiB
Python

import time
from pathlib import Path
from inkex.gui import Gtk, GLib
from gi.repository import GdkPixbuf
from plotters import plotters
class KMPlotGUI:
def build_window(self):
self.window = Gtk.Window(title="KM Plot")
self.window.set_border_width(10)
self.window.set_default_size(500, 500)
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.status_label = Gtk.Label(label="No supported plotter detected.")
self.status_label.set_xalign(0.5)
self.device_label = Gtk.Label(label="Device: not connected")
self.device_label.set_xalign(0.5)
self.device_image = Gtk.Image()
self.device_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG)
self.device_image.set_pixel_size(150)
self.device_image.set_halign(Gtk.Align.CENTER)
self.device_image.set_valign(Gtk.Align.CENTER)
self.port_info_label = Gtk.Label(label="")
self.port_info_label.set_xalign(0.5)
self.port_info_label.set_halign(Gtk.Align.CENTER)
self.port_info_label.set_justify(Gtk.Justification.CENTER)
self.port_info_label.set_line_wrap(True)
self.port_info_label.set_margin_top(14)
self.port_info_label.set_margin_bottom(14)
self.port_info_label.set_margin_start(12)
self.port_info_label.set_margin_end(12)
self.port_info_frame = Gtk.Frame(label="Driver")
self.port_info_frame.set_shadow_type(Gtk.ShadowType.IN)
self.port_info_frame.set_halign(Gtk.Align.CENTER)
self.port_info_frame.set_margin_top(12)
self.port_info_frame.set_margin_bottom(12)
self.port_info_frame.set_margin_start(12)
self.port_info_frame.set_margin_end(12)
self.port_info_frame.set_size_request(250, -1)
self.port_info_frame.add(self.port_info_label)
self.port_store = Gtk.ListStore(str, str, str) # device, vidpid, display
self.port_combo = Gtk.ComboBox.new_with_model(self.port_store)
port_renderer = Gtk.CellRendererText()
self.port_combo.pack_start(port_renderer, True)
self.port_combo.add_attribute(port_renderer, "text", 2)
self.port_combo.set_sensitive(False)
self.port_combo.connect("changed", self.on_port_changed)
self.port_combo.set_halign(Gtk.Align.CENTER)
self.cut_button = Gtk.Button(label="Send to plotter")
self.cut_button.set_sensitive(False)
self.cut_button.connect("clicked", self.on_cut_clicked)
self.status_bar = Gtk.Label(label="Searching for devices...")
self.status_bar.set_xalign(0)
notebook = Gtk.Notebook()
notebook.set_tab_pos(Gtk.PositionType.TOP)
# Main tab
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
main_box.set_hexpand(True)
main_box.set_vexpand(True)
main_box.set_valign(Gtk.Align.CENTER)
main_box.set_halign(Gtk.Align.CENTER)
port_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
port_row.set_halign(Gtk.Align.CENTER)
port_row.pack_start(self.port_combo, False, False, 0)
main_box.pack_start(port_row, False, False, 0)
main_box.pack_start(self.port_info_frame, False, False, 10)
main_box.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 12)
main_box.pack_start(self.device_image, False, False, 6)
notebook.append_page(main_box, Gtk.Label(label="Device"))
# Connection settings tab
conn_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
conn_box.set_hexpand(True)
conn_box.set_vexpand(True)
conn_box.set_halign(Gtk.Align.CENTER)
conn_box.set_margin_top(12)
conn_box.set_margin_bottom(12)
conn_grid = Gtk.Grid(column_spacing=8, row_spacing=6)
conn_grid.set_column_homogeneous(False)
conn_grid.set_halign(Gtk.Align.CENTER)
# Plot settings tab
plot_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
plot_box.set_hexpand(True)
plot_box.set_vexpand(True)
plot_box.set_halign(Gtk.Align.CENTER)
plot_box.set_margin_top(12)
plot_box.set_margin_bottom(12)
plot_grid = Gtk.Grid(column_spacing=8, row_spacing=6)
plot_grid.set_column_homogeneous(False)
plot_grid.set_halign(Gtk.Align.CENTER)
self.adv_controls = {}
conn_row = 0
plot_row = 0
def add_conn_row(label_text, widget):
nonlocal conn_row
label = Gtk.Label(label=label_text)
label.set_xalign(0)
conn_grid.attach(label, 0, conn_row, 1, 1)
conn_grid.attach(widget, 1, conn_row, 1, 1)
conn_row += 1
def add_plot_row(label_text, widget):
nonlocal plot_row
label = Gtk.Label(label=label_text)
label.set_xalign(0)
plot_grid.attach(label, 0, plot_row, 1, 1)
plot_grid.attach(widget, 1, plot_row, 1, 1)
plot_row += 1
# Dropdown helpers
def combo(options, active_value):
store = Gtk.ListStore(str, str)
for key, text in options:
store.append([key, text])
combo_box = Gtk.ComboBox.new_with_model(store)
renderer_text = Gtk.CellRendererText()
combo_box.pack_start(renderer_text, True)
combo_box.add_attribute(renderer_text, "text", 1)
# set active
for i, row_data in enumerate(store):
if row_data[0] == active_value:
combo_box.set_active(i)
break
return combo_box
# Spin helpers
def spin_int(value, min_val, max_val, step):
adj = Gtk.Adjustment(value=value, lower=min_val, upper=max_val, step_increment=step)
return Gtk.SpinButton(adjustment=adj, climb_rate=1, digits=0)
def spin_float(value, min_val, max_val, step, digits=1):
adj = Gtk.Adjustment(value=value, lower=min_val, upper=max_val, step_increment=step)
return Gtk.SpinButton(adjustment=adj, climb_rate=1, digits=digits)
# Build widgets using current options/defaults
baud_spin = spin_int(int(getattr(self.options, "serialBaudRate", 9600)), 1200, 115200, 100)
bytesize_combo = combo(
[("five", "5"), ("six", "6"), ("seven", "7"), ("eight", "8")],
str(getattr(self.options, "serialByteSize", "eight")).lower(),
)
stopbits_combo = combo(
[("one", "1"), ("onepointfive", "1.5"), ("two", "2")],
str(getattr(self.options, "serialStopBits", "one")).lower(),
)
parity_combo = combo(
[("none", "None"), ("even", "Even"), ("odd", "Odd"), ("mark", "Mark"), ("space", "Space")],
str(getattr(self.options, "serialParity", "none")).lower(),
)
flow_combo = combo(
[("xonxoff", "XON/XOFF"), ("rtscts", "RTS/CTS"), ("dsrdtrrtscts", "DSR/DTR+RTS/CTS"), ("none", "None")],
str(getattr(self.options, "serialFlowControl", "xonxoff")).lower(),
)
resx_spin = spin_float(float(getattr(self.options, "resolutionX", 1016.0)), 10, 5000, 10, digits=1)
resy_spin = spin_float(float(getattr(self.options, "resolutionY", 1016.0)), 10, 5000, 10, digits=1)
pen_spin = spin_int(int(getattr(self.options, "pen", 1)), 0, 10, 1)
force_spin = spin_int(int(getattr(self.options, "force", 0)), 0, 1000, 1)
speed_spin = spin_int(int(getattr(self.options, "speed", 0)), 0, 1000, 1)
orientation_combo = combo(
[("0", ""), ("90", "90°"), ("180", "180°"), ("270", "270°")],
str(getattr(self.options, "orientation", "0")),
)
mirrorx_check = Gtk.CheckButton(label="Mirror X")
mirrorx_check.set_active(bool(getattr(self.options, "mirrorX", False)))
mirrory_check = Gtk.CheckButton(label="Mirror Y")
mirrory_check.set_active(bool(getattr(self.options, "mirrorY", False)))
center_check = Gtk.CheckButton(label="Center zero point")
center_check.set_active(bool(getattr(self.options, "center", False)))
precut_check = Gtk.CheckButton(label="Use precut")
precut_check.set_active(bool(getattr(self.options, "precut", True)))
autoalign_check = Gtk.CheckButton(label="Auto align")
autoalign_check.set_active(bool(getattr(self.options, "autoAlign", True)))
overcut_spin = spin_float(float(getattr(self.options, "overcut", 1.0)), 0, 10, 0.1, digits=2)
flat_spin = spin_float(float(getattr(self.options, "flat", 1.2)), 0.1, 10, 0.1, digits=2)
tool_spin = spin_float(float(getattr(self.options, "toolOffset", 0.25)), 0, 10, 0.05, digits=2)
def set_tip(widget, text):
try:
widget.set_tooltip_text(text)
except Exception:
pass
# Tooltips for plot settings to guide users.
set_tip(resx_spin, "Horizontal resolution in dots per inch (higher is finer).")
set_tip(resy_spin, "Vertical resolution in dots per inch (higher is finer).")
set_tip(pen_spin, "Pen number to use when cutting/drawing.")
set_tip(force_spin, "Downward force in grams; higher presses harder.")
set_tip(speed_spin, "Movement speed in cm/s; higher is faster.")
set_tip(orientation_combo, "Rotate the plot by the selected angle.")
set_tip(mirrorx_check, "Flip the design horizontally.")
set_tip(mirrory_check, "Flip the design vertically.")
set_tip(center_check, "Center the zero point on the page.")
set_tip(precut_check, "Use a small precut to help corners release cleanly.")
set_tip(autoalign_check, "Attempt to auto-align the plot to the material.")
set_tip(overcut_spin, "Extend cuts past corners to ensure complete separation.")
set_tip(flat_spin, "Flatness compensation factor.")
set_tip(tool_spin, "Offset distance for the tool tip (in mm).")
add_conn_row("Baud rate", baud_spin); self.adv_controls["serialBaudRate"] = baud_spin
add_conn_row("Byte size", bytesize_combo); self.adv_controls["serialByteSize"] = bytesize_combo
add_conn_row("Stop bits", stopbits_combo); self.adv_controls["serialStopBits"] = stopbits_combo
add_conn_row("Parity", parity_combo); self.adv_controls["serialParity"] = parity_combo
add_conn_row("Flow control", flow_combo); self.adv_controls["serialFlowControl"] = flow_combo
add_plot_row("Resolution X (dpi)", resx_spin); self.adv_controls["resolutionX"] = resx_spin
add_plot_row("Resolution Y (dpi)", resy_spin); self.adv_controls["resolutionY"] = resy_spin
add_plot_row("Pen number", pen_spin); self.adv_controls["pen"] = pen_spin
add_plot_row("Force (g)", force_spin); self.adv_controls["force"] = force_spin
add_plot_row("Speed (cm/s)", speed_spin); self.adv_controls["speed"] = speed_spin
add_plot_row("Orientation", orientation_combo); self.adv_controls["orientation"] = orientation_combo
add_plot_row("Mirror X", mirrorx_check); self.adv_controls["mirrorX"] = mirrorx_check
add_plot_row("Mirror Y", mirrory_check); self.adv_controls["mirrorY"] = mirrory_check
add_plot_row("Center", center_check); self.adv_controls["center"] = center_check
add_plot_row("Precut", precut_check); self.adv_controls["precut"] = precut_check
add_plot_row("Auto align", autoalign_check); self.adv_controls["autoAlign"] = autoalign_check
add_plot_row("Overcut (mm)", overcut_spin); self.adv_controls["overcut"] = overcut_spin
add_plot_row("Flatness", flat_spin); self.adv_controls["flat"] = flat_spin
add_plot_row("Tool offset (mm)", tool_spin); self.adv_controls["toolOffset"] = tool_spin
# Connect change handlers to keep self.options in sync.
baud_spin.connect("value-changed", lambda w: self.update_option("serialBaudRate", int(w.get_value())))
resx_spin.connect("value-changed", lambda w: self.update_option("resolutionX", float(w.get_value())))
resy_spin.connect("value-changed", lambda w: self.update_option("resolutionY", float(w.get_value())))
pen_spin.connect("value-changed", lambda w: self.update_option("pen", int(w.get_value())))
force_spin.connect("value-changed", lambda w: self.update_option("force", int(w.get_value())))
speed_spin.connect("value-changed", lambda w: self.update_option("speed", int(w.get_value())))
overcut_spin.connect("value-changed", lambda w: self.update_option("overcut", float(w.get_value())))
flat_spin.connect("value-changed", lambda w: self.update_option("flat", float(w.get_value())))
tool_spin.connect("value-changed", lambda w: self.update_option("toolOffset", float(w.get_value())))
bytesize_combo.connect(
"changed",
lambda w: self.update_option_from_combo("serialByteSize", w, default="eight"),
)
stopbits_combo.connect(
"changed",
lambda w: self.update_option_from_combo("serialStopBits", w, default="one"),
)
parity_combo.connect(
"changed", lambda w: self.update_option_from_combo("serialParity", w, default="none")
)
flow_combo.connect(
"changed",
lambda w: self.update_option_from_combo("serialFlowControl", w, default="xonxoff"),
)
orientation_combo.connect(
"changed", lambda w: self.update_option_from_combo("orientation", w, default="0")
)
mirrorx_check.connect("toggled", lambda w: self.update_option("mirrorX", w.get_active()))
mirrory_check.connect("toggled", lambda w: self.update_option("mirrorY", w.get_active()))
center_check.connect("toggled", lambda w: self.update_option("center", w.get_active()))
precut_check.connect("toggled", lambda w: self.update_option("precut", w.get_active()))
autoalign_check.connect("toggled", lambda w: self.update_option("autoAlign", w.get_active()))
conn_box.pack_start(conn_grid, False, False, 0)
conn_scroller = Gtk.ScrolledWindow()
conn_scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
conn_scroller.set_hexpand(True)
conn_scroller.set_vexpand(True)
conn_scroller.add(conn_box)
plot_box.pack_start(plot_grid, False, False, 0)
plot_scroller = Gtk.ScrolledWindow()
plot_scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
plot_scroller.set_hexpand(True)
plot_scroller.set_vexpand(True)
plot_scroller.add(plot_box)
notebook.append_page(conn_scroller, Gtk.Label(label="Connection Settings"))
notebook.append_page(plot_scroller, Gtk.Label(label="Plot Settings"))
# Footer with status and send button at the bottom.
footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
footer.pack_start(self.status_bar, True, True, 0)
footer.pack_end(self.cut_button, False, False, 0)
root.pack_start(notebook, True, True, 0)
root.pack_end(footer, False, False, 0)
self.window.add(root)
self.window.connect("destroy", self.on_window_close)
self.window.show_all()
def poll_devices(self):
if getattr(self, "sending", False):
return True
self.debug("Polling for devices...")
self.update_status_bar("Searching for devices...")
devices = list(self.enumerate_with_serial())
self.port_entries = []
for device_entry in devices:
device_path = device_entry["device"]
vidpid = device_entry["vidpid"]
info_text = device_entry["info"]
plotter_info = plotters.get(vidpid)
if isinstance(plotter_info, dict):
name = plotter_info.get("name", vidpid)
icon_key = plotter_info.get("icon")
elif plotter_info:
name = str(plotter_info)
icon_key = None
else:
name = "Unknown device"
icon_key = None
display = f"{device_path} ({vidpid})" if vidpid else device_path
self.port_entries.append(
{
"device": device_path,
"vidpid": vidpid,
"name": name,
"display": display,
"info": info_text,
"icon": icon_key,
"supported": plotter_info is not None,
}
)
if self.port_store:
self.port_store.clear()
for entry in self.port_entries:
self.port_store.append([entry["device"], entry["vidpid"] or "", entry["display"]])
if not self.port_entries:
self.debug("No ports detected this cycle.")
if self.port_combo:
self.port_combo.set_sensitive(False)
self.port_combo.hide()
self.current_device = None
self.current_vidpid = None
if self.port_info_label:
self.port_info_label.set_text("No Serial Devices Found")
self.port_info_frame.set_visible(True)
self.set_device_icon(None, fallback="noport")
self.cut_button.set_sensitive(False)
self.update_status_bar("Waiting for a supported plotter...")
return True
if self.port_combo:
self.port_combo.set_sensitive(True)
self.port_combo.show()
if self.port_info_frame:
self.port_info_frame.set_visible(True)
active_idx = 0
if self.current_device:
for idx, entry in enumerate(self.port_entries):
if entry["device"] == self.current_device:
active_idx = idx
break
if self.port_combo:
self.port_combo.handler_block_by_func(self.on_port_changed)
self.port_combo.set_active(active_idx)
self.port_combo.handler_unblock_by_func(self.on_port_changed)
self.apply_port_entry(self.port_entries[active_idx], update_status=not getattr(self, "sending", False))
return True
def apply_port_entry(self, entry, update_status=False):
self.current_device = entry["device"]
self.current_vidpid = entry["vidpid"]
detail = entry["vidpid"] or "unknown VID/PID"
if self.port_info_label:
info_text = entry.get("info") or ""
self.port_info_label.set_text(info_text)
self.set_device_icon(entry.get("icon"))
self.cut_button.set_sensitive(True)
if update_status:
self.update_status_bar("Ready")
def on_port_changed(self, _combo):
idx = self.port_combo.get_active() if self.port_combo else -1
if idx is None or idx < 0 or idx >= len(self.port_entries):
return
self.apply_port_entry(self.port_entries[idx], update_status=True)
def on_window_close(self, *_args):
if self.poll_id:
GLib.source_remove(self.poll_id)
Gtk.main_quit()
def on_cut_clicked(self, _button):
if not self.current_device:
self.show_dialog("No plotter detected yet.", Gtk.MessageType.ERROR)
self.update_status_bar("No plotter detected.", error=True)
return
try:
self.sending = True
self.update_status_bar("Sending to plotter...")
self.flush_gui()
time.sleep(0.05)
self.perform_cut(self.current_device)
self.show_dialog(
f"Sent the document to {self.current_device}.", Gtk.MessageType.INFO
)
self.update_status_bar("Sent")
except Exception as exc: # pylint: disable=broad-except
self.show_dialog(f"Cut failed: {exc}", Gtk.MessageType.ERROR)
self.update_status_bar(f"Cut failed: {exc}", error=True)
finally:
self.sending = False
def show_dialog(self, message, level):
dialog = Gtk.MessageDialog(
transient_for=self.window,
flags=0,
message_type=level,
buttons=Gtk.ButtonsType.OK,
text=message,
)
dialog.run()
dialog.destroy()
def update_status_bar(self, message, error=False):
if not self.status_bar:
return
prefix = "Error: " if error else ""
self.status_bar.set_text(f"{prefix}{message}")
def set_device_icon(self, icon_key, fallback=None):
if not self.device_image:
return
target_px = 150
base_dir = getattr(self, "icons_dir", None)
if base_dir is None:
base_dir = Path(__file__).resolve().parent / "icons"
def load_icon(name):
icon_path = base_dir / f"{name}.png"
if icon_path.exists():
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(icon_path), width=target_px, height=target_px, preserve_aspect_ratio=True
)
self.device_image.set_from_pixbuf(pixbuf)
return True
except Exception as exc:
try:
self.debug(f"Failed to load icon {icon_path}: {exc}")
except Exception:
pass
return False
if icon_key and load_icon(icon_key):
return
if fallback and load_icon(fallback):
return
if load_icon("unknown"):
return
self.device_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG)
self.device_image.set_pixel_size(target_px)
def flush_gui(self):
"""Process pending GTK events so label updates are shown before blocking work."""
while Gtk.events_pending():
Gtk.main_iteration_do(False)