"""The main application window. ISO Scalpel uses a **split-navigation** layout: two panes side by side, each a fully-featured file manager (breadcrumbs, back/forward/up, live filter, tabs). Either pane can host either the host filesystem or the ISO image, and the two can be swapped with one click / keystroke. Menus are grouped into **Image · Navigate · Entry · View · Tools · Help** — deliberately distinct from the File/Edit/View/Settings/Help pattern of older ISO editors. """ # This file is part of ISO Scalpel. # Copyright (C) 2025 Jeremy Anderson # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, see # or write to the Free Software Foundation, Inc., 51 Franklin Street, # Fifth Floor, Boston, MA 02110-1301 USA. from __future__ import annotations import os from PySide6.QtCore import QSize, Qt from PySide6.QtGui import QAction, QActionGroup, QCloseEvent, QKeySequence from PySide6.QtWidgets import ( QApplication, QFileDialog, QInputDialog, QLabel, QMainWindow, QMessageBox, QProgressDialog, QSizePolicy, QSplitter, QStatusBar, QStyle, QTabWidget, QToolBar, QToolButton, QVBoxLayout, QWidget, ) from . import __app_name__ from .config import Settings from .dialogs.about_dialog import AboutDialog from .dialogs.boot_dialog import BootDialog from .dialogs.diff_dialog import DiffDialog from .dialogs.extract_dialog import ExtractDialog from .dialogs.new_iso_dialog import NewIsoDialog from .dialogs.properties_dialog import PropertiesDialog from .dialogs.settings_dialog import SettingsDialog from .iso_handler import IsoHandler from .iso_record import NAME_TYPE_LABELS, NameType from .widgets.fs_pane import FsPane from .widgets.iso_pane import IsoPane class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() self._settings = Settings.load() self._handler = IsoHandler() self._closing = False self.setWindowTitle(__app_name__) self.resize(self._settings.window_width, self._settings.window_height) self._build_central() self._build_actions() self._build_menubar() self._build_toolbar() self._build_statusbar() self._connect_panes() self._update_actions() self._update_nav_actions() self._update_status() # ====================================================================== # Central widget — split-nav with directional transfer + swap + tabs # ====================================================================== def _build_central(self) -> None: self._fs_pane = FsPane(self._settings) self._iso_pane = IsoPane(self._handler, self._settings) self._iso_pane.refresh() # wrap each pane in a tab widget so the user can open multiple # filesystem folders / multiple ISOs (ISO tabs come later; for now # each side is a single-tab container that is swap-aware). self._left_tabs = _PaneTabWidget("Filesystem") self._left_tabs.addTab(self._fs_pane, "FS") self._right_tabs = _PaneTabWidget("ISO Image") self._right_tabs.addTab(self._iso_pane, "ISO") # Cap each pane's minimum width so neither one can starve the # other when the window is narrow -- without this, the ISO pane # (which has more chrome: nav buttons + breadcrumb + view combo # + filter box) ends up with a much larger minimum-size hint # than the FS pane and the splitter gives it the lion's share # of any extra width. 150px is enough to show the Name column # plus a sliver of the next column. self._left_tabs.setMinimumWidth(150) self._right_tabs.setMinimumWidth(150) # The middle column hosts two directional transfer buttons: # → Add : transfer selected FS files into the ISO # ← Extract : transfer selected ISO entries back to disk # Each button fills half the column height so the column stays # narrow (no wasted vertical space) and the direction is # unambiguous: arrow direction = data flow direction. self._transfer_col = _TransferColumn( on_add=self.on_add, on_extract=self.on_extract, ) # we track which side holds what so swap is reversible self._left_is_fs = True # True => left pane is filesystem self._splitter = QSplitter(Qt.Horizontal) self._splitter.addWidget(self._left_tabs) self._splitter.addWidget(self._transfer_col) self._splitter.addWidget(self._right_tabs) # Validate saved splitter sizes and fall back to a sane default # if they're missing, malformed, or would starve a pane. saved = self._settings.splitter_sizes if (saved and len(saved) == 3 and all(isinstance(x, int) and x >= 100 for x in saved)): self._splitter.setSizes(saved) else: default_w = max(self._settings.window_width, 800) half = (default_w - 36) // 2 self._splitter.setSizes([half, 36, half]) self._splitter.setChildrenCollapsible(False) self._splitter.setStretchFactor(0, 1) self._splitter.setStretchFactor(1, 0) self._splitter.setStretchFactor(2, 1) # keep the transfer column narrow so the panes get the bulk of # the width; it must never grow when the window is resized. self._transfer_col.setMaximumWidth(36) self._splitter.setMinimumSize(400, 200) self.setCentralWidget(self._splitter) def swap_panes(self) -> None: """Exchange the contents of the left and right panes. Bound to ``Ctrl+Shift+X`` and the Navigate menu. The directional transfer buttons always point in the data-flow direction regardless of which pane is on which side, so swapping panes never changes the arrow labels. """ sizes = self._splitter.sizes() self._left_tabs.setParent(None) self._right_tabs.setParent(None) self._transfer_col.setParent(None) self._left_is_fs = not self._left_is_fs if self._left_is_fs: self._splitter.addWidget(self._left_tabs) self._splitter.addWidget(self._transfer_col) self._splitter.addWidget(self._right_tabs) else: self._splitter.addWidget(self._right_tabs) self._splitter.addWidget(self._transfer_col) self._splitter.addWidget(self._left_tabs) self._splitter.setSizes(sizes) self._update_active_pane() # ====================================================================== # Active-pane tracking # ====================================================================== def _active_pane(self): """Return the pane widget (FsPane or IsoPane) currently focused. We prefer the side that last received focus; fall back to whatever is on the left. """ if self._iso_pane.hasFocus() or self._iso_pane.view.hasFocus(): return self._iso_pane if self._fs_pane.hasFocus() or self._fs_pane.view.hasFocus(): return self._fs_pane return self._fs_pane def _update_active_pane(self) -> None: self._update_nav_actions() # ====================================================================== # Actions # ====================================================================== def _build_actions(self) -> None: st = self.style() def mk(text, icon=None, shortcut=None, tip=None, slot=None): a = QAction(text, self) if icon is not None: a.setIcon(st.standardIcon(icon)) if shortcut: a.setShortcut(QKeySequence(shortcut)) if tip: a.setToolTip(tip) a.setStatusTip(tip) if slot: a.triggered.connect(slot) return a # --- Image menu ------------------------------------------------- self.act_new = mk("New…", QStyle.SP_FileIcon, "Ctrl+N", "Create a new ISO image", self.on_new) self.act_open = mk("Open…", QStyle.SP_DirOpenIcon, "Ctrl+O", "Open an existing ISO image", self.on_open) self.act_save = mk("Save", QStyle.SP_DialogSaveButton, "Ctrl+S", "Save the image", self.on_save) self.act_saveas = mk("Save As…", QStyle.SP_DialogSaveButton, "Ctrl+Shift+S", "Save the image to a new file", self.on_save_as) self.act_close = mk("Close", None, "Ctrl+W", "Close the current image", self.on_close) self.act_props = mk("Properties…", QStyle.SP_FileDialogContentsView, "Alt+Enter", "View / edit image properties", self.on_properties) self.act_quit = mk("Quit", None, "Ctrl+Q", "Exit the application", self.close) # --- Navigate menu --------------------------------------------- self.act_back = mk("Back", QStyle.SP_ArrowBack, "Alt+Left", "Active pane: go back", self.on_nav_back) self.act_forward = mk("Forward", QStyle.SP_ArrowForward, "Alt+Right", "Active pane: go forward", self.on_nav_forward) self.act_up = mk("Up", QStyle.SP_ArrowUp, "Alt+Up", "Active pane: go to parent", self.on_nav_up) self.act_goto = mk("Go to…", None, "Ctrl+L", "Active pane: go to a typed path", self.on_nav_goto) self.act_swap = mk("Swap Panes", None, "Ctrl+Shift+X", "Exchange the left and right panes", self.swap_panes) self.act_newtab = mk("New Tab", QStyle.SP_FileDialogNewFolder, "Ctrl+T", "New tab in the active pane (filesystem)", self.on_new_tab) # --- Entry menu ------------------------------------------------ self.act_add = mk("Add to Image", QStyle.SP_ArrowForward, "Insert", "Add selected files to the ISO", self.on_add) self.act_extract = mk("Extract to Disk…", QStyle.SP_ArrowBack, "Ctrl+E", "Extract selected entries to disk", self.on_extract) self.act_newfolder = mk("New Folder…", QStyle.SP_FileDialogNewFolder, "Ctrl+Shift+N", "Create a new folder in the ISO", self.on_new_folder) self.act_rename = mk("Rename…", QStyle.SP_DialogResetButton, "F2", "Rename the selected entry", self.on_rename) self.act_delete = mk("Delete", QStyle.SP_DialogCancelButton, "Delete", "Delete selected entries from the ISO", self.on_delete) self.act_selectall = mk("Select All", None, "Ctrl+A", "Select all entries in the active pane", self.on_select_all) self.act_invert = mk("Invert Selection", None, "Ctrl+Shift+I", "Invert the selection", self.on_invert_selection) # --- View menu ------------------------------------------------- self.act_show_hidden = QAction("Show Hidden Files", self, checkable=True) self.act_show_hidden.setChecked(self._settings.show_hidden) self.act_show_hidden.toggled.connect(self._on_show_hidden) self.act_filter = mk("Filter…", QStyle.SP_FileDialogContentsView, "Ctrl+F", "Focus the active pane's filter box", self.on_filter) self.act_refresh = mk("Refresh", QStyle.SP_BrowserReload, "F5", "Refresh the active pane", self.on_refresh) # --- Tools menu ------------------------------------------------ # SP_MediaPlay is the closest standard icon to a "boot" arrow; # a dedicated disc-boot icon would be better but isn't in # QStyle's standard set. self.act_boot = mk("Boot Image…", QStyle.SP_MediaPlay, "Ctrl+B", "Configure El Torito boot", self.on_boot) self.act_volmeta = mk("Volume Metadata…", None, None, "Edit volume label and identifiers", self.on_properties) self.act_diff = mk("Compare Images…", None, "Ctrl+D", "Compare the filesystems of two ISO images", self.on_diff) self.act_settings = mk("Preferences…", QStyle.SP_FileDialogListView, "Ctrl+,", "Application preferences", self.on_settings) # --- Help menu ------------------------------------------------- self.act_about = mk("About " + __app_name__, QStyle.SP_MessageBoxInformation, None, None, self.on_about) self.act_about_qt = mk("About Qt", None, None, None, self.on_about_qt) # ====================================================================== # Menu bar — Image · Navigate · Entry · View · Tools · Help # ====================================================================== def _build_menubar(self) -> None: mb = self.menuBar() m_image = mb.addMenu("&Image") m_image.addAction(self.act_new) m_image.addAction(self.act_open) self._recent_menu = m_image.addMenu("Open Recent") m_image.addSeparator() m_image.addAction(self.act_save) m_image.addAction(self.act_saveas) m_image.addAction(self.act_close) m_image.addSeparator() m_image.addAction(self.act_props) m_image.addSeparator() m_image.addAction(self.act_quit) self._rebuild_recent_menu() m_nav = mb.addMenu("&Navigate") m_nav.addAction(self.act_back) m_nav.addAction(self.act_forward) m_nav.addAction(self.act_up) m_nav.addAction(self.act_goto) m_nav.addSeparator() m_nav.addAction(self.act_swap) m_nav.addSeparator() m_nav.addAction(self.act_newtab) m_entry = mb.addMenu("&Entry") m_entry.addAction(self.act_add) m_entry.addAction(self.act_extract) m_entry.addSeparator() m_entry.addAction(self.act_newfolder) m_entry.addAction(self.act_rename) m_entry.addAction(self.act_delete) m_entry.addSeparator() m_entry.addAction(self.act_selectall) m_entry.addAction(self.act_invert) m_view = mb.addMenu("&View") m_view.addAction(self.act_show_hidden) m_view.addAction(self.act_filter) m_view.addSeparator() self._name_type_menu = m_view.addMenu("Display Names As") self._name_type_group = QActionGroup(self) self._name_type_group.setExclusive(True) self._name_type_group.triggered.connect(self._on_name_type_action) m_view.addSeparator() m_view.addAction(self.act_refresh) m_tools = mb.addMenu("&Tools") m_tools.addAction(self.act_boot) m_tools.addAction(self.act_volmeta) m_tools.addSeparator() m_tools.addAction(self.act_diff) m_tools.addSeparator() m_tools.addAction(self.act_settings) m_help = mb.addMenu("&Help") m_help.addAction(self.act_about) m_help.addAction(self.act_about_qt) # ====================================================================== # Toolbar # ====================================================================== def _build_toolbar(self) -> None: tb = QToolBar("Main") tb.setMovable(False) # PySide6 requires a QSize, not a raw int. Wrap the pixelMetric # result so we don't rely on the implicit conversion that older # PyQt5 / early PySide6 builds accepted. icon_px = self.style().pixelMetric(QStyle.PM_ToolBarIconSize) tb.setIconSize(QSize(icon_px, icon_px)) self.addToolBar(tb) tb.addAction(self.act_new) tb.addAction(self.act_open) tb.addAction(self.act_save) tb.addSeparator() tb.addAction(self.act_back) tb.addAction(self.act_forward) tb.addAction(self.act_up) tb.addSeparator() tb.addAction(self.act_add) tb.addAction(self.act_extract) tb.addAction(self.act_newfolder) tb.addAction(self.act_delete) tb.addSeparator() tb.addAction(self.act_boot) tb.addAction(self.act_props) # act_swap stays out of the toolbar: directional transfer buttons in # the splitter gutter cover the on-screen interaction, and swap # remains reachable via Ctrl+Shift+X and the Navigate menu. # A stretch spacer keeps the toolbar items left-aligned on wide windows. spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) tb.addWidget(spacer) # ====================================================================== # Status bar # ====================================================================== def _build_statusbar(self) -> None: sb = QStatusBar() self.setStatusBar(sb) self._status_msg = QLabel("Ready") self._status_info = QLabel("") self._status_sel = QLabel("") sb.addWidget(self._status_msg, 1) sb.addPermanentWidget(self._status_sel) sb.addPermanentWidget(self._status_info) # ====================================================================== # Pane signals # ====================================================================== def _connect_panes(self) -> None: self._fs_pane.addRequested.connect(self._on_add_files) self._fs_pane.pathChanged.connect(lambda _p: self._update_nav_actions()) self._fs_pane.navChanged.connect(self._update_nav_actions) self._iso_pane.addFilesRequested.connect(self._on_add_files_to) self._iso_pane.extractRequested.connect(self._on_extract_items) self._iso_pane.deleteRequested.connect(self.on_delete_items) self._iso_pane.renameRequested.connect(self._on_rename) self._iso_pane.newFolderRequested.connect(self._on_new_folder) self._iso_pane.propertiesRequested.connect(lambda _p: self.on_properties()) self._iso_pane.nameTypeChanged.connect(lambda _nt: self._update_status()) self._iso_pane.navChanged.connect(self._update_nav_actions) # selection-changed → update status + nav self._fs_pane.view.selectionModel().selectionChanged.connect( lambda *_: self._update_status()) self._iso_pane.view.selectionModel().selectionChanged.connect( lambda *_: self._update_status()) # ====================================================================== # Navigation actions # ====================================================================== def on_nav_back(self) -> None: self._active_pane().go_back() def on_nav_forward(self) -> None: self._active_pane().go_forward() def on_nav_up(self) -> None: self._active_pane().go_up() def on_nav_goto(self) -> None: pane = self._active_pane() if isinstance(pane, FsPane): path, ok = QInputDialog.getText( self, "Go to", "Path:", text=pane.current_path()) if ok and path.strip(): pane.set_path(os.path.expanduser(path.strip())) else: path, ok = QInputDialog.getText( self, "Go to (ISO path)", "ISO path:", text=pane.current_dir()) if ok and path.strip(): pane.go_to_path(path.strip()) def on_new_tab(self) -> None: """Open a new filesystem tab in the filesystem side.""" # For now: spawn a new FsPane in the filesystem tab widget. new_pane = FsPane(self._settings) container = self._left_tabs if self._left_is_fs else self._right_tabs container.addTab(new_pane, "FS") container.setCurrentIndex(container.count() - 1) new_pane.addRequested.connect(self._on_add_files) new_pane.pathChanged.connect(lambda _p: self._update_nav_actions()) new_pane.navChanged.connect(self._update_nav_actions) def on_filter(self) -> None: self._active_pane().focus_filter() def on_select_all(self) -> None: self._active_pane().view.selectAll() def on_invert_selection(self) -> None: view = self._active_pane().view model = view.model() sm = view.selectionModel() # Build the select / deselect sets in one pass, then apply each as a # single batched QItemSelectionModel.select() call -- fewer model # signals than a per-row toggle. rows = range(model.rowCount()) to_select = [model.index(r, 0) for r in rows if not sm.isSelected(model.index(r, 0))] to_deselect = [model.index(r, 0) for r in rows if sm.isSelected(model.index(r, 0))] if to_select: sm.select(to_select, sm.Select) if to_deselect: sm.select(to_deselect, sm.Deselect) def _update_nav_actions(self) -> None: pane = self._active_pane() can_back = hasattr(pane, "can_back") and pane.can_back() can_fwd = hasattr(pane, "can_forward") and pane.can_forward() self.act_back.setEnabled(can_back) self.act_forward.setEnabled(can_fwd) self.act_up.setEnabled(True) # ====================================================================== # Image operations # ====================================================================== def on_new(self) -> None: if not self._confirm_discard(): return dlg = NewIsoDialog(self._settings, self) if dlg.exec() != NewIsoDialog.Accepted: return try: self._handler.new(dlg.options()) except Exception as exc: # noqa: BLE001 -- user-facing error boundary QMessageBox.critical(self, "New Image", f"Failed to create image:\n{exc}") return self._iso_pane.refresh() self._update_actions() self._update_nav_actions() self._update_status() self._status("New image created.") def on_open(self) -> None: if not self._confirm_discard(): return path, _ = QFileDialog.getOpenFileName( self, "Open ISO Image", self._settings.last_dir, "ISO images (*.iso *.bin *.cue);;All files (*)") if not path: return self._open_path(path) def _open_path(self, path: str) -> None: try: self._handler.open(path) except Exception as exc: # noqa: BLE001 -- user-facing error boundary QMessageBox.critical(self, "Open", f"Failed to open image:\n{exc}") return self._settings.add_recent(path) self._rebuild_recent_menu() self._iso_pane.refresh() self._update_actions() self._update_nav_actions() self._update_status() self._status(f"Opened {os.path.basename(path)}") def on_save(self) -> None: if not self._handler.is_open: return if not self._handler.filename: self.on_save_as() return self._do_save(self._handler.filename) def on_save_as(self) -> None: if not self._handler.is_open: return path, _ = QFileDialog.getSaveFileName( self, "Save ISO Image As", self._settings.last_dir, "ISO images (*.iso);;All files (*)") if not path: return self._do_save(path) def _do_save(self, path: str) -> None: prog = QProgressDialog("Writing image…", "Cancel", 0, 100, self) prog.setWindowTitle("Save") prog.setWindowModality(Qt.WindowModal) prog.setMinimumDuration(0) prog.setValue(0) self._handler.progress_cb = self._make_save_progress(prog) try: self._handler.save(path) except Exception as exc: # noqa: BLE001 -- user-facing error boundary QMessageBox.critical(self, "Save", f"Failed to save image:\n{exc}") prog.close() return finally: self._handler.progress_cb = None prog.close() self._settings.add_recent(path) self._rebuild_recent_menu() self._iso_pane.refresh() self._update_actions() self._update_status() self._status(f"Saved to {os.path.basename(path)}") def _make_save_progress(self, prog: QProgressDialog): def cb(op: str, done: int, total: int) -> None: if total <= 0: return pct = int(done * 100 / total) prog.setValue(min(99, pct)) if prog.wasCanceled(): self._handler.cancel() QApplication.processEvents() return cb def on_close(self) -> None: if not self._confirm_discard(): return self._handler.close() self._iso_pane.refresh() self._update_actions() self._update_nav_actions() self._update_status() self._status("Image closed.") # ====================================================================== # Entry operations # ====================================================================== def on_add(self) -> None: paths = self._fs_pane.selected_paths() if not paths: self._status("Nothing selected in the filesystem pane.") return self._on_add_files(paths) def _on_add_files(self, paths: list[str]) -> None: self._on_add_files_to("/", paths) def _on_add_files_to(self, target: str, paths: list[str]) -> None: if not self._handler.is_open: QMessageBox.information(self, "Add", "Open or create an image first.") return nt = self._iso_pane._model.name_type() prog = QProgressDialog(f"Adding {len(paths)} item(s)…", "Cancel", 0, len(paths), self) prog.setWindowModality(Qt.WindowModal) prog.setMinimumDuration(0) ok = 0 for i, p in enumerate(paths): if prog.wasCanceled(): break prog.setValue(i) QApplication.processEvents() try: if os.path.isdir(p): name = os.path.basename(p) new_dir = self._handler.add_directory(target, nt, name) self._import_dir(p, target.rstrip("/") + "/" + new_dir, nt) else: self._handler.add_file(p, target, nt, nice_name=os.path.basename(p)) ok += 1 except Exception as exc: # noqa: BLE001 -- user-facing error boundary if not self._ask_continue("Add", p, exc): break prog.setValue(len(paths)) prog.close() self._iso_pane.refresh_parent(target) self._update_actions() self._update_status() self._status(f"Added {ok} item(s).") def _import_dir(self, local_dir: str, iso_dir: str, nt: NameType) -> None: for entry in sorted(os.listdir(local_dir)): full = os.path.join(local_dir, entry) if os.path.isdir(full): new_dir = self._handler.add_directory(iso_dir, nt, entry) self._import_dir(full, iso_dir.rstrip("/") + "/" + new_dir, nt) else: self._handler.add_file(full, iso_dir, nt, nice_name=entry) def on_extract(self) -> None: records = self._iso_pane.selected_records() if not records: self._status("Nothing selected in the ISO pane.") return self._on_extract_items([(r.path, r.is_dir) for r in records], self._settings.last_dir) def _on_extract_items(self, items: list[tuple], default_dir: str) -> None: if not self._handler.is_open or not items: return dlg = ExtractDialog(items, default_dir, self) if dlg.exec() != ExtractDialog.Accepted: return dest = dlg.destination() os.makedirs(dest, exist_ok=True) nt = self._iso_pane._model.name_type() prog = QProgressDialog(f"Extracting {len(items)} item(s)…", "Cancel", 0, len(items), self) prog.setWindowModality(Qt.WindowModal) prog.setMinimumDuration(0) ok = 0 for i, (path, is_dir) in enumerate(items): if prog.wasCanceled(): break prog.setValue(i) QApplication.processEvents() try: local = os.path.join(dest, os.path.basename(path) or "root") if is_dir: self._handler.extract_dir(path, nt, local) else: self._handler.extract_file(path, nt, local) ok += 1 except Exception as exc: # noqa: BLE001 -- user-facing error boundary if not self._ask_continue("Extract", path, exc): break prog.setValue(len(items)) prog.close() self._settings.last_dir = dest self._update_status() self._status(f"Extracted {ok} item(s) to {dest}.") def on_delete(self) -> None: items = [(r.path, r.is_dir) for r in self._iso_pane.selected_records()] if not items: self._status("Nothing selected to delete.") return self.on_delete_items(items) def on_delete_items(self, items: list[tuple]) -> None: if not self._handler.is_open or not items: return if self._settings.confirm_delete: names = ", ".join(os.path.basename(p) for p, _ in items[:5]) if QMessageBox.question( self, "Delete", f"Delete {len(items)} item(s): {names}?\nThis cannot be undone.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) != QMessageBox.Yes: return nt = self._iso_pane._model.name_type() items_sorted = sorted(items, key=lambda it: it[0].count("/"), reverse=True) for path, is_dir in items_sorted: try: self._handler.remove(path, nt, is_dir) except Exception as exc: # noqa: BLE001 -- user-facing error boundary if not self._ask_continue("Delete", path, exc): break self._iso_pane.refresh() self._update_actions() self._update_status() self._status(f"Deleted {len(items)} item(s).") def on_rename(self) -> None: records = self._iso_pane.selected_records() if len(records) != 1: self._status("Select a single item to rename.") return self._on_rename(records[0].path, records[0].is_dir) def _on_rename(self, path: str, is_dir: bool) -> None: if not self._handler.is_open: return old = os.path.basename(path) new, ok = QInputDialog.getText(self, "Rename", "New name:", text=old) if not ok or not new.strip() or new.strip() == old: return nt = self._iso_pane._model.name_type() try: self._handler.rename(path, nt, new.strip(), is_dir) except Exception as exc: # noqa: BLE001 -- user-facing error boundary QMessageBox.critical(self, "Rename", f"Failed to rename:\n{exc}") return self._iso_pane.refresh() self._update_status() self._status(f"Renamed to {new.strip()}.") def on_new_folder(self) -> None: self._on_new_folder("/") def _on_new_folder(self, parent: str) -> None: if not self._handler.is_open: return name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") if not ok or not name.strip(): return nt = self._iso_pane._model.name_type() try: self._handler.add_directory(parent, nt, name.strip()) except Exception as exc: # noqa: BLE001 -- user-facing error boundary QMessageBox.critical(self, "New Folder", f"Failed to create folder:\n{exc}") return self._iso_pane.refresh_parent(parent) self._update_status() self._status(f"Created folder '{name.strip()}'.") # ====================================================================== # Tools # ====================================================================== def on_properties(self) -> None: if not self._handler.is_open: return dlg = PropertiesDialog(self._handler, self) dlg.exec() self._iso_pane.refresh() self._update_status() def on_boot(self) -> None: if not self._handler.is_open: return dlg = BootDialog(self._handler, self) dlg.exec() self._iso_pane.refresh() self._update_actions() def on_diff(self) -> None: dlg = DiffDialog(self) # pre-fill A with the currently-open image if there is one if self._handler.is_open and self._handler.filename: dlg.set_images(self._handler.filename, "") dlg.exec() def on_settings(self) -> None: dlg = SettingsDialog(self._settings, self) if dlg.exec() == SettingsDialog.Accepted: self._settings = dlg.apply_to(self._settings) self._fs_pane.set_show_hidden(self._settings.show_hidden) self.act_show_hidden.setChecked(self._settings.show_hidden) self._settings.save() # ====================================================================== # Help # ====================================================================== def on_about(self) -> None: AboutDialog(self).exec() def on_about_qt(self) -> None: QMessageBox.aboutQt(self, "About Qt") # ====================================================================== # View helpers # ====================================================================== def on_refresh(self) -> None: pane = self._active_pane() if isinstance(pane, IsoPane): pane.refresh() else: pane.set_path(pane.current_path()) def _on_show_hidden(self, on: bool) -> None: self._fs_pane.set_show_hidden(on) self._settings.show_hidden = on self._settings.save() def _on_name_type_action(self, action: QAction) -> None: nt = action.data() if nt is None: return self._iso_pane._model.set_name_type(nt) self._iso_pane.name_type.blockSignals(True) for i in range(self._iso_pane.name_type.count()): if self._iso_pane.name_type.itemData(i) == nt: self._iso_pane.name_type.setCurrentIndex(i) break self._iso_pane.name_type.blockSignals(False) self._update_status() # ====================================================================== # Action state # ====================================================================== def _update_actions(self) -> None: open_ = self._handler.is_open for a in (self.act_save, self.act_saveas, self.act_close, self.act_props, self.act_boot, self.act_volmeta, self.act_add, self.act_extract, self.act_newfolder, self.act_delete, self.act_rename): a.setEnabled(open_) # The directional transfer buttons mirror the action enabled # state so the user gets immediate visual feedback when there's # no open image to add to / extract from. if hasattr(self, "_transfer_col"): self._transfer_col.set_add_enabled(open_) self._transfer_col.set_extract_enabled(open_) self._name_type_menu.clear() if open_: current = self._iso_pane._model.name_type() for nt in self._handler.available_name_types(): a = QAction(NAME_TYPE_LABELS[nt], self._name_type_menu, checkable=True) a.setData(nt) a.setChecked(nt == current) self._name_type_group.addAction(a) self._name_type_menu.addAction(a) def _update_status(self) -> None: if self._handler.is_open: props = self._handler.get_properties() # Compact short labels for the status bar (the Properties dialog # uses the full "Joliet 3" form via VolumeProperties.extensions). ext_flags = ( (props.has_joliet, "Joliet"), (props.has_rock_ridge, "RR"), (props.has_udf, "UDF"), ) exts = [label for enabled, label in ext_flags if enabled] nt = NAME_TYPE_LABELS.get(self._iso_pane._model.name_type(), "ISO9660") dirty = " (modified)" if self._handler.is_dirty else "" self._status_info.setText( f"{_human(props.total_size)} · ISO9660 L{props.interchange_level}" f" · {', '.join(exts) or 'plain'} · view: {nt}{dirty}") title_base = os.path.basename(self._handler.filename or "untitled.iso") self.setWindowTitle(f"{__app_name__} — {title_base}{dirty}") else: self._status_info.setText("No image open") self.setWindowTitle(__app_name__) # selection count fs_n = self._fs_pane.selection_count() iso_n = self._iso_pane.selection_count() bits = [] if fs_n: bits.append(f"FS: {fs_n}") if iso_n: bits.append(f"ISO: {iso_n}") self._status_sel.setText(" · ".join(bits)) def _status(self, msg: str) -> None: self._status_msg.setText(msg) # ====================================================================== # Recent files # ====================================================================== def _rebuild_recent_menu(self) -> None: self._recent_menu.clear() if not self._settings.recent_files: a = self._recent_menu.addAction("(none)") a.setEnabled(False) return for path in self._settings.recent_files: label = os.path.basename(path) a = self._recent_menu.addAction(f"{label} — {path}") a.setToolTip(path) a.triggered.connect(lambda checked=False, p=path: self._open_recent(p)) def _open_recent(self, path: str) -> None: if not os.path.exists(path): QMessageBox.information(self, "Open Recent", f"File not found:\n{path}") self._settings.recent_files = [p for p in self._settings.recent_files if p != path] self._rebuild_recent_menu() return if not self._confirm_discard(): return self._open_path(path) # ====================================================================== # Helpers # ====================================================================== def _confirm_discard(self) -> bool: if not self._handler.is_open or not self._handler.is_dirty: return True ret = QMessageBox.question( self, "Unsaved changes", "The current image has unsaved changes. Discard them?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) return ret == QMessageBox.Yes def _ask_continue(self, op: str, what: str, exc: Exception) -> bool: ret = QMessageBox.question( self, op, f"Failed to {op.lower()} '{os.path.basename(what)}':\n{exc}\n\nContinue with the rest?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) return ret == QMessageBox.Yes # ====================================================================== # Close / persist # ====================================================================== def closeEvent(self, event: QCloseEvent) -> None: if not self._confirm_discard(): event.ignore() return self._settings.window_width = self.width() self._settings.window_height = self.height() self._settings.splitter_sizes = self._splitter.sizes() self._settings.save() if self._handler.is_open: self._handler.close() event.accept() class _PaneTabWidget(QTabWidget): """A tab widget hosting a pane; tabs are closable except the last one. The close button on the lone remaining tab is hidden imperatively after each add/remove via :meth:`_refresh_close_button`, deferred to the next event-loop iteration so Qt's internal tab-insert/remove layout finishes first. Overriding the ``QTabBar`` virtuals ``tabInserted`` / ``tabRemoved`` is avoided because it interferes with tab-bar rendering on several Qt styles and leaves the pane blank. """ def __init__(self, label: str): super().__init__() # Keep closable enabled so the close button appears when 2+ tabs # exist. We hide it imperatively when only one tab remains. self.setTabsClosable(True) self.setMovable(True) self.tabCloseRequested.connect(self._on_close) self._label = label def _on_close(self, index: int) -> None: if self.count() <= 1: return self.removeTab(index) # _refresh_close_button is scheduled by removeTab itself. def addTab(self, *args, **kwargs): # type: ignore[override] result = super().addTab(*args, **kwargs) # Defer the close-button refresh to the next event loop # iteration so we don't interfere with Qt's internal tab-insert # layout. from PySide6.QtCore import QTimer QTimer.singleShot(0, self._refresh_close_button) return result def removeTab(self, index: int) -> None: # type: ignore[override] super().removeTab(index) # Schedule the close-button refresh so the lone remaining tab # gets its close button hidden (if count drops to 1). from PySide6.QtCore import QTimer QTimer.singleShot(0, self._refresh_close_button) def _refresh_close_button(self) -> None: """Toggle closable on every tab based on tab count. A single tab shows no close button (a red 'X' with nothing to close reads as an error indicator); two or more tabs each get a close button. ``setTabsClosable`` is used rather than ``setTabButton(..., None)`` because the latter permanently removes the button for a tab index and Qt does not re-create it when a second tab is added. ``setTabsClosable`` atomically creates or removes close buttons on every tab. """ self.setTabsClosable(self.count() > 1) class _TransferColumn(QWidget): """A narrow vertical column of two directional transfer buttons. Two buttons make the data-flow direction unambiguous: * **→ Add** -- selected FS files are added into the ISO. * **← Extract** -- selected ISO entries are extracted back to disk. The column is fixed at 36px wide and stretches to the full splitter height; each button takes half that height so the column always fills the available vertical space without orphaned empty regions. """ # Common stylesheet for both transfer buttons. Each button gets its # own arrow character via setText(); the rest of the styling is # identical so the column reads as a single visual unit. _BUTTON_SS = ( "QToolButton {" " font-size: 22px;" " font-weight: 700;" " border: 1px solid #b8bdc6;" " border-radius: 4px;" " background: #ffffff;" " margin: 2px 1px;" " padding: 0;" "}" "QToolButton:hover {" " background: #eef5f0;" " border-color: #34a96b;" "}" "QToolButton:pressed {" " background: #d4ead9;" "}" "QToolButton:disabled {" " color: #c0c4cc;" " background: #f4f5f7;" " border-color: #e4e7ed;" "}" ) def __init__(self, on_add, on_extract, parent=None): super().__init__(parent) self._on_add_cb = on_add self._on_extract_cb = on_extract # --- Add button (top): FS → ISO ------------------------------- self.add_btn = QToolButton() self.add_btn.setText("→") self.add_btn.setToolTip( "Add (FS → ISO)\n" "Add the selected filesystem files to the ISO image.\n" "Shortcut: Insert" ) self.add_btn.setStyleSheet(self._BUTTON_SS) self.add_btn.clicked.connect(self._on_add_clicked) # --- Extract button (bottom): ISO → FS ------------------------ self.extract_btn = QToolButton() self.extract_btn.setText("←") self.extract_btn.setToolTip( "Extract (ISO → FS)\n" "Extract the selected ISO entries back to disk.\n" "Shortcut: Ctrl+E" ) self.extract_btn.setStyleSheet(self._BUTTON_SS) self.extract_btn.clicked.connect(self._on_extract_clicked) # Stack the two buttons vertically; each gets equal vertical # stretch so the column always fills the available height # without wasted space above/below. layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) layout.addWidget(self.add_btn, 1) layout.addWidget(self.extract_btn, 1) # Lock the column to a narrow width so the two panes flanking it # get the bulk of the splitter's width. self.setFixedWidth(36) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) # ------------------------------------------------------------------ slots def _on_add_clicked(self) -> None: if self._on_add_cb is not None: self._on_add_cb() def _on_extract_clicked(self) -> None: if self._on_extract_cb is not None: self._on_extract_cb() # ------------------------------------------------------------------ state def set_add_enabled(self, enabled: bool) -> None: """Enable/disable the Add button based on app state.""" self.add_btn.setEnabled(enabled) def set_extract_enabled(self, enabled: bool) -> None: """Enable/disable the Extract button based on app state.""" self.extract_btn.setEnabled(enabled) def _human(n: int) -> str: """Format a byte count for the status bar. Delegates to :func:`iso_scalpel.iso_record._human_size` so the CLI, the Properties dialog, and the status bar all report sizes the same way. """ from .iso_record import _human_size return _human_size(n)