import re
from PySide6.QtCore import QRegularExpression, Qt
from PySide6.QtGui import QRegularExpressionValidator
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QMessageBox,
QPushButton,
QScrollArea,
QSpinBox,
QTabWidget,
QVBoxLayout,
QWidget,
)
from pbpicat.config import (
DEFAULT_IMAGE_EXTENSIONS,
DEFAULT_SIDECAR_EXTENSIONS,
DEFAULT_VIDEO_EXTENSIONS,
DEFAULTS,
app_qsettings,
load_global_config,
save_config,
save_global_config,
)
_EXT_RE = re.compile(r"^(\.[a-zA-Z0-9]+)+$")
class _SchemaTab(QWidget):
def __init__(self, config: dict, parent=None):
super().__init__(parent)
self._config = config
self._title_edits: list[QLineEdit] = []
self._setup_ui()
def _setup_ui(self) -> None:
root = QVBoxLayout(self)
root.setSpacing(10)
count_row = QHBoxLayout()
count_row.addWidget(QLabel(_("Number of fields:")))
self._count_spin = QSpinBox()
self._count_spin.setRange(1, 12)
self._count_spin.setValue(self._config.get("schema_field_count", 6))
self._count_spin.setFixedWidth(60)
count_row.addWidget(self._count_spin)
count_row.addStretch()
root.addLayout(count_row)
history_row = QHBoxLayout()
history_row.addWidget(QLabel(_("Max history entries:")))
self._history_spin = QSpinBox()
self._history_spin.setRange(1, 200)
self._history_spin.setValue(self._config.get("history_max", DEFAULTS["history_max"]))
self._history_spin.setToolTip(_("Maximum number of values kept per field history and sidecar filter history"))
self._history_spin.setFixedWidth(60)
history_row.addWidget(self._history_spin)
history_row.addStretch()
root.addLayout(history_row)
del_list_row = QHBoxLayout()
del_list_row.addWidget(QLabel(_("Max files listed in deletion confirmation:")))
self._del_list_spin = QSpinBox()
self._del_list_spin.setRange(1, 999)
self._del_list_spin.setValue(self._config.get("delete_list_max_files", DEFAULTS["delete_list_max_files"]))
self._del_list_spin.setToolTip(
_("When deleting more files than this threshold, show the count instead of listing each file name")
)
self._del_list_spin.setFixedWidth(60)
del_list_row.addWidget(self._del_list_spin)
del_list_row.addStretch()
root.addLayout(del_list_row)
root.addWidget(QLabel(_("<b>Field titles:</b>")))
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.StyledPanel)
self._titles_container = QWidget()
self._titles_layout = QFormLayout(self._titles_container)
self._titles_layout.setSpacing(6)
scroll.setWidget(self._titles_container)
root.addWidget(scroll, stretch=1)
self._rebuild_rows(self._count_spin.value())
self._count_spin.valueChanged.connect(self._rebuild_rows)
def _rebuild_rows(self, count: int) -> None:
current = [e.text() for e in self._title_edits]
saved: list[str] = self._config.get("schema_field_titles", [])
while self._titles_layout.rowCount():
self._titles_layout.removeRow(0)
self._title_edits.clear()
for i in range(count):
text = current[i] if i < len(current) else (saved[i] if i < len(saved) else _("Field {n}").format(n=i + 1))
edit = QLineEdit(text)
edit.setPlaceholderText(_("Field {n}").format(n=i + 1))
self._titles_layout.addRow(_("Field {n}:").format(n=i + 1), edit)
self._title_edits.append(edit)
def apply_to(self, config: dict) -> None:
count = self._count_spin.value()
titles = [e.text().strip() or _("Field {n}").format(n=i + 1) for i, e in enumerate(self._title_edits)]
config["schema_field_count"] = count
config["schema_field_titles"] = titles
config["history_max"] = self._history_spin.value()
config["delete_list_max_files"] = self._del_list_spin.value()
class _SidecarTab(QWidget):
def __init__(self, config: dict, parent=None):
super().__init__(parent)
self._config = config
self._setup_ui()
def _setup_ui(self) -> None:
root = QVBoxLayout(self)
root.setSpacing(8)
root.addWidget(QLabel(_("Sidecar file extensions (e.g. .xmp, .prompt.txt):")))
self._list = QListWidget()
for ext in self._config.get("sidecar_extensions", []):
self._list.addItem(ext)
root.addWidget(self._list, stretch=1)
add_row = QHBoxLayout()
self._ext_edit = QLineEdit()
self._ext_edit.setPlaceholderText(_(".ext or .name.ext"))
self._ext_edit.setMaximumWidth(120)
self._ext_edit.returnPressed.connect(self._add_ext)
add_row.addWidget(self._ext_edit)
add_btn = QPushButton(_("Add"))
add_btn.setFixedWidth(90)
add_btn.clicked.connect(self._add_ext)
add_row.addWidget(add_btn)
add_row.addStretch()
del_btn = QPushButton(_("Delete"))
del_btn.setFixedWidth(90)
del_btn.clicked.connect(self._del_ext)
add_row.addWidget(del_btn)
root.addLayout(add_row)
restore_btn = QPushButton(_("Restore missing defaults"))
restore_btn.setToolTip(_("Add extensions from the global default list that are not yet in the list above"))
restore_btn.clicked.connect(self._restore_missing_defaults)
root.addWidget(restore_btn)
self._delete_empty_cb = QCheckBox(_("Delete empty sidecar files when loading a directory"))
self._delete_empty_cb.setToolTip(
_("Automatically delete zero-byte sidecar files (including orphans) when entering a directory")
)
self._delete_empty_cb.setChecked(self._config.get("delete_empty_sidecars", True))
root.addWidget(self._delete_empty_cb)
form = QFormLayout()
form.setSpacing(8)
self._new_ext_combo = QComboBox()
self._new_ext_combo.setToolTip(
_("Extension used when creating a new sidecar by double-clicking the sidecar column")
)
form.addRow(_("Default extension for new sidecar:"), self._new_ext_combo)
root.addLayout(form)
self._rebuild_new_ext_combo()
def _rebuild_new_ext_combo(self) -> None:
current = self._new_ext_combo.currentText()
self._new_ext_combo.clear()
for i in range(self._list.count()):
self._new_ext_combo.addItem(self._list.item(i).text())
saved = self._config.get("sidecar_new_extension", "")
target = current or saved
idx = self._new_ext_combo.findText(target)
if idx >= 0:
self._new_ext_combo.setCurrentIndex(idx)
def _add_ext(self) -> None:
raw = self._ext_edit.text().strip().lower()
if not raw.startswith("."):
raw = "." + raw
if not _EXT_RE.match(raw):
QMessageBox.warning(
self,
_("Invalid extension"),
_("'{ext}' is not a valid extension (e.g. .xmp, .prompt.txt).").format(ext=raw),
)
return
if self._list.findItems(raw, Qt.MatchExactly):
self._ext_edit.clear()
return
self._list.addItem(raw)
self._ext_edit.clear()
self._rebuild_new_ext_combo()
def _del_ext(self) -> None:
row = self._list.currentRow()
if row >= 0:
self._list.takeItem(row)
self._rebuild_new_ext_combo()
def _restore_missing_defaults(self) -> None:
defaults = load_global_config()["default_sidecar_extensions"]
current = {self._list.item(i).text() for i in range(self._list.count())}
added = 0
for ext in defaults:
if ext not in current:
self._list.addItem(ext)
added += 1
if added:
self._rebuild_new_ext_combo()
else:
QMessageBox.information(self, _("Restore defaults"), _("All default extensions are already in the list."))
def apply_to(self, config: dict) -> None:
config["sidecar_extensions"] = [self._list.item(i).text() for i in range(self._list.count())]
config["sidecar_new_extension"] = self._new_ext_combo.currentText()
config["delete_empty_sidecars"] = self._delete_empty_cb.isChecked()
class _ThumbnailTab(QWidget):
def __init__(self, config: dict, parent=None):
super().__init__(parent)
self._config = config
self._setup_ui()
def _setup_ui(self) -> None:
root = QVBoxLayout(self)
root.setSpacing(10)
root.addWidget(
QLabel(
_(
"Maximum size of thumbnails displayed in the file list."
"\nA navigation restart is required for the change to take effect."
)
)
)
form = QFormLayout()
form.setSpacing(8)
legacy = self._config.get("thumbnail_size", 128)
self._w_spin = QSpinBox()
self._w_spin.setRange(32, 512)
self._w_spin.setSuffix(" px")
self._w_spin.setValue(self._config.get("thumbnail_max_width", legacy))
form.addRow(_("Max width:"), self._w_spin)
self._h_spin = QSpinBox()
self._h_spin.setRange(32, 512)
self._h_spin.setSuffix(" px")
self._h_spin.setValue(self._config.get("thumbnail_max_height", legacy))
form.addRow(_("Max height:"), self._h_spin)
root.addLayout(form)
root.addStretch()
def apply_to(self, config: dict) -> None:
config["thumbnail_max_width"] = self._w_spin.value()
config["thumbnail_max_height"] = self._h_spin.value()
class _ImageTab(QWidget):
def __init__(self, config: dict, parent=None):
super().__init__(parent)
self._config = config
self._setup_ui()
def _setup_ui(self) -> None:
root = QVBoxLayout(self)
root.setSpacing(8)
root.addWidget(QLabel(_("Recognised image extensions (thumbnail + rename):")))
self._list = QListWidget()
for ext in self._config.get("image_extensions", []):
self._list.addItem(ext)
root.addWidget(self._list, stretch=1)
add_row = QHBoxLayout()
self._ext_edit = QLineEdit()
self._ext_edit.setPlaceholderText(".ext")
self._ext_edit.setMaximumWidth(120)
self._ext_edit.returnPressed.connect(self._add_ext)
add_row.addWidget(self._ext_edit)
add_btn = QPushButton(_("Add"))
add_btn.setFixedWidth(90)
add_btn.clicked.connect(self._add_ext)
add_row.addWidget(add_btn)
add_row.addStretch()
del_btn = QPushButton(_("Delete"))
del_btn.setFixedWidth(90)
del_btn.clicked.connect(self._del_ext)
add_row.addWidget(del_btn)
root.addLayout(add_row)
restore_btn = QPushButton(_("Restore missing defaults"))
restore_btn.setToolTip(_("Add extensions from the built-in default list that are not yet in the list above"))
restore_btn.clicked.connect(self._restore_missing_defaults)
root.addWidget(restore_btn)
form = QFormLayout()
form.setSpacing(8)
self._step_spin = QSpinBox()
self._step_spin.setRange(1, 200)
self._step_spin.setSuffix(" %")
self._step_spin.setToolTip(_("Enlargement/reduction factor per click or double-click"))
self._step_spin.setValue(self._config.get("zoom_step_percent", DEFAULTS["zoom_step_percent"]))
form.addRow(_("Zoom step:"), self._step_spin)
self._max_spin = QSpinBox()
self._max_spin.setRange(100, 25600)
self._max_spin.setSingleStep(100)
self._max_spin.setSuffix(" %")
self._max_spin.setToolTip(_("Maximum zoom allowed in the image viewer"))
self._max_spin.setValue(self._config.get("zoom_max_percent", DEFAULTS["zoom_max_percent"]))
form.addRow(_("Max zoom:"), self._max_spin)
root.addLayout(form)
self._confirm_deletions_cb = QCheckBox(_("Confirm deletions"))
self._confirm_deletions_cb.setToolTip(_("Ask for confirmation before permanently deleting files"))
self._confirm_deletions_cb.setChecked(self._config.get("confirm_deletions", True))
root.addWidget(self._confirm_deletions_cb)
self._exif_auto_rotate_cb = QCheckBox(_("Apply EXIF rotation"))
self._exif_auto_rotate_cb.setToolTip(
_("Automatically rotate thumbnails and viewer according to the image EXIF orientation tag")
)
self._exif_auto_rotate_cb.setChecked(self._config.get("exif_auto_rotate", True))
root.addWidget(self._exif_auto_rotate_cb)
def _add_ext(self) -> None:
raw = self._ext_edit.text().strip().lower()
if not raw.startswith("."):
raw = "." + raw
if not _EXT_RE.match(raw):
QMessageBox.warning(
self,
_("Invalid extension"),
_("'{ext}' is not a valid extension (e.g. .png, .heic).").format(ext=raw),
)
return
if self._list.findItems(raw, Qt.MatchExactly):
self._ext_edit.clear()
return
self._list.addItem(raw)
self._ext_edit.clear()
def _del_ext(self) -> None:
row = self._list.currentRow()
if row >= 0:
self._list.takeItem(row)
def _restore_missing_defaults(self) -> None:
current = {self._list.item(i).text() for i in range(self._list.count())}
added = 0
for ext in DEFAULT_IMAGE_EXTENSIONS:
if ext not in current:
self._list.addItem(ext)
added += 1
if added == 0:
QMessageBox.information(self, _("Restore defaults"), _("All default extensions are already in the list."))
def apply_to(self, config: dict) -> None:
config["image_extensions"] = [self._list.item(i).text() for i in range(self._list.count())]
config["zoom_step_percent"] = self._step_spin.value()
config["zoom_max_percent"] = self._max_spin.value()
config["confirm_deletions"] = self._confirm_deletions_cb.isChecked()
config["exif_auto_rotate"] = self._exif_auto_rotate_cb.isChecked()
class _BehaviourTab(QWidget):
def __init__(self, config: dict, parent=None):
super().__init__(parent)
self._config = config
self._setup_ui()
def _setup_ui(self) -> None:
root = QVBoxLayout(self)
root.setSpacing(8)
self._use_trash_cb = QCheckBox(_("Move deleted files to trash"))
self._use_trash_cb.setToolTip(_("Send files to the system trash instead of deleting them permanently"))
self._use_trash_cb.setChecked(self._config.get("use_trash", True))
root.addWidget(self._use_trash_cb)
root.addStretch()
def apply_to(self, config: dict) -> None:
config["use_trash"] = self._use_trash_cb.isChecked()
class _VideoTab(QWidget):
def __init__(self, config: dict, parent=None):
super().__init__(parent)
self._config = config
self._setup_ui()
def _setup_ui(self) -> None:
root = QVBoxLayout(self)
root.setSpacing(10)
root.addWidget(QLabel(_("Recognised video extensions:")))
self._list = QListWidget()
for ext in self._config.get("video_extensions", []):
self._list.addItem(ext)
root.addWidget(self._list, stretch=1)
add_row = QHBoxLayout()
self._ext_edit = QLineEdit()
self._ext_edit.setPlaceholderText(".ext")
self._ext_edit.setMaximumWidth(120)
self._ext_edit.returnPressed.connect(self._add_ext)
add_row.addWidget(self._ext_edit)
add_btn = QPushButton(_("Add"))
add_btn.setFixedWidth(90)
add_btn.clicked.connect(self._add_ext)
add_row.addWidget(add_btn)
add_row.addStretch()
del_btn = QPushButton(_("Delete"))
del_btn.setFixedWidth(90)
del_btn.clicked.connect(self._del_ext)
add_row.addWidget(del_btn)
root.addLayout(add_row)
restore_btn = QPushButton(_("Restore missing defaults"))
restore_btn.setToolTip(_("Add extensions from the built-in default list that are not yet in the list above"))
restore_btn.clicked.connect(self._restore_missing_defaults)
root.addWidget(restore_btn)
root.addWidget(
QLabel(
_(
"<b>Video marker</b><br>Inserted among the name components at the position chosen in the schema."
"<br>No '_' in the value. Empty = no marker."
)
)
)
validator = QRegularExpressionValidator(QRegularExpression(r"[^_.]*"))
form = QFormLayout()
form.setSpacing(8)
self._marker_edit = QLineEdit(self._config.get("video_marker", ""))
self._marker_edit.setPlaceholderText(_("e.g. VID (empty = none)"))
self._marker_edit.setValidator(validator)
form.addRow(_("Marker:"), self._marker_edit)
root.addLayout(form)
def _add_ext(self) -> None:
raw = self._ext_edit.text().strip().lower()
if not raw.startswith("."):
raw = "." + raw
if not _EXT_RE.match(raw):
QMessageBox.warning(
self,
_("Invalid extension"),
_("'{ext}' is not a valid extension (e.g. .mp4).").format(ext=raw),
)
return
if self._list.findItems(raw, Qt.MatchExactly):
self._ext_edit.clear()
return
self._list.addItem(raw)
self._ext_edit.clear()
def _del_ext(self) -> None:
row = self._list.currentRow()
if row >= 0:
self._list.takeItem(row)
def _restore_missing_defaults(self) -> None:
current = {self._list.item(i).text() for i in range(self._list.count())}
added = 0
for ext in DEFAULT_VIDEO_EXTENSIONS:
if ext not in current:
self._list.addItem(ext)
added += 1
if added == 0:
QMessageBox.information(self, _("Restore defaults"), _("All default extensions are already in the list."))
def apply_to(self, config: dict) -> None:
config["video_extensions"] = [self._list.item(i).text() for i in range(self._list.count())]
config["video_marker"] = self._marker_edit.text().strip()
class _LanguageTab(QWidget):
def __init__(self, config: dict, parent=None):
super().__init__(parent)
self._config = config
self._setup_ui()
def _setup_ui(self) -> None:
from pbpicat.i18n import available_languages
root = QVBoxLayout(self)
root.setSpacing(10)
form = QFormLayout()
form.setSpacing(8)
self._lang_combo = QComboBox()
self._lang_combo.addItem(_("System default"), "")
for code, name in available_languages():
self._lang_combo.addItem(name, code)
current = self._config.get("language", "")
idx = self._lang_combo.findData(current)
if idx >= 0:
self._lang_combo.setCurrentIndex(idx)
form.addRow(_("Interface language:"), self._lang_combo)
root.addLayout(form)
root.addWidget(QLabel(_("A restart is required for the language change to take effect.")))
root.addStretch()
def apply_to(self, config: dict) -> None:
config["language"] = self._lang_combo.currentData()
[docs]
class SettingsDialog(QDialog):
def __init__(self, config: dict, parent=None):
super().__init__(parent)
self.setWindowTitle(_("Configuration"))
self.setMinimumSize(460, 420)
self._config = dict(config)
self._setup_ui()
geom = app_qsettings().value("settings_dialog/geometry")
if geom:
self.restoreGeometry(geom)
[docs]
def done(self, result) -> None: # noqa: N802
app_qsettings().setValue("settings_dialog/geometry", self.saveGeometry())
super().done(result)
def _setup_ui(self) -> None:
root = QVBoxLayout(self)
tabs = QTabWidget()
self._schema_tab = _SchemaTab(self._config)
self._sidecar_tab = _SidecarTab(self._config)
self._image_tab = _ImageTab(self._config)
self._thumb_tab = _ThumbnailTab(self._config)
self._video_tab = _VideoTab(self._config)
self._behaviour_tab = _BehaviourTab(self._config)
tabs.addTab(self._schema_tab, _("Rename schema"))
tabs.addTab(self._sidecar_tab, _("Sidecar extensions"))
tabs.addTab(self._image_tab, _("Images"))
tabs.addTab(self._video_tab, _("Video"))
tabs.addTab(self._thumb_tab, _("Thumbnails"))
tabs.addTab(self._behaviour_tab, _("Behaviour"))
root.addWidget(tabs)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._accept)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
def _accept(self) -> None:
self._schema_tab.apply_to(self._config)
self._sidecar_tab.apply_to(self._config)
self._image_tab.apply_to(self._config)
self._video_tab.apply_to(self._config)
self._thumb_tab.apply_to(self._config)
self._behaviour_tab.apply_to(self._config)
save_config(self._config)
self.accept()
[docs]
def updated_config(self) -> dict:
return self._config
[docs]
class GlobalSettingsDialog(QDialog):
"""Program-level settings (not per-catalog)."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle(_("Program settings"))
self.setMinimumSize(460, 380)
self._global_config = load_global_config()
self._setup_ui()
geom = app_qsettings().value("global_settings_dialog/geometry")
if geom:
self.restoreGeometry(geom)
[docs]
def done(self, result) -> None: # noqa: N802
app_qsettings().setValue("global_settings_dialog/geometry", self.saveGeometry())
super().done(result)
def _setup_ui(self) -> None:
root = QVBoxLayout(self)
tabs = QTabWidget()
tabs.addTab(self._build_sidecar_tab(), _("Sidecar extensions"))
tabs.addTab(self._build_language_tab(), _("Language"))
root.addWidget(tabs)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._accept)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
def _build_sidecar_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.setSpacing(8)
layout.addWidget(QLabel(_("<b>Default sidecar extensions for new catalogs</b>")))
layout.addWidget(QLabel(_("(e.g. .xmp, .dop, .prompt.txt)")))
self._list = QListWidget()
for ext in self._global_config.get("default_sidecar_extensions", []):
self._list.addItem(ext)
layout.addWidget(self._list, stretch=1)
add_row = QHBoxLayout()
self._ext_edit = QLineEdit()
self._ext_edit.setPlaceholderText(_(".ext or .name.ext"))
self._ext_edit.setMaximumWidth(140)
self._ext_edit.returnPressed.connect(self._add_ext)
add_row.addWidget(self._ext_edit)
add_btn = QPushButton(_("Add"))
add_btn.setFixedWidth(90)
add_btn.clicked.connect(self._add_ext)
add_row.addWidget(add_btn)
add_row.addStretch()
del_btn = QPushButton(_("Delete"))
del_btn.setFixedWidth(90)
del_btn.clicked.connect(self._del_ext)
add_row.addWidget(del_btn)
layout.addLayout(add_row)
restore_btn = QPushButton(_("Restore built-in defaults"))
restore_btn.setToolTip(_("Reset the list to the built-in default sidecar extensions"))
restore_btn.clicked.connect(self._restore_defaults)
layout.addWidget(restore_btn)
return w
def _build_language_tab(self) -> QWidget:
from pbpicat.i18n import available_languages
w = QWidget()
layout = QVBoxLayout(w)
layout.setSpacing(10)
form = QFormLayout()
form.setSpacing(8)
self._lang_combo = QComboBox()
self._lang_combo.addItem(_("System default"), "")
for code, name in available_languages():
self._lang_combo.addItem(name, code)
current = self._global_config.get("language", "")
idx = self._lang_combo.findData(current)
if idx >= 0:
self._lang_combo.setCurrentIndex(idx)
form.addRow(_("Interface language:"), self._lang_combo)
layout.addLayout(form)
layout.addWidget(QLabel(_("A restart is required for the language change to take effect.")))
layout.addStretch()
return w
def _add_ext(self) -> None:
raw = self._ext_edit.text().strip().lower()
if not raw.startswith("."):
raw = "." + raw
if not _EXT_RE.match(raw):
QMessageBox.warning(
self,
_("Invalid extension"),
_("'{ext}' is not a valid extension (e.g. .xmp, .prompt.txt).").format(ext=raw),
)
return
if self._list.findItems(raw, Qt.MatchExactly):
self._ext_edit.clear()
return
self._list.addItem(raw)
self._ext_edit.clear()
def _del_ext(self) -> None:
row = self._list.currentRow()
if row >= 0:
self._list.takeItem(row)
def _restore_defaults(self) -> None:
self._list.clear()
for ext in DEFAULT_SIDECAR_EXTENSIONS:
self._list.addItem(ext)
def _accept(self) -> None:
self._global_config["default_sidecar_extensions"] = [
self._list.item(i).text() for i in range(self._list.count())
]
self._global_config["language"] = self._lang_combo.currentData()
save_global_config(self._global_config)
self.accept()
[docs]
def updated_global_config(self) -> dict:
return self._global_config