diff --git a/a1_swap_mod_packer/gui.py b/a1_swap_mod_packer/gui.py index 2522ea6..dbfcff9 100644 --- a/a1_swap_mod_packer/gui.py +++ b/a1_swap_mod_packer/gui.py @@ -2,8 +2,10 @@ from __future__ import annotations import json import os +import re import subprocess import sys +import zipfile from pathlib import Path from typing import Any, Callable @@ -12,7 +14,7 @@ if sys.platform.startswith("win") and "QT_QPA_PLATFORM" not in os.environ: try: 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 ( QApplication, QAbstractItemView, @@ -30,9 +32,11 @@ try: QMessageBox, QPushButton, QSpinBox, + QStyle, QTableWidget, QTableWidgetItem, QTextEdit, + QToolButton, QVBoxLayout, QWidget, ) @@ -47,9 +51,11 @@ from .core import ( build_packed_3mf, format_duration, format_filament, + list_gcode_members, list_swap_gcode_files, 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 .planning import ( DEFAULT_OUTPUT_PATTERN, @@ -62,6 +68,40 @@ from .planning import ( ) 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): @@ -146,14 +186,27 @@ class SuccessToast(QLabel): 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) self.on_files_dropped = on_files_dropped + self.on_delete_pressed = on_delete_pressed self.setAcceptDrops(True) self.viewport().setAcceptDrops(True) self.viewport().installEventFilter(self) 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: if watched is self.viewport(): if event.type() in {QEvent.DragEnter, QEvent.DragMove}: @@ -213,11 +266,12 @@ class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() self.setWindowTitle(APP_NAME) - self.resize(1180, 860) + self.resize(960, 860) self.setAcceptDrops(True) self._updating_table = False self._loading_settings = True self._settings = self.load_settings() + self._shared_growth_enabled = False self.build_ui() self.load_swap_gcode_to_combo() self.restore_settings_to_ui() @@ -244,31 +298,63 @@ class MainWindow(QMainWindow): def build_ui(self) -> None: central = QWidget(self) root = QVBoxLayout(central) + self.root_layout = root file_group = QGroupBox("Input 3MF files") + self.file_group = file_group file_layout = QVBoxLayout(file_group) - self.table = DropTableWidget(self.add_paths) - self.table.setColumnCount(4) - self.table.setHorizontalHeaderLabels(["3MF path", "Copies", "Time", "Filament"]) - self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) - self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) - self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) - self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents) + file_body = QHBoxLayout() + table_layout = QVBoxLayout() + self.table = DropTableWidget(self.add_paths, self.remove_selected) + self.table.setColumnCount(5) + self.table.setHorizontalHeaderLabels(["Order", "3MF file", "Copies", "Time", "Filament"]) + self.table.horizontalHeader().setSectionResizeMode(ORDER_COLUMN, 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.setAlternatingRowColors(True) 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) - 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") - 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() add_button = QPushButton("Add 3MF") remove_button = QPushButton("Remove") 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") self.build_button = QPushButton("Build 3MF") self.build_button.setMinimumHeight(64) @@ -281,26 +367,28 @@ class MainWindow(QMainWindow): add_button.clicked.connect(self.add_files) remove_button.clicked.connect(self.remove_selected) 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) 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.addStretch(1) file_buttons.addWidget(self.build_button) file_layout.addLayout(file_buttons) - root.addWidget(file_group) + root.addWidget(file_group, 1) options_group = QGroupBox("Packing options") options_layout = QFormLayout(options_group) 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() refresh_button = QPushButton("Refresh") 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) open_folder_button.clicked.connect(self.open_swap_gcode_folder) 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.setRange(1, 9999) self.default_copies_spin.setValue(1) + self.default_copies_spin.setFixedWidth(96) options_layout.addRow("Default copies for new inputs", self.default_copies_spin) 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.setValue(45) self.wait_spin.setSuffix(" s") + self.wait_spin.setFixedWidth(96) 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") @@ -339,21 +429,21 @@ class MainWindow(QMainWindow): self.swap_final_check.setChecked(True) 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) 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.clicked.connect(self.open_patch_config) patch_row.addWidget(self.patch_check) - patch_row.addWidget(patch_path_label, 1) patch_row.addWidget(open_patch_button) + patch_row.addStretch(1) options_layout.addRow("G-code patches", patch_row) self.metadata_combo = QComboBox() self.metadata_combo.addItem("Keep source prediction and weight", "source") self.metadata_combo.addItem("Sum prediction and filament", "sum") + self.metadata_combo.setFixedWidth(260) options_layout.addRow("3MF metadata", self.metadata_combo) self.individual_batch_check = QCheckBox("Individual batch mode") @@ -378,7 +468,10 @@ class MainWindow(QMainWindow): output_dir_row = QHBoxLayout() self.output_dir_edit = QLineEdit() 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.setFixedWidth(browse_output_dir_button.sizeHint().width()) browse_output_dir_button.clicked.connect(self.choose_output_dir) output_dir_row.addWidget(self.output_dir_edit, 1) 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.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() output_rule_help_button = QPushButton("?") output_rule_help_button.setFixedWidth(34) @@ -395,17 +490,33 @@ class MainWindow(QMainWindow): filename_rule_row.addWidget(output_rule_help_button) 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) root.addWidget(output_group) self.log = QTextEdit() self.log.setReadOnly(True) - self.log.setMinimumHeight(130) - root.addWidget(self.log) + self.log.setMinimumHeight(100) + root.addWidget(self.log, 0) self.setCentralWidget(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: 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 (*)") 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: expanded: list[Path] = [] for path in paths: @@ -567,7 +770,11 @@ class MainWindow(QMainWindow): expanded.append(path) if not expanded: 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 skipped = 0 for path in expanded: @@ -596,24 +803,29 @@ class MainWindow(QMainWindow): self._updating_table = True try: self.table.insertRow(row) - path_item = QTableWidgetItem(str(path)) + path_item = QTableWidgetItem(path.name) 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.setToolTip(self.base_summary_tooltip(summary)) - copies_item = QTableWidgetItem(str(max(1, int(copies)))) - copies_item.setTextAlignment(Qt.AlignCenter) + path_item.setToolTip(f"{path}\n\n{self.base_summary_tooltip(summary)}") + path_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter) time_item = QTableWidgetItem("") filament_item = QTableWidgetItem("") for item in (time_item, filament_item): item.setFlags(item.flags() & ~Qt.ItemIsEditable) item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) - self.table.setItem(row, 0, path_item) - self.table.setItem(row, 1, copies_item) - self.table.setItem(row, 2, time_item) - self.table.setItem(row, 3, filament_item) + self.table.setCellWidget(row, ORDER_COLUMN, self.create_order_widget(row)) + self.table.setItem(row, FILE_COLUMN, path_item) + self.table.setCellWidget(row, COPIES_COLUMN, self.create_copies_spin(row, copies)) + 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) finally: self._updating_table = False + self.update_order_controls() + if self.table.rowCount() == 1: + self.table.selectRow(0) return True 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: - 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: return 1 try: @@ -643,12 +858,28 @@ class MainWindow(QMainWindow): return 1 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: - 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: - path_item = self.table.item(row, 0) + path_item = self.table.item(row, FILE_COLUMN) if path_item is None: return 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_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 - self.table.item(row, 2).setText(format_duration(total_prediction)) - self.table.item(row, 3).setText(format_filament(total_weight, total_used_m)) + self.table.item(row, TIME_COLUMN).setText(format_duration(total_prediction)) + self.table.item(row, FILAMENT_COLUMN).setText(format_filament(total_weight, total_used_m)) def on_table_item_changed(self, item: QTableWidgetItem) -> None: if self._updating_table: return - if item.column() == 1: + if item.column() == COPIES_COLUMN: self._updating_table = True try: copies = self.get_row_copies(item.row()) @@ -688,48 +919,54 @@ class MainWindow(QMainWindow): def remove_selected(self) -> None: rows = sorted(self.selected_rows(), reverse=True) + next_row = min(rows) if rows else None for row in rows: 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_output_preview() + self.update_thumbnail_preview() def remove_all(self) -> None: self.table.setRowCount(0) + self.update_order_controls() self.update_total_summary() self.update_output_preview() + self.update_thumbnail_preview() def move_selected(self, delta: int) -> None: row = self.selected_row() if row is None: return + self.move_row(row, delta) + + def move_row(self, row: int, delta: int) -> None: new_row = row + delta if new_row < 0 or new_row >= self.table.rowCount(): 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()): 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 try: self.table.removeRow(row) self.table.insertRow(new_row) - for col, (text, flags, data, tooltip) in enumerate(row_values): - item = QTableWidgetItem(text) - 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) + for col, item in enumerate(row_items): + if item is not None: + 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: self._updating_table = False + self.update_order_controls() self.table.selectRow(new_row) self.update_total_summary() self.update_output_preview() + self.update_thumbnail_preview() def apply_default_copies_to_selected(self) -> None: rows = self.selected_rows() @@ -747,8 +984,9 @@ class MainWindow(QMainWindow): def choose_output_dir(self) -> None: start_dir = self.output_dir_edit.text().strip() - if not start_dir and self.table.rowCount() > 0 and self.table.item(0, 0): - start_dir = str(Path(self.table.item(0, 0).text()).parent) + first_path = self.row_path(0) if self.table.rowCount() > 0 else None + 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 "") if directory: self.output_dir_edit.setText(directory) @@ -756,17 +994,18 @@ class MainWindow(QMainWindow): def collect_jobs(self) -> list[PlateJob]: jobs: list[PlateJob] = [] for row in range(self.table.rowCount()): - path_item = self.table.item(row, 0) - if path_item is None: + path = self.row_path(row) + if path is None: continue - jobs.append(PlateJob(Path(path_item.text()), self.get_row_copies(row))) + jobs.append(PlateJob(path, self.get_row_copies(row))) return jobs def summary_for_path(self, path: Path) -> ThreeMfSummary: normalized = str(path) for row in range(self.table.rowCount()): - item = self.table.item(row, 0) - if item is not None and item.text() == normalized: + row_path = self.row_path(row) + 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) if isinstance(data, ThreeMfSummary): return data @@ -795,19 +1034,19 @@ class MainWindow(QMainWindow): def update_output_preview(self) -> None: jobs = self.collect_jobs() if not jobs: - self.output_preview_label.setText("Output path preview: -") + self.output_preview_label.setText("-") return try: if self.individual_batch_check.isChecked(): first_path = resolve_output_path([jobs[0]], self.output_naming_options(), self.summary_for_path) 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: 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: - 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: swap_gcode_path = self.swap_gcode_combo.currentData()