iso-scalpel/iso_scalpel/main_window.py

1116 lines
46 KiB
Python

"""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 <info@dcos.net>
#
# 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 <https://www.gnu.org/licenses/>
# 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)