Enhance GUI: Add order controls, thumbnail preview, and improve more UI stuff

This commit is contained in:
hyyz17200
2026-05-08 02:02:47 +08:00
parent 5f3b5c716c
commit b334b16c42
+305 -66
View File
@@ -2,8 +2,10 @@ from __future__ import annotations
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
@@ -12,7 +14,7 @@ if sys.platform.startswith("win") and "QT_QPA_PLATFORM" not in os.environ:
try: try:
from PySide6.QtCore import QEasingCurve, QEvent, QPropertyAnimation, Qt, QTimer, QUrl from PySide6.QtCore import QEasingCurve, QEvent, QPropertyAnimation, Qt, QTimer, QUrl
from PySide6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QFont from PySide6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QFont, QKeyEvent, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QAbstractItemView, QAbstractItemView,
@@ -30,9 +32,11 @@ try:
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QSpinBox, QSpinBox,
QStyle,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
QTextEdit, QTextEdit,
QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@@ -47,9 +51,11 @@ from .core import (
build_packed_3mf, build_packed_3mf,
format_duration, format_duration,
format_filament, format_filament,
list_gcode_members,
list_swap_gcode_files, list_swap_gcode_files,
read_3mf_summary, read_3mf_summary,
) )
from .builder import preview_members_for_gcode_member, resolve_output_gcode_member
from .paths import default_patch_config_path, default_swap_gcode_dir, user_settings_path from .paths import default_patch_config_path, default_swap_gcode_dir, user_settings_path
from .planning import ( from .planning import (
DEFAULT_OUTPUT_PATTERN, DEFAULT_OUTPUT_PATTERN,
@@ -62,6 +68,40 @@ from .planning import (
) )
SUMMARY_ROLE = Qt.UserRole + 100 SUMMARY_ROLE = Qt.UserRole + 100
PATH_ROLE = Qt.UserRole + 101
ORDER_COLUMN = 0
FILE_COLUMN = 1
COPIES_COLUMN = 2
TIME_COLUMN = 3
FILAMENT_COLUMN = 4
PREVIEW_IMAGE_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE)
def preview_image_sort_key(member_name: str) -> tuple[int, int, str]:
match = PREVIEW_IMAGE_RE.match(member_name)
plate_number = int(match.group(1)) if match else 9999
is_small = 1 if member_name.lower().endswith("_small.png") else 0
return (is_small, plate_number, member_name.lower())
def first_preview_image_member(member_names: list[str], gcode_member: str | None = None) -> str | None:
available_members = set(member_names)
if gcode_member is not None:
candidates = [name for name in preview_members_for_gcode_member(gcode_member) if name in available_members]
if candidates:
return sorted(candidates, key=preview_image_sort_key)[0]
candidates = [name for name in member_names if PREVIEW_IMAGE_RE.match(name)]
if not candidates:
candidates = [
name
for name in member_names
if name.lower().startswith("metadata/") and name.lower().endswith(".png")
]
if not candidates:
return None
return sorted(candidates, key=preview_image_sort_key)[0]
class SuccessToast(QLabel): class SuccessToast(QLabel):
@@ -146,14 +186,27 @@ class SuccessToast(QLabel):
class DropTableWidget(QTableWidget): class DropTableWidget(QTableWidget):
def __init__(self, on_files_dropped: Callable[[list[Path]], None], parent: QWidget | None = None) -> None: def __init__(
self,
on_files_dropped: Callable[[list[Path]], None],
on_delete_pressed: Callable[[], None],
parent: QWidget | None = None,
) -> None:
super().__init__(parent) super().__init__(parent)
self.on_files_dropped = on_files_dropped self.on_files_dropped = on_files_dropped
self.on_delete_pressed = on_delete_pressed
self.setAcceptDrops(True) self.setAcceptDrops(True)
self.viewport().setAcceptDrops(True) self.viewport().setAcceptDrops(True)
self.viewport().installEventFilter(self) self.viewport().installEventFilter(self)
self.setDragDropMode(QAbstractItemView.DropOnly) self.setDragDropMode(QAbstractItemView.DropOnly)
def keyPressEvent(self, event: QKeyEvent) -> None:
if event.key() == Qt.Key_Delete and self.selectedIndexes():
self.on_delete_pressed()
event.accept()
return
super().keyPressEvent(event)
def eventFilter(self, watched: object, event: QEvent) -> bool: def eventFilter(self, watched: object, event: QEvent) -> bool:
if watched is self.viewport(): if watched is self.viewport():
if event.type() in {QEvent.DragEnter, QEvent.DragMove}: if event.type() in {QEvent.DragEnter, QEvent.DragMove}:
@@ -213,11 +266,12 @@ class MainWindow(QMainWindow):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setWindowTitle(APP_NAME) self.setWindowTitle(APP_NAME)
self.resize(1180, 860) self.resize(960, 860)
self.setAcceptDrops(True) self.setAcceptDrops(True)
self._updating_table = False self._updating_table = False
self._loading_settings = True self._loading_settings = True
self._settings = self.load_settings() self._settings = self.load_settings()
self._shared_growth_enabled = False
self.build_ui() self.build_ui()
self.load_swap_gcode_to_combo() self.load_swap_gcode_to_combo()
self.restore_settings_to_ui() self.restore_settings_to_ui()
@@ -244,31 +298,63 @@ class MainWindow(QMainWindow):
def build_ui(self) -> None: def build_ui(self) -> None:
central = QWidget(self) central = QWidget(self)
root = QVBoxLayout(central) root = QVBoxLayout(central)
self.root_layout = root
file_group = QGroupBox("Input 3MF files") file_group = QGroupBox("Input 3MF files")
self.file_group = file_group
file_layout = QVBoxLayout(file_group) file_layout = QVBoxLayout(file_group)
self.table = DropTableWidget(self.add_paths) file_body = QHBoxLayout()
self.table.setColumnCount(4) table_layout = QVBoxLayout()
self.table.setHorizontalHeaderLabels(["3MF path", "Copies", "Time", "Filament"]) self.table = DropTableWidget(self.add_paths, self.remove_selected)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.setColumnCount(5)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.table.setHorizontalHeaderLabels(["Order", "3MF file", "Copies", "Time", "Filament"])
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) self.table.horizontalHeader().setSectionResizeMode(ORDER_COLUMN, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents) self.table.horizontalHeader().setSectionResizeMode(FILE_COLUMN, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(COPIES_COLUMN, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(TIME_COLUMN, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(FILAMENT_COLUMN, QHeaderView.ResizeToContents)
self.table.setColumnWidth(ORDER_COLUMN, 104)
self.table.verticalHeader().setDefaultSectionSize(38)
self.table.verticalHeader().hide()
self.table.setMinimumHeight(220)
self.table.setSelectionBehavior(QTableWidget.SelectRows) self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setAlternatingRowColors(True) self.table.setAlternatingRowColors(True)
self.table.setToolTip("Drag .3mf files or folders here. Folders add all top-level .3mf files.") self.table.setToolTip("Drag .3mf files or folders here. Folders add all top-level .3mf files.")
self.table.itemChanged.connect(self.on_table_item_changed) self.table.itemChanged.connect(self.on_table_item_changed)
file_layout.addWidget(self.table) self.table.itemSelectionChanged.connect(self.update_thumbnail_preview)
table_layout.addWidget(self.table)
self.total_summary_label = QLabel("Total: 0 plates | Time: Unknown | Filament: Unknown") self.total_summary_label = QLabel("Total: 0 plates | Time: Unknown | Filament: Unknown")
file_layout.addWidget(self.total_summary_label) table_layout.addWidget(self.total_summary_label)
file_body.addLayout(table_layout, 1)
preview_group = QGroupBox("Selected thumbnail")
preview_layout = QVBoxLayout(preview_group)
self.thumbnail_label = QLabel("Select an input file")
self.thumbnail_label.setAlignment(Qt.AlignCenter)
self.thumbnail_label.setFixedSize(280, 220)
self.thumbnail_label.setStyleSheet(
"""
QLabel {
background: #f8f8f8;
border: 1px solid #cfcfcf;
color: #666;
}
"""
)
self.thumbnail_name_label = QLabel("")
self.thumbnail_name_label.setWordWrap(True)
self.thumbnail_name_label.setMaximumWidth(280)
preview_layout.addWidget(self.thumbnail_label)
preview_layout.addWidget(self.thumbnail_name_label)
preview_layout.addStretch(1)
file_body.addWidget(preview_group)
file_layout.addLayout(file_body)
file_buttons = QHBoxLayout() file_buttons = QHBoxLayout()
add_button = QPushButton("Add 3MF") add_button = QPushButton("Add 3MF")
remove_button = QPushButton("Remove") remove_button = QPushButton("Remove")
remove_all_button = QPushButton("Remove All") remove_all_button = QPushButton("Remove All")
up_button = QPushButton("Move Up")
down_button = QPushButton("Move Down")
apply_default_copies_button = QPushButton("Apply Default Copies to Selected") apply_default_copies_button = QPushButton("Apply Default Copies to Selected")
self.build_button = QPushButton("Build 3MF") self.build_button = QPushButton("Build 3MF")
self.build_button.setMinimumHeight(64) self.build_button.setMinimumHeight(64)
@@ -281,26 +367,28 @@ class MainWindow(QMainWindow):
add_button.clicked.connect(self.add_files) add_button.clicked.connect(self.add_files)
remove_button.clicked.connect(self.remove_selected) remove_button.clicked.connect(self.remove_selected)
remove_all_button.clicked.connect(self.remove_all) remove_all_button.clicked.connect(self.remove_all)
up_button.clicked.connect(lambda: self.move_selected(-1))
down_button.clicked.connect(lambda: self.move_selected(1))
apply_default_copies_button.clicked.connect(self.apply_default_copies_to_selected) apply_default_copies_button.clicked.connect(self.apply_default_copies_to_selected)
self.build_button.clicked.connect(self.build_output) self.build_button.clicked.connect(self.build_output)
for button in (add_button, remove_button, remove_all_button, up_button, down_button, apply_default_copies_button): for button in (add_button, remove_button, remove_all_button, apply_default_copies_button):
button.setMinimumHeight(44)
file_buttons.addWidget(button) file_buttons.addWidget(button)
file_buttons.addStretch(1) file_buttons.addStretch(1)
file_buttons.addWidget(self.build_button) file_buttons.addWidget(self.build_button)
file_layout.addLayout(file_buttons) file_layout.addLayout(file_buttons)
root.addWidget(file_group) root.addWidget(file_group, 1)
options_group = QGroupBox("Packing options") options_group = QGroupBox("Packing options")
options_layout = QFormLayout(options_group) options_layout = QFormLayout(options_group)
self.swap_gcode_combo = QComboBox() self.swap_gcode_combo = QComboBox()
self.swap_gcode_combo.setMinimumWidth(520) self.swap_gcode_combo.setMinimumWidth(260)
self.swap_gcode_combo.setMaximumWidth(440)
swap_gcode_row = QHBoxLayout() swap_gcode_row = QHBoxLayout()
refresh_button = QPushButton("Refresh") refresh_button = QPushButton("Refresh")
open_folder_button = QPushButton("Open Folder") open_folder_button = QPushButton("Open Folder")
refresh_button.setFixedWidth(refresh_button.sizeHint().width())
open_folder_button.setFixedWidth(open_folder_button.sizeHint().width())
refresh_button.clicked.connect(self.load_swap_gcode_to_combo) refresh_button.clicked.connect(self.load_swap_gcode_to_combo)
open_folder_button.clicked.connect(self.open_swap_gcode_folder) open_folder_button.clicked.connect(self.open_swap_gcode_folder)
swap_gcode_row.addWidget(self.swap_gcode_combo, 1) swap_gcode_row.addWidget(self.swap_gcode_combo, 1)
@@ -311,6 +399,7 @@ class MainWindow(QMainWindow):
self.default_copies_spin = QSpinBox() self.default_copies_spin = QSpinBox()
self.default_copies_spin.setRange(1, 9999) self.default_copies_spin.setRange(1, 9999)
self.default_copies_spin.setValue(1) self.default_copies_spin.setValue(1)
self.default_copies_spin.setFixedWidth(96)
options_layout.addRow("Default copies for new inputs", self.default_copies_spin) options_layout.addRow("Default copies for new inputs", self.default_copies_spin)
self.bed_cooldown_check = QCheckBox("Wait for bed cooldown") self.bed_cooldown_check = QCheckBox("Wait for bed cooldown")
@@ -329,6 +418,7 @@ class MainWindow(QMainWindow):
self.wait_spin.setRange(0, 3600) self.wait_spin.setRange(0, 3600)
self.wait_spin.setValue(45) self.wait_spin.setValue(45)
self.wait_spin.setSuffix(" s") self.wait_spin.setSuffix(" s")
self.wait_spin.setFixedWidth(96)
options_layout.addRow("Wait after ejection", self.wait_spin) options_layout.addRow("Wait after ejection", self.wait_spin)
self.show_plate_number_check = QCheckBox("Show current plate in the hundreds digit of remaining time") self.show_plate_number_check = QCheckBox("Show current plate in the hundreds digit of remaining time")
@@ -339,21 +429,21 @@ class MainWindow(QMainWindow):
self.swap_final_check.setChecked(True) self.swap_final_check.setChecked(True)
options_layout.addRow("Final swap", self.swap_final_check) options_layout.addRow("Final swap", self.swap_final_check)
self.patch_check = QCheckBox("Apply editable G-code patches from gcode_patches.ini") self.patch_check = QCheckBox("Apply editable G-code patches")
self.patch_check.setToolTip("Uses gcode_patches.ini")
self.patch_check.setChecked(True) self.patch_check.setChecked(True)
patch_row = QHBoxLayout() patch_row = QHBoxLayout()
patch_path_label = QLabel(str(default_patch_config_path()))
patch_path_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
open_patch_button = QPushButton("Open Config") open_patch_button = QPushButton("Open Config")
open_patch_button.clicked.connect(self.open_patch_config) open_patch_button.clicked.connect(self.open_patch_config)
patch_row.addWidget(self.patch_check) patch_row.addWidget(self.patch_check)
patch_row.addWidget(patch_path_label, 1)
patch_row.addWidget(open_patch_button) patch_row.addWidget(open_patch_button)
patch_row.addStretch(1)
options_layout.addRow("G-code patches", patch_row) options_layout.addRow("G-code patches", patch_row)
self.metadata_combo = QComboBox() self.metadata_combo = QComboBox()
self.metadata_combo.addItem("Keep source prediction and weight", "source") self.metadata_combo.addItem("Keep source prediction and weight", "source")
self.metadata_combo.addItem("Sum prediction and filament", "sum") self.metadata_combo.addItem("Sum prediction and filament", "sum")
self.metadata_combo.setFixedWidth(260)
options_layout.addRow("3MF metadata", self.metadata_combo) options_layout.addRow("3MF metadata", self.metadata_combo)
self.individual_batch_check = QCheckBox("Individual batch mode") self.individual_batch_check = QCheckBox("Individual batch mode")
@@ -378,7 +468,10 @@ class MainWindow(QMainWindow):
output_dir_row = QHBoxLayout() output_dir_row = QHBoxLayout()
self.output_dir_edit = QLineEdit() self.output_dir_edit = QLineEdit()
self.output_dir_edit.setPlaceholderText("Leave empty to write next to the input file") self.output_dir_edit.setPlaceholderText("Leave empty to write next to the input file")
self.output_dir_edit.setMinimumWidth(280)
self.output_dir_edit.setMaximumWidth(560)
browse_output_dir_button = QPushButton("Browse") browse_output_dir_button = QPushButton("Browse")
browse_output_dir_button.setFixedWidth(browse_output_dir_button.sizeHint().width())
browse_output_dir_button.clicked.connect(self.choose_output_dir) browse_output_dir_button.clicked.connect(self.choose_output_dir)
output_dir_row.addWidget(self.output_dir_edit, 1) output_dir_row.addWidget(self.output_dir_edit, 1)
output_dir_row.addWidget(browse_output_dir_button) output_dir_row.addWidget(browse_output_dir_button)
@@ -386,6 +479,8 @@ class MainWindow(QMainWindow):
self.output_name_edit = QLineEdit(DEFAULT_OUTPUT_PATTERN) self.output_name_edit = QLineEdit(DEFAULT_OUTPUT_PATTERN)
self.output_name_edit.setPlaceholderText("Use tokens such as {source}, {sources}, {plates}, {copies}, {date}, {time}") self.output_name_edit.setPlaceholderText("Use tokens such as {source}, {sources}, {plates}, {copies}, {date}, {time}")
self.output_name_edit.setMinimumWidth(280)
self.output_name_edit.setMaximumWidth(560)
filename_rule_row = QHBoxLayout() filename_rule_row = QHBoxLayout()
output_rule_help_button = QPushButton("?") output_rule_help_button = QPushButton("?")
output_rule_help_button.setFixedWidth(34) output_rule_help_button.setFixedWidth(34)
@@ -395,17 +490,33 @@ class MainWindow(QMainWindow):
filename_rule_row.addWidget(output_rule_help_button) filename_rule_row.addWidget(output_rule_help_button)
output_layout.addRow("Output filename rule", filename_rule_row) output_layout.addRow("Output filename rule", filename_rule_row)
self.output_preview_label = QLabel("Output path preview: -") self.output_preview_label = QLabel("-")
output_layout.addRow("Preview", self.output_preview_label) output_layout.addRow("Preview", self.output_preview_label)
root.addWidget(output_group) root.addWidget(output_group)
self.log = QTextEdit() self.log = QTextEdit()
self.log.setReadOnly(True) self.log.setReadOnly(True)
self.log.setMinimumHeight(130) self.log.setMinimumHeight(100)
root.addWidget(self.log) root.addWidget(self.log, 0)
self.setCentralWidget(central) self.setCentralWidget(central)
self.success_toast = SuccessToast(central) self.success_toast = SuccessToast(central)
self.update_vertical_growth_policy()
def update_vertical_growth_policy(self) -> None:
if not hasattr(self, "root_layout") or not hasattr(self, "file_group") or not hasattr(self, "log"):
return
shared_growth = self.height() >= 980
if shared_growth == self._shared_growth_enabled:
return
self._shared_growth_enabled = shared_growth
self.root_layout.setStretchFactor(self.file_group, 4 if shared_growth else 1)
self.root_layout.setStretchFactor(self.log, 1 if shared_growth else 0)
def resizeEvent(self, event: QEvent) -> None:
super().resizeEvent(event)
self.update_vertical_growth_policy()
self.update_thumbnail_preview()
def connect_option_signals(self) -> None: def connect_option_signals(self) -> None:
self.swap_gcode_combo.currentIndexChanged.connect(self.save_current_settings) self.swap_gcode_combo.currentIndexChanged.connect(self.save_current_settings)
@@ -558,6 +669,98 @@ class MainWindow(QMainWindow):
files, _ = QFileDialog.getOpenFileNames(self, "Add 3MF files", "", "3MF files (*.3mf);;All files (*)") files, _ = QFileDialog.getOpenFileNames(self, "Add 3MF files", "", "3MF files (*.3mf);;All files (*)")
self.add_paths([Path(file_name) for file_name in files]) self.add_paths([Path(file_name) for file_name in files])
def create_order_widget(self, row: int) -> QWidget:
widget = QWidget()
layout = QHBoxLayout(widget)
layout.setContentsMargins(4, 0, 4, 0)
layout.setSpacing(4)
number_label = QLabel(str(row + 1))
number_label.setAlignment(Qt.AlignCenter)
number_label.setMinimumWidth(24)
up_button = QToolButton()
up_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp))
up_button.setToolTip("Move up")
up_button.setAutoRaise(True)
up_button.setFixedSize(28, 28)
up_button.setEnabled(row > 0)
up_button.clicked.connect(lambda _checked=False, current_row=row: self.move_row(current_row, -1))
down_button = QToolButton()
down_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
down_button.setToolTip("Move down")
down_button.setAutoRaise(True)
down_button.setFixedSize(28, 28)
down_button.setEnabled(row < self.table.rowCount() - 1)
down_button.clicked.connect(lambda _checked=False, current_row=row: self.move_row(current_row, 1))
layout.addWidget(number_label)
layout.addWidget(up_button)
layout.addWidget(down_button)
return widget
def update_order_controls(self) -> None:
for row in range(self.table.rowCount()):
self.table.setCellWidget(row, ORDER_COLUMN, self.create_order_widget(row))
self.table.setCellWidget(row, COPIES_COLUMN, self.create_copies_spin(row, self.get_row_copies(row)))
def create_copies_spin(self, row: int, copies: int) -> QSpinBox:
spin = QSpinBox()
spin.setRange(1, 9999)
spin.setFixedWidth(78)
spin.setAlignment(Qt.AlignCenter)
spin.setValue(max(1, int(copies)))
spin.valueChanged.connect(lambda value, current_row=row: self.on_row_copies_changed(current_row, value))
return spin
def row_path(self, row: int) -> Path | None:
item = self.table.item(row, FILE_COLUMN)
if item is None:
return None
path_value = item.data(PATH_ROLE)
if path_value:
return Path(str(path_value))
return Path(item.text())
def load_thumbnail_pixmap(self, path: Path) -> QPixmap | None:
with zipfile.ZipFile(path, "r") as archive:
gcode_members = list_gcode_members(archive)
active_gcode_member = resolve_output_gcode_member(archive, gcode_members[0]) if gcode_members else None
member_name = first_preview_image_member(archive.namelist(), active_gcode_member)
if member_name is None:
return None
data = archive.read(member_name)
pixmap = QPixmap()
if not pixmap.loadFromData(data):
return None
return pixmap
def update_thumbnail_preview(self) -> None:
if not hasattr(self, "thumbnail_label"):
return
row = self.selected_row()
path = self.row_path(row) if row is not None else None
self.thumbnail_label.clear()
if path is None:
self.thumbnail_label.setText("Select an input file")
self.thumbnail_name_label.setText("")
return
self.thumbnail_name_label.setText(path.name)
try:
pixmap = self.load_thumbnail_pixmap(path)
except Exception as exc:
self.thumbnail_label.setText("Preview unavailable")
self.thumbnail_label.setToolTip(str(exc))
return
if pixmap is None:
self.thumbnail_label.setText("No preview image")
self.thumbnail_label.setToolTip("")
return
scaled = pixmap.scaled(self.thumbnail_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.thumbnail_label.setPixmap(scaled)
self.thumbnail_label.setToolTip(str(path))
def add_paths(self, paths: list[Path]) -> None: def add_paths(self, paths: list[Path]) -> None:
expanded: list[Path] = [] expanded: list[Path] = []
for path in paths: for path in paths:
@@ -567,7 +770,11 @@ class MainWindow(QMainWindow):
expanded.append(path) expanded.append(path)
if not expanded: if not expanded:
return return
existing = {self.table.item(row, 0).text() for row in range(self.table.rowCount()) if self.table.item(row, 0)} existing: set[str] = set()
for row in range(self.table.rowCount()):
current_path = self.row_path(row)
if current_path is not None:
existing.add(str(current_path))
added = 0 added = 0
skipped = 0 skipped = 0
for path in expanded: for path in expanded:
@@ -596,24 +803,29 @@ class MainWindow(QMainWindow):
self._updating_table = True self._updating_table = True
try: try:
self.table.insertRow(row) self.table.insertRow(row)
path_item = QTableWidgetItem(str(path)) path_item = QTableWidgetItem(path.name)
path_item.setFlags(path_item.flags() & ~Qt.ItemIsEditable) path_item.setFlags(path_item.flags() & ~Qt.ItemIsEditable)
path_item.setData(PATH_ROLE, str(path))
path_item.setData(SUMMARY_ROLE, self.summary_to_dict(summary)) path_item.setData(SUMMARY_ROLE, self.summary_to_dict(summary))
path_item.setToolTip(self.base_summary_tooltip(summary)) path_item.setToolTip(f"{path}\n\n{self.base_summary_tooltip(summary)}")
copies_item = QTableWidgetItem(str(max(1, int(copies)))) path_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter)
copies_item.setTextAlignment(Qt.AlignCenter)
time_item = QTableWidgetItem("") time_item = QTableWidgetItem("")
filament_item = QTableWidgetItem("") filament_item = QTableWidgetItem("")
for item in (time_item, filament_item): for item in (time_item, filament_item):
item.setFlags(item.flags() & ~Qt.ItemIsEditable) item.setFlags(item.flags() & ~Qt.ItemIsEditable)
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.table.setItem(row, 0, path_item) self.table.setCellWidget(row, ORDER_COLUMN, self.create_order_widget(row))
self.table.setItem(row, 1, copies_item) self.table.setItem(row, FILE_COLUMN, path_item)
self.table.setItem(row, 2, time_item) self.table.setCellWidget(row, COPIES_COLUMN, self.create_copies_spin(row, copies))
self.table.setItem(row, 3, filament_item) self.table.setItem(row, TIME_COLUMN, time_item)
self.table.setItem(row, FILAMENT_COLUMN, filament_item)
self.table.setRowHeight(row, 38)
self.update_row_stats(row) self.update_row_stats(row)
finally: finally:
self._updating_table = False self._updating_table = False
self.update_order_controls()
if self.table.rowCount() == 1:
self.table.selectRow(0)
return True return True
def summary_to_dict(self, summary: ThreeMfSummary) -> dict[str, Any]: def summary_to_dict(self, summary: ThreeMfSummary) -> dict[str, Any]:
@@ -634,7 +846,10 @@ class MainWindow(QMainWindow):
) )
def get_row_copies(self, row: int) -> int: def get_row_copies(self, row: int) -> int:
item = self.table.item(row, 1) widget = self.table.cellWidget(row, COPIES_COLUMN)
if isinstance(widget, QSpinBox):
return max(1, int(widget.value()))
item = self.table.item(row, COPIES_COLUMN)
if item is None: if item is None:
return 1 return 1
try: try:
@@ -643,12 +858,28 @@ class MainWindow(QMainWindow):
return 1 return 1
def set_row_copies(self, row: int, copies: int) -> None: def set_row_copies(self, row: int, copies: int) -> None:
item = self.table.item(row, 1) value = max(1, int(copies))
widget = self.table.cellWidget(row, COPIES_COLUMN)
if isinstance(widget, QSpinBox):
widget.setValue(value)
return
item = self.table.item(row, COPIES_COLUMN)
if item is not None: if item is not None:
item.setText(str(max(1, int(copies)))) item.setText(str(value))
def on_row_copies_changed(self, row: int, copies: int) -> None:
if self._updating_table:
return
self._updating_table = True
try:
self.update_row_stats(row)
finally:
self._updating_table = False
self.update_total_summary()
self.update_output_preview()
def update_row_stats(self, row: int) -> None: def update_row_stats(self, row: int) -> None:
path_item = self.table.item(row, 0) path_item = self.table.item(row, FILE_COLUMN)
if path_item is None: if path_item is None:
return return
data = path_item.data(SUMMARY_ROLE) data = path_item.data(SUMMARY_ROLE)
@@ -662,13 +893,13 @@ class MainWindow(QMainWindow):
total_prediction = None if summary.prediction_seconds is None else summary.prediction_seconds * copies total_prediction = None if summary.prediction_seconds is None else summary.prediction_seconds * copies
total_weight = None if summary.weight_grams is None else summary.weight_grams * copies total_weight = None if summary.weight_grams is None else summary.weight_grams * copies
total_used_m = None if summary.filament_used_m is None else summary.filament_used_m * copies total_used_m = None if summary.filament_used_m is None else summary.filament_used_m * copies
self.table.item(row, 2).setText(format_duration(total_prediction)) self.table.item(row, TIME_COLUMN).setText(format_duration(total_prediction))
self.table.item(row, 3).setText(format_filament(total_weight, total_used_m)) self.table.item(row, FILAMENT_COLUMN).setText(format_filament(total_weight, total_used_m))
def on_table_item_changed(self, item: QTableWidgetItem) -> None: def on_table_item_changed(self, item: QTableWidgetItem) -> None:
if self._updating_table: if self._updating_table:
return return
if item.column() == 1: if item.column() == COPIES_COLUMN:
self._updating_table = True self._updating_table = True
try: try:
copies = self.get_row_copies(item.row()) copies = self.get_row_copies(item.row())
@@ -688,48 +919,54 @@ class MainWindow(QMainWindow):
def remove_selected(self) -> None: def remove_selected(self) -> None:
rows = sorted(self.selected_rows(), reverse=True) rows = sorted(self.selected_rows(), reverse=True)
next_row = min(rows) if rows else None
for row in rows: for row in rows:
self.table.removeRow(row) self.table.removeRow(row)
self.update_order_controls()
if next_row is not None and self.table.rowCount() > 0:
self.table.selectRow(min(next_row, self.table.rowCount() - 1))
self.update_total_summary() self.update_total_summary()
self.update_output_preview() self.update_output_preview()
self.update_thumbnail_preview()
def remove_all(self) -> None: def remove_all(self) -> None:
self.table.setRowCount(0) self.table.setRowCount(0)
self.update_order_controls()
self.update_total_summary() self.update_total_summary()
self.update_output_preview() self.update_output_preview()
self.update_thumbnail_preview()
def move_selected(self, delta: int) -> None: def move_selected(self, delta: int) -> None:
row = self.selected_row() row = self.selected_row()
if row is None: if row is None:
return return
self.move_row(row, delta)
def move_row(self, row: int, delta: int) -> None:
new_row = row + delta new_row = row + delta
if new_row < 0 or new_row >= self.table.rowCount(): if new_row < 0 or new_row >= self.table.rowCount():
return return
row_values: list[tuple[str, Any, Any, str]] = [] copies = self.get_row_copies(row)
row_items: list[QTableWidgetItem | None] = []
for col in range(self.table.columnCount()): for col in range(self.table.columnCount()):
item = self.table.item(row, col) item = self.table.item(row, col)
row_values.append((item.text() if item else "", item.flags() if item else Qt.ItemIsEnabled, item.data(SUMMARY_ROLE) if item else None, item.toolTip() if item else "")) row_items.append(item.clone() if item is not None else None)
self._updating_table = True self._updating_table = True
try: try:
self.table.removeRow(row) self.table.removeRow(row)
self.table.insertRow(new_row) self.table.insertRow(new_row)
for col, (text, flags, data, tooltip) in enumerate(row_values): for col, item in enumerate(row_items):
item = QTableWidgetItem(text) if item is not None:
item.setFlags(flags)
if col in {1}:
item.setTextAlignment(Qt.AlignCenter)
elif col in {2, 3}:
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
if data is not None:
item.setData(SUMMARY_ROLE, data)
if tooltip:
item.setToolTip(tooltip)
self.table.setItem(new_row, col, item) self.table.setItem(new_row, col, item)
self.table.setCellWidget(new_row, COPIES_COLUMN, self.create_copies_spin(new_row, copies))
self.table.setRowHeight(new_row, 38)
finally: finally:
self._updating_table = False self._updating_table = False
self.update_order_controls()
self.table.selectRow(new_row) self.table.selectRow(new_row)
self.update_total_summary() self.update_total_summary()
self.update_output_preview() self.update_output_preview()
self.update_thumbnail_preview()
def apply_default_copies_to_selected(self) -> None: def apply_default_copies_to_selected(self) -> None:
rows = self.selected_rows() rows = self.selected_rows()
@@ -747,8 +984,9 @@ class MainWindow(QMainWindow):
def choose_output_dir(self) -> None: def choose_output_dir(self) -> None:
start_dir = self.output_dir_edit.text().strip() start_dir = self.output_dir_edit.text().strip()
if not start_dir and self.table.rowCount() > 0 and self.table.item(0, 0): first_path = self.row_path(0) if self.table.rowCount() > 0 else None
start_dir = str(Path(self.table.item(0, 0).text()).parent) if not start_dir and first_path is not None:
start_dir = str(first_path.parent)
directory = QFileDialog.getExistingDirectory(self, "Choose output directory", start_dir or "") directory = QFileDialog.getExistingDirectory(self, "Choose output directory", start_dir or "")
if directory: if directory:
self.output_dir_edit.setText(directory) self.output_dir_edit.setText(directory)
@@ -756,17 +994,18 @@ class MainWindow(QMainWindow):
def collect_jobs(self) -> list[PlateJob]: def collect_jobs(self) -> list[PlateJob]:
jobs: list[PlateJob] = [] jobs: list[PlateJob] = []
for row in range(self.table.rowCount()): for row in range(self.table.rowCount()):
path_item = self.table.item(row, 0) path = self.row_path(row)
if path_item is None: if path is None:
continue continue
jobs.append(PlateJob(Path(path_item.text()), self.get_row_copies(row))) jobs.append(PlateJob(path, self.get_row_copies(row)))
return jobs return jobs
def summary_for_path(self, path: Path) -> ThreeMfSummary: def summary_for_path(self, path: Path) -> ThreeMfSummary:
normalized = str(path) normalized = str(path)
for row in range(self.table.rowCount()): for row in range(self.table.rowCount()):
item = self.table.item(row, 0) row_path = self.row_path(row)
if item is not None and item.text() == normalized: item = self.table.item(row, FILE_COLUMN)
if row_path is not None and str(row_path) == normalized and item is not None:
data = item.data(SUMMARY_ROLE) data = item.data(SUMMARY_ROLE)
if isinstance(data, ThreeMfSummary): if isinstance(data, ThreeMfSummary):
return data return data
@@ -795,19 +1034,19 @@ class MainWindow(QMainWindow):
def update_output_preview(self) -> None: def update_output_preview(self) -> None:
jobs = self.collect_jobs() jobs = self.collect_jobs()
if not jobs: if not jobs:
self.output_preview_label.setText("Output path preview: -") self.output_preview_label.setText("-")
return return
try: try:
if self.individual_batch_check.isChecked(): if self.individual_batch_check.isChecked():
first_path = resolve_output_path([jobs[0]], self.output_naming_options(), self.summary_for_path) first_path = resolve_output_path([jobs[0]], self.output_naming_options(), self.summary_for_path)
self.output_preview_label.setText( self.output_preview_label.setText(
f"Individual batch preview: {first_path} | {len(jobs)} output file(s)" f"{first_path} | {len(jobs)} output file(s)"
) )
else: else:
output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path) output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path)
self.output_preview_label.setText(f"Output path preview: {output_path}") self.output_preview_label.setText(str(output_path))
except Exception as exc: except Exception as exc:
self.output_preview_label.setText(f"Output path preview: {exc}") self.output_preview_label.setText(str(exc))
def build_options_for_output(self, output_path: Path) -> BuildOptions: def build_options_for_output(self, output_path: Path) -> BuildOptions:
swap_gcode_path = self.swap_gcode_combo.currentData() swap_gcode_path = self.swap_gcode_combo.currentData()