1116 lines
46 KiB
Python
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)
|