1
0
mirror of https://github.com/arsenetar/dupeguru.git synced 2026-03-12 03:31:37 +00:00

Compare commits

...

13 Commits

Author SHA1 Message Date
2f23f34b91 More updates mainly in preferences 2022-07-08 23:15:59 -05:00
ec35c2df8f Next batch of qt6 upgrades 2022-07-08 22:17:42 -05:00
4d56bd3515 More updates for pyqt6 2022-07-08 21:01:24 -05:00
66e69a3854 Start PyQt5 -> PyQt6 migration 2022-07-08 21:01:13 -05:00
1f1dfa88dc Update version & changelog for 4.3.1 release 2022-07-07 22:06:06 -05:00
916c5204cf Update translations from transifex 2022-07-07 21:57:59 -05:00
71af825b37 Move try/except of cache db to get() and put()
- Move the try/except of cache db calls to the calls themselves.
- Add some additional information to logging statements on cache db
  exception to improve troubleshooting.
2022-07-07 21:52:22 -05:00
97f490b8b7 Fix typo in engine.py 2022-07-07 19:06:35 -05:00
d369bcddd7 Updates from investigation of #1015
- Add protection for empty hash digests in comparison of non-zero size
  files
- Bump version to 4.3.1-dev for identification
2022-07-07 19:00:09 -05:00
360dceca7b Update to version 4.3.0, update changelog 2022-06-30 23:27:14 -05:00
92b27801c3 Update translations, remove iphoto_plist.py 2022-06-30 23:03:40 -05:00
Marcus Yanello
b9aabb8545 Redirect stdout from custom command to the log files (#1008)
Send the logs for the custom command subprocess to the logs
Closes #1007
2022-06-13 21:04:40 -05:00
d5eeab4a17 Additional type hints in hscommon 2022-05-11 00:50:34 -05:00
40 changed files with 603 additions and 555 deletions

View File

@@ -40,7 +40,7 @@ jobs:
name: Build Cpp name: Build Cpp
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install python3-pyqt5 sudo apt-get install python3-pyqt6
make modules make modules
- if: matrix.language == 'python' - if: matrix.language == 'python'
name: Autobuild name: Autobuild

View File

@@ -60,8 +60,8 @@ ifndef NO_VENV
@${PYTHON} -m venv -h > /dev/null || \ @${PYTHON} -m venv -h > /dev/null || \
echo "Creation of our virtualenv failed. If you're on Ubuntu, you probably need python3-venv." echo "Creation of our virtualenv failed. If you're on Ubuntu, you probably need python3-venv."
endif endif
@${PYTHON} -c 'import PyQt5' >/dev/null 2>&1 || \ @${PYTHON} -c 'import PyQt6' >/dev/null 2>&1 || \
{ echo "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; } { echo "PyQt 6.3+ required. Install it and try again. Aborting"; exit 1; }
env: | reqs env: | reqs
ifndef NO_VENV ifndef NO_VENV

View File

@@ -32,18 +32,15 @@ For macos instructions (qt version) see the [macOS Instructions](macos.md).
### Prerequisites ### Prerequisites
* [Python 3.7+][python] * [Python 3.7+][python]
* PyQt5 * PyQt6
### System Setup ### System Setup
When running in a linux based environment the following system packages or equivalents are needed to build: When running in a linux based environment the following system packages or equivalents are needed to build:
* python3-pyqt5 * python3-pyqt6
* pyqt5-dev-tools (on some systems, see note)
* python3-venv (only if using a virtual environment) * python3-venv (only if using a virtual environment)
* python3-dev * python3-dev
* build-essential * build-essential
Note: On some linux systems pyrcc5 is not put on the path when installing python3-pyqt5, this will cause some issues with the resource files (and icons). These systems should have a respective pyqt5-dev-tools package, which should also be installed. The presence of pyrcc5 can be checked with `which pyrcc5`. Debian based systems need the extra package, and Arch does not.
To create packages the following are also needed: To create packages the following are also needed:
* python3-setuptools * python3-setuptools
* debhelper * debhelper

View File

@@ -28,7 +28,7 @@ To build with a different python version 3.7 vs 3.8 or 32 bit vs 64 bit specify
### With makefile ### With makefile
It is possible to build dupeGuru with the makefile on windows using a compatable POSIX environment. The following steps have been tested using [msys2][msys2]. Before running make: It is possible to build dupeGuru with the makefile on windows using a compatable POSIX environment. The following steps have been tested using [msys2][msys2]. Before running make:
1. Install msys2 or other POSIX environment 1. Install msys2 or other POSIX environment
2. Install PyQt5 globally via pip 2. Install PyQt6 globally via pip
3. Use the respective console for msys2 it is `msys2 msys` 3. Use the respective console for msys2 it is `msys2 msys`
Then the following execution of the makefile should work. Pass the correct value for PYTHON to the makefile if not on the path as python3. Then the following execution of the makefile should work. Pass the correct value for PYTHON to the makefile if not on the path as python3.

View File

@@ -1,2 +1,2 @@
__version__ = "4.2.1" __version__ = "4.3.1"
__appname__ = "dupeGuru" __appname__ = "dupeGuru"

View File

@@ -555,9 +555,15 @@ class DupeGuru(Broadcaster):
# a workaround to make the damn thing work. # a workaround to make the damn thing work.
exepath, args = match.groups() exepath, args = match.groups()
path, exename = op.split(exepath) path, exename = op.split(exepath)
subprocess.Popen(exename + args, shell=True, cwd=path) p = subprocess.Popen(
exename + args, shell=True, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
output = p.stdout.read()
logging.info("Custom command %s %s: %s", exename, args, output)
else: else:
subprocess.Popen(dupe_cmd, shell=True) p = subprocess.Popen(dupe_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
output = p.stdout.read()
logging.info("Custom command %s: %s", dupe_cmd, output)
def load(self): def load(self):
"""Load directory selection and ignore list from files in appdata. """Load directory selection and ignore list from files in appdata.

View File

@@ -303,12 +303,13 @@ def getmatches_by_contents(files, bigsize=0, j=job.nulljob):
# skip hashing for zero length files # skip hashing for zero length files
result.append(Match(first, second, 100)) result.append(Match(first, second, 100))
continue continue
if first.digest_partial == second.digest_partial: # if digests are the same (and not None) then files match
if first.digest_partial == second.digest_partial and first.digest_partial is not None:
if bigsize > 0 and first.size > bigsize: if bigsize > 0 and first.size > bigsize:
if first.digest_samples == second.digest_samples: if first.digest_samples == second.digest_samples and first.digest_samples is not None:
result.append(Match(first, second, 100)) result.append(Match(first, second, 100))
else: else:
if first.digest == second.digest: if first.digest == second.digest and first.digest is not None:
result.append(Match(first, second, 100)) result.append(Match(first, second, 100))
group_count += 1 group_count += 1
j.add_progress(desc=PROGRESS_MESSAGE % (len(result), group_count)) j.add_progress(desc=PROGRESS_MESSAGE % (len(result), group_count))

View File

@@ -144,13 +144,17 @@ class FilesDB:
stat = path.stat() stat = path.stat()
size = stat.st_size size = stat.st_size
mtime_ns = stat.st_mtime_ns mtime_ns = stat.st_mtime_ns
try:
with self.lock:
self.cur.execute(
self.select_query.format(key=key), {"path": str(path), "size": size, "mtime_ns": mtime_ns}
)
result = self.cur.fetchone()
with self.lock: if result:
self.cur.execute(self.select_query.format(key=key), {"path": str(path), "size": size, "mtime_ns": mtime_ns}) return result[0]
result = self.cur.fetchone() except Exception as ex:
logging.warning(f"Couldn't get {key} for {path} w/{size}, {mtime_ns}: {ex}")
if result:
return result[0]
return None return None
@@ -158,12 +162,14 @@ class FilesDB:
stat = path.stat() stat = path.stat()
size = stat.st_size size = stat.st_size
mtime_ns = stat.st_mtime_ns mtime_ns = stat.st_mtime_ns
try:
with self.lock: with self.lock:
self.cur.execute( self.cur.execute(
self.insert_query.format(key=key), self.insert_query.format(key=key),
{"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value}, {"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value},
) )
except Exception as ex:
logging.warning(f"Couldn't put {key} for {path} w/{size}, {mtime_ns}: {ex}")
def commit(self) -> None: def commit(self) -> None:
with self.lock: with self.lock:
@@ -265,34 +271,25 @@ class File:
self.size = nonone(stats.st_size, 0) self.size = nonone(stats.st_size, 0)
self.mtime = nonone(stats.st_mtime, 0) self.mtime = nonone(stats.st_mtime, 0)
elif field == "digest_partial": elif field == "digest_partial":
try: self.digest_partial = filesdb.get(self.path, "digest_partial")
self.digest_partial = filesdb.get(self.path, "digest_partial") if self.digest_partial is None:
if self.digest_partial is None: self.digest_partial = self._calc_digest_partial()
self.digest_partial = self._calc_digest_partial() filesdb.put(self.path, "digest_partial", self.digest_partial)
filesdb.put(self.path, "digest_partial", self.digest_partial)
except Exception as e:
logging.warning("Couldn't get digest_partial for %s: %s", self.path, e)
elif field == "digest": elif field == "digest":
try: self.digest = filesdb.get(self.path, "digest")
self.digest = filesdb.get(self.path, "digest") if self.digest is None:
if self.digest is None: self.digest = self._calc_digest()
self.digest = self._calc_digest() filesdb.put(self.path, "digest", self.digest)
filesdb.put(self.path, "digest", self.digest)
except Exception as e:
logging.warning("Couldn't get digest for %s: %s", self.path, e)
elif field == "digest_samples": elif field == "digest_samples":
size = self.size size = self.size
# Might as well hash such small files entirely. # Might as well hash such small files entirely.
if size <= MIN_FILE_SIZE: if size <= MIN_FILE_SIZE:
setattr(self, field, self.digest) setattr(self, field, self.digest)
return return
try: self.digest_samples = filesdb.get(self.path, "digest_samples")
self.digest_samples = filesdb.get(self.path, "digest_samples") if self.digest_samples is None:
if self.digest_samples is None: self.digest_samples = self._calc_digest_samples()
self.digest_samples = self._calc_digest_samples() filesdb.put(self.path, "digest_samples", self.digest_samples)
filesdb.put(self.path, "digest_samples", self.digest_samples)
except Exception as e:
logging.warning(f"Couldn't get digest_samples for {self.path}: {e}")
def _read_all_info(self, attrnames=None): def _read_all_info(self, attrnames=None):
"""Cache all possible info. """Cache all possible info.

View File

@@ -1,33 +0,0 @@
# Created By: Virgil Dupras
# Created On: 2014-03-15
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import plistlib
class IPhotoPlistParser(plistlib._PlistParser):
"""A parser for iPhoto plists.
iPhoto plists tend to be malformed, so we have to subclass the built-in parser to be a bit more
lenient.
"""
def __init__(self):
plistlib._PlistParser.__init__(self, use_builtin_types=True, dict_type=dict)
# For debugging purposes, we remember the last bit of data to be analyzed so that we can
# log it in case of an exception
self.lastdata = ""
def get_data(self):
self.lastdata = plistlib._PlistParser.get_data(self)
return self.lastdata
def end_integer(self):
try:
self.add_object(int(self.get_data()))
except ValueError:
self.add_object(0)

View File

@@ -1,3 +1,21 @@
=== 4.3.1 (2022-07-08)
* Fix issue where cache db exceptions could prevent files being hashed (#1015)
* Add extra guard for non-zero length files without digests to prevent false duplicates
* Update Italian translations
=== 4.3.0 (2022-07-01)
* Redirect stdout from custom command to the log files (#1008)
* Update translations
* Fix typo in debian control file (#989)
* Add option to profile scans
* Update fs.py to optimize stat() calls
* Fix Error when delete after scan (#988)
* Update directory scanning to use os.scandir() and DirEntry objects
* Improve performance of Directories.get_state()
* Migrate from hscommon.path to pathlib
* Switch file hashing to xxhash with fallback to md5
* Add update check feature to about box
=== 4.2.1 (2022-03-25) === 4.2.1 (2022-03-25)
* Default to English on unsupported system language (#976) * Default to English on unsupported system language (#976)
* Fix image viewer zoom datatype issue (#978) * Fix image viewer zoom datatype issue (#978)

View File

@@ -44,8 +44,8 @@ def special_folder_path(special_folder: SpecialFolder, portable: bool = False) -
try: try:
from PyQt5.QtCore import QUrl, QStandardPaths from PyQt6.QtCore import QUrl, QStandardPaths
from PyQt5.QtGui import QDesktopServices from PyQt6.QtGui import QDesktopServices
from qt.util import get_appdata from qt.util import get_appdata
from core.util import executable_folder from core.util import executable_folder
from hscommon.plat import ISWINDOWS, ISOSX from hscommon.plat import ISWINDOWS, ISOSX
@@ -71,7 +71,7 @@ try:
if ISWINDOWS and portable: if ISWINDOWS and portable:
folder = op.join(executable_folder(), "cache") folder = op.join(executable_folder(), "cache")
else: else:
folder = QStandardPaths.standardLocations(QStandardPaths.CacheLocation)[0] folder = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.CacheLocation)[0]
else: else:
folder = get_appdata(portable) folder = get_appdata(portable)
return folder return folder

View File

@@ -36,11 +36,11 @@ class GUIObject:
``multibind`` flag to ``True`` and the safeguard will be disabled. ``multibind`` flag to ``True`` and the safeguard will be disabled.
""" """
def __init__(self, multibind=False): def __init__(self, multibind: bool = False) -> None:
self._view = None self._view = None
self._multibind = multibind self._multibind = multibind
def _view_updated(self): def _view_updated(self) -> None:
"""(Virtual) Called after :attr:`view` has been set. """(Virtual) Called after :attr:`view` has been set.
Doing nothing by default, this method is called after :attr:`view` has been set (it isn't Doing nothing by default, this method is called after :attr:`view` has been set (it isn't
@@ -48,7 +48,7 @@ class GUIObject:
(which is often the whole of the initialization code). (which is often the whole of the initialization code).
""" """
def has_view(self): def has_view(self) -> bool:
return (self._view is not None) and (not isinstance(self._view, NoopGUI)) return (self._view is not None) and (not isinstance(self._view, NoopGUI))
@property @property
@@ -67,7 +67,7 @@ class GUIObject:
return self._view return self._view
@view.setter @view.setter
def view(self, value): def view(self, value) -> None:
if self._view is None and value is None: if self._view is None and value is None:
# Initial view assignment # Initial view assignment
return return

View File

@@ -7,8 +7,10 @@
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
import copy import copy
from typing import Any, List, Tuple, Union
from hscommon.gui.base import GUIObject from hscommon.gui.base import GUIObject
from hscommon.gui.table import GUITable
class Column: class Column:
@@ -17,7 +19,7 @@ class Column:
These attributes are then used to correctly configure the column on the "view" side. These attributes are then used to correctly configure the column on the "view" side.
""" """
def __init__(self, name, display="", visible=True, optional=False): def __init__(self, name: str, display: str = "", visible: bool = True, optional: bool = False) -> None:
#: "programmatical" (not for display) name. Used as a reference in a couple of place, such #: "programmatical" (not for display) name. Used as a reference in a couple of place, such
#: as :meth:`Columns.column_by_name`. #: as :meth:`Columns.column_by_name`.
self.name = name self.name = name
@@ -52,14 +54,14 @@ class ColumnsView:
callbacks. callbacks.
""" """
def restore_columns(self): def restore_columns(self) -> None:
"""Update all columns according to the model. """Update all columns according to the model.
When this is called, our view has to update the columns title, order and visibility of all When this is called, our view has to update the columns title, order and visibility of all
columns. columns.
""" """
def set_column_visible(self, colname, visible): def set_column_visible(self, colname: str, visible: bool) -> None:
"""Update visibility of column ``colname``. """Update visibility of column ``colname``.
Called when the user toggles the visibility of a column, we must update the column Called when the user toggles the visibility of a column, we must update the column
@@ -73,13 +75,13 @@ class PrefAccessInterface:
*Not actually used in the code. For documentation purposes only.* *Not actually used in the code. For documentation purposes only.*
""" """
def get_default(self, key, fallback_value): def get_default(self, key: str, fallback_value: Union[Any, None]) -> Any:
"""Retrieve the value for ``key`` in the currently running app's preference store. """Retrieve the value for ``key`` in the currently running app's preference store.
If the key doesn't exist, return ``fallback_value``. If the key doesn't exist, return ``fallback_value``.
""" """
def set_default(self, key, value): def set_default(self, key: str, value: Any) -> None:
"""Set the value ``value`` for ``key`` in the currently running app's preference store.""" """Set the value ``value`` for ``key`` in the currently running app's preference store."""
@@ -104,65 +106,65 @@ class Columns(GUIObject):
have that same prefix. have that same prefix.
""" """
def __init__(self, table, prefaccess=None, savename=None): def __init__(self, table: GUITable, prefaccess=None, savename: Union[str, None] = None):
GUIObject.__init__(self) GUIObject.__init__(self)
self.table = table self.table = table
self.prefaccess = prefaccess self.prefaccess = prefaccess
self.savename = savename self.savename = savename
# We use copy here for test isolation. If we don't, changing a column affects all tests. # We use copy here for test isolation. If we don't, changing a column affects all tests.
self.column_list = list(map(copy.copy, table.COLUMNS)) self.column_list: List[Column] = list(map(copy.copy, table.COLUMNS))
for i, column in enumerate(self.column_list): for i, column in enumerate(self.column_list):
column.logical_index = i column.logical_index = i
column.ordered_index = i column.ordered_index = i
self.coldata = {col.name: col for col in self.column_list} self.coldata = {col.name: col for col in self.column_list}
# --- Private # --- Private
def _get_colname_attr(self, colname, attrname, default): def _get_colname_attr(self, colname: str, attrname: str, default: Any) -> Any:
try: try:
return getattr(self.coldata[colname], attrname) return getattr(self.coldata[colname], attrname)
except KeyError: except KeyError:
return default return default
def _set_colname_attr(self, colname, attrname, value): def _set_colname_attr(self, colname: str, attrname: str, value: Any) -> None:
try: try:
col = self.coldata[colname] col = self.coldata[colname]
setattr(col, attrname, value) setattr(col, attrname, value)
except KeyError: except KeyError:
pass pass
def _optional_columns(self): def _optional_columns(self) -> List[Column]:
return [c for c in self.column_list if c.optional] return [c for c in self.column_list if c.optional]
# --- Override # --- Override
def _view_updated(self): def _view_updated(self) -> None:
self.restore_columns() self.restore_columns()
# --- Public # --- Public
def column_by_index(self, index): def column_by_index(self, index: int):
"""Return the :class:`Column` having the :attr:`~Column.logical_index` ``index``.""" """Return the :class:`Column` having the :attr:`~Column.logical_index` ``index``."""
return self.column_list[index] return self.column_list[index]
def column_by_name(self, name): def column_by_name(self, name: str):
"""Return the :class:`Column` having the :attr:`~Column.name` ``name``.""" """Return the :class:`Column` having the :attr:`~Column.name` ``name``."""
return self.coldata[name] return self.coldata[name]
def columns_count(self): def columns_count(self) -> int:
"""Returns the number of columns in our set.""" """Returns the number of columns in our set."""
return len(self.column_list) return len(self.column_list)
def column_display(self, colname): def column_display(self, colname: str) -> str:
"""Returns display name for column named ``colname``, or ``''`` if there's none.""" """Returns display name for column named ``colname``, or ``''`` if there's none."""
return self._get_colname_attr(colname, "display", "") return self._get_colname_attr(colname, "display", "")
def column_is_visible(self, colname): def column_is_visible(self, colname: str) -> bool:
"""Returns visibility for column named ``colname``, or ``True`` if there's none.""" """Returns visibility for column named ``colname``, or ``True`` if there's none."""
return self._get_colname_attr(colname, "visible", True) return self._get_colname_attr(colname, "visible", True)
def column_width(self, colname): def column_width(self, colname: str) -> int:
"""Returns width for column named ``colname``, or ``0`` if there's none.""" """Returns width for column named ``colname``, or ``0`` if there's none."""
return self._get_colname_attr(colname, "width", 0) return self._get_colname_attr(colname, "width", 0)
def columns_to_right(self, colname): def columns_to_right(self, colname: str) -> List[str]:
"""Returns the list of all columns to the right of ``colname``. """Returns the list of all columns to the right of ``colname``.
"right" meaning "having a higher :attr:`Column.ordered_index`" in our left-to-right "right" meaning "having a higher :attr:`Column.ordered_index`" in our left-to-right
@@ -172,7 +174,7 @@ class Columns(GUIObject):
index = column.ordered_index index = column.ordered_index
return [col.name for col in self.column_list if (col.visible and col.ordered_index > index)] return [col.name for col in self.column_list if (col.visible and col.ordered_index > index)]
def menu_items(self): def menu_items(self) -> List[Tuple[str, bool]]:
"""Returns a list of items convenient for quick visibility menu generation. """Returns a list of items convenient for quick visibility menu generation.
Returns a list of ``(display_name, is_marked)`` items for each optional column in the Returns a list of ``(display_name, is_marked)`` items for each optional column in the
@@ -184,7 +186,7 @@ class Columns(GUIObject):
""" """
return [(c.display, c.visible) for c in self._optional_columns()] return [(c.display, c.visible) for c in self._optional_columns()]
def move_column(self, colname, index): def move_column(self, colname: str, index: int) -> None:
"""Moves column ``colname`` to ``index``. """Moves column ``colname`` to ``index``.
The column will be placed just in front of the column currently having that index, or to the The column will be placed just in front of the column currently having that index, or to the
@@ -195,7 +197,7 @@ class Columns(GUIObject):
colnames.insert(index, colname) colnames.insert(index, colname)
self.set_column_order(colnames) self.set_column_order(colnames)
def reset_to_defaults(self): def reset_to_defaults(self) -> None:
"""Reset all columns' width and visibility to their default values.""" """Reset all columns' width and visibility to their default values."""
self.set_column_order([col.name for col in self.column_list]) self.set_column_order([col.name for col in self.column_list])
for col in self._optional_columns(): for col in self._optional_columns():
@@ -203,11 +205,11 @@ class Columns(GUIObject):
col.width = col.default_width col.width = col.default_width
self.view.restore_columns() self.view.restore_columns()
def resize_column(self, colname, newwidth): def resize_column(self, colname: str, newwidth: int) -> None:
"""Set column ``colname``'s width to ``newwidth``.""" """Set column ``colname``'s width to ``newwidth``."""
self._set_colname_attr(colname, "width", newwidth) self._set_colname_attr(colname, "width", newwidth)
def restore_columns(self): def restore_columns(self) -> None:
"""Restore's column persistent attributes from the last :meth:`save_columns`.""" """Restore's column persistent attributes from the last :meth:`save_columns`."""
if not (self.prefaccess and self.savename and self.coldata): if not (self.prefaccess and self.savename and self.coldata):
if (not self.savename) and (self.coldata): if (not self.savename) and (self.coldata):
@@ -226,7 +228,7 @@ class Columns(GUIObject):
col.visible = coldata["visible"] col.visible = coldata["visible"]
self.view.restore_columns() self.view.restore_columns()
def save_columns(self): def save_columns(self) -> None:
"""Save column attributes in persistent storage for restoration in :meth:`restore_columns`.""" """Save column attributes in persistent storage for restoration in :meth:`restore_columns`."""
if not (self.prefaccess and self.savename and self.coldata): if not (self.prefaccess and self.savename and self.coldata):
return return
@@ -237,7 +239,8 @@ class Columns(GUIObject):
coldata["visible"] = col.visible coldata["visible"] = col.visible
self.prefaccess.set_default(pref_name, coldata) self.prefaccess.set_default(pref_name, coldata)
def set_column_order(self, colnames): # TODO annotate colnames
def set_column_order(self, colnames) -> None:
"""Change the columns order so it matches the order in ``colnames``. """Change the columns order so it matches the order in ``colnames``.
:param colnames: A list of column names in the desired order. :param colnames: A list of column names in the desired order.
@@ -247,17 +250,17 @@ class Columns(GUIObject):
col = self.coldata[colname] col = self.coldata[colname]
col.ordered_index = i col.ordered_index = i
def set_column_visible(self, colname, visible): def set_column_visible(self, colname: str, visible: bool) -> None:
"""Set the visibility of column ``colname``.""" """Set the visibility of column ``colname``."""
self.table.save_edits() # the table on the GUI side will stop editing when the columns change self.table.save_edits() # the table on the GUI side will stop editing when the columns change
self._set_colname_attr(colname, "visible", visible) self._set_colname_attr(colname, "visible", visible)
self.view.set_column_visible(colname, visible) self.view.set_column_visible(colname, visible)
def set_default_width(self, colname, width): def set_default_width(self, colname: str, width: int) -> None:
"""Set the default width or column ``colname``.""" """Set the default width or column ``colname``."""
self._set_colname_attr(colname, "default_width", width) self._set_colname_attr(colname, "default_width", width)
def toggle_menu_item(self, index): def toggle_menu_item(self, index: int) -> bool:
"""Toggles the visibility of an optional column. """Toggles the visibility of an optional column.
You know, that optional column menu you've generated in :meth:`menu_items`? Well, ``index`` You know, that optional column menu you've generated in :meth:`menu_items`? Well, ``index``
@@ -271,11 +274,11 @@ class Columns(GUIObject):
# --- Properties # --- Properties
@property @property
def ordered_columns(self): def ordered_columns(self) -> List[Column]:
"""List of :class:`Column` in visible order.""" """List of :class:`Column` in visible order."""
return [col for col in sorted(self.column_list, key=lambda col: col.ordered_index)] return [col for col in sorted(self.column_list, key=lambda col: col.ordered_index)]
@property @property
def colnames(self): def colnames(self) -> List[str]:
"""List of column names in visible order.""" """List of column names in visible order."""
return [col.name for col in self.ordered_columns] return [col.name for col in self.ordered_columns]

View File

@@ -4,6 +4,7 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from typing import Callable, Tuple, Union
from hscommon.jobprogress.performer import ThreadedJobPerformer from hscommon.jobprogress.performer import ThreadedJobPerformer
from hscommon.gui.base import GUIObject from hscommon.gui.base import GUIObject
from hscommon.gui.text_field import TextField from hscommon.gui.text_field import TextField
@@ -20,13 +21,13 @@ class ProgressWindowView:
It's also expected to call :meth:`ProgressWindow.cancel` when the cancel button is clicked. It's also expected to call :meth:`ProgressWindow.cancel` when the cancel button is clicked.
""" """
def show(self): def show(self) -> None:
"""Show the dialog.""" """Show the dialog."""
def close(self): def close(self) -> None:
"""Close the dialog.""" """Close the dialog."""
def set_progress(self, progress): def set_progress(self, progress: int) -> None:
"""Set the progress of the progress bar to ``progress``. """Set the progress of the progress bar to ``progress``.
Not all jobs are equally responsive on their job progress report and it is recommended that Not all jobs are equally responsive on their job progress report and it is recommended that
@@ -60,7 +61,11 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
called as if the job terminated normally. called as if the job terminated normally.
""" """
def __init__(self, finish_func, error_func=None): def __init__(
self,
finish_func: Callable[[Union[str, None]], None],
error_func: Callable[[Union[str, None], Exception], bool] = None,
) -> None:
# finish_func(jobid) is the function that is called when a job is completed. # finish_func(jobid) is the function that is called when a job is completed.
GUIObject.__init__(self) GUIObject.__init__(self)
ThreadedJobPerformer.__init__(self) ThreadedJobPerformer.__init__(self)
@@ -71,9 +76,9 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
#: :class:`.TextField`. It contains the job textual update that the function might yield #: :class:`.TextField`. It contains the job textual update that the function might yield
#: during its course. #: during its course.
self.progressdesc_textfield = TextField() self.progressdesc_textfield = TextField()
self.jobid = None self.jobid: Union[str, None] = None
def cancel(self): def cancel(self) -> None:
"""Call for a user-initiated job cancellation.""" """Call for a user-initiated job cancellation."""
# The UI is sometimes a bit buggy and calls cancel() on self.view.close(). We just want to # The UI is sometimes a bit buggy and calls cancel() on self.view.close(). We just want to
# make sure that this doesn't lead us to think that the user acually cancelled the task, so # make sure that this doesn't lead us to think that the user acually cancelled the task, so
@@ -81,7 +86,7 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
if self._job_running: if self._job_running:
self.job_cancelled = True self.job_cancelled = True
def pulse(self): def pulse(self) -> None:
"""Update progress reports in the GUI. """Update progress reports in the GUI.
Call this regularly from the GUI main run loop. The values might change before Call this regularly from the GUI main run loop. The values might change before
@@ -111,7 +116,7 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer):
self.progressdesc_textfield.text = last_desc self.progressdesc_textfield.text = last_desc
self.view.set_progress(last_progress) self.view.set_progress(last_progress)
def run(self, jobid, title, target, args=()): def run(self, jobid: str, title: str, target: Callable, args: Tuple = ()):
"""Starts a threaded job. """Starts a threaded job.
The ``target`` function will be sent, as its first argument, a :class:`.Job` instance which The ``target`` function will be sent, as its first argument, a :class:`.Job` instance which

View File

@@ -8,6 +8,7 @@
from collections.abc import MutableSequence from collections.abc import MutableSequence
from collections import namedtuple from collections import namedtuple
from typing import Any, List, Tuple, Union
from hscommon.gui.base import GUIObject from hscommon.gui.base import GUIObject
from hscommon.gui.selectable_list import Selectable from hscommon.gui.selectable_list import Selectable
@@ -27,12 +28,16 @@ class Table(MutableSequence, Selectable):
Subclasses :class:`.Selectable`. Subclasses :class:`.Selectable`.
""" """
def __init__(self): # Should be List[Column], but have circular import...
Selectable.__init__(self) COLUMNS: List = []
self._rows = []
self._header = None
self._footer = None
def __init__(self) -> None:
Selectable.__init__(self)
self._rows: List["Row"] = []
self._header: Union["Row", None] = None
self._footer: Union["Row", None] = None
# TODO type hint for key
def __delitem__(self, key): def __delitem__(self, key):
self._rows.__delitem__(key) self._rows.__delitem__(key)
if self._header is not None and ((not self) or (self[0] is not self._header)): if self._header is not None and ((not self) or (self[0] is not self._header)):
@@ -41,16 +46,18 @@ class Table(MutableSequence, Selectable):
self._footer = None self._footer = None
self._check_selection_range() self._check_selection_range()
def __getitem__(self, key): # TODO type hint for key
def __getitem__(self, key) -> Any:
return self._rows.__getitem__(key) return self._rows.__getitem__(key)
def __len__(self): def __len__(self) -> int:
return len(self._rows) return len(self._rows)
def __setitem__(self, key, value): # TODO type hint for key
def __setitem__(self, key, value: Any) -> None:
self._rows.__setitem__(key, value) self._rows.__setitem__(key, value)
def append(self, item): def append(self, item: "Row") -> None:
"""Appends ``item`` at the end of the table. """Appends ``item`` at the end of the table.
If there's a footer, the item is inserted before it. If there's a footer, the item is inserted before it.
@@ -60,7 +67,7 @@ class Table(MutableSequence, Selectable):
else: else:
self._rows.append(item) self._rows.append(item)
def insert(self, index, item): def insert(self, index: int, item: "Row") -> None:
"""Inserts ``item`` at ``index`` in the table. """Inserts ``item`` at ``index`` in the table.
If there's a header, will make sure we don't insert before it, and if there's a footer, will If there's a header, will make sure we don't insert before it, and if there's a footer, will
@@ -72,7 +79,7 @@ class Table(MutableSequence, Selectable):
index = len(self) - 1 index = len(self) - 1
self._rows.insert(index, item) self._rows.insert(index, item)
def remove(self, row): def remove(self, row: "Row") -> None:
"""Removes ``row`` from table. """Removes ``row`` from table.
If ``row`` is a header or footer, that header or footer will be set to ``None``. If ``row`` is a header or footer, that header or footer will be set to ``None``.
@@ -84,7 +91,7 @@ class Table(MutableSequence, Selectable):
self._rows.remove(row) self._rows.remove(row)
self._check_selection_range() self._check_selection_range()
def sort_by(self, column_name, desc=False): def sort_by(self, column_name: str, desc: bool = False) -> None:
"""Sort table by ``column_name``. """Sort table by ``column_name``.
Sort key for each row is computed from :meth:`Row.sort_key_for_column`. Sort key for each row is computed from :meth:`Row.sort_key_for_column`.
@@ -105,7 +112,7 @@ class Table(MutableSequence, Selectable):
# --- Properties # --- Properties
@property @property
def footer(self): def footer(self) -> Union["Row", None]:
"""If set, a row that always stay at the bottom of the table. """If set, a row that always stay at the bottom of the table.
:class:`Row`. *get/set*. :class:`Row`. *get/set*.
@@ -128,7 +135,7 @@ class Table(MutableSequence, Selectable):
return self._footer return self._footer
@footer.setter @footer.setter
def footer(self, value): def footer(self, value: Union["Row", None]) -> None:
if self._footer is not None: if self._footer is not None:
self._rows.pop() self._rows.pop()
if value is not None: if value is not None:
@@ -136,7 +143,7 @@ class Table(MutableSequence, Selectable):
self._footer = value self._footer = value
@property @property
def header(self): def header(self) -> Union["Row", None]:
"""If set, a row that always stay at the bottom of the table. """If set, a row that always stay at the bottom of the table.
See :attr:`footer` for details. See :attr:`footer` for details.
@@ -144,7 +151,7 @@ class Table(MutableSequence, Selectable):
return self._header return self._header
@header.setter @header.setter
def header(self, value): def header(self, value: Union["Row", None]) -> None:
if self._header is not None: if self._header is not None:
self._rows.pop(0) self._rows.pop(0)
if value is not None: if value is not None:
@@ -152,7 +159,7 @@ class Table(MutableSequence, Selectable):
self._header = value self._header = value
@property @property
def row_count(self): def row_count(self) -> int:
"""Number or rows in the table (without counting header and footer). """Number or rows in the table (without counting header and footer).
*int*. *read-only*. *int*. *read-only*.
@@ -165,7 +172,7 @@ class Table(MutableSequence, Selectable):
return result return result
@property @property
def rows(self): def rows(self) -> List["Row"]:
"""List of rows in the table, excluding header and footer. """List of rows in the table, excluding header and footer.
List of :class:`Row`. *read-only*. List of :class:`Row`. *read-only*.
@@ -179,7 +186,7 @@ class Table(MutableSequence, Selectable):
return self[start:end] return self[start:end]
@property @property
def selected_row(self): def selected_row(self) -> "Row":
"""Selected row according to :attr:`Selectable.selected_index`. """Selected row according to :attr:`Selectable.selected_index`.
:class:`Row`. *get/set*. :class:`Row`. *get/set*.
@@ -190,14 +197,14 @@ class Table(MutableSequence, Selectable):
return self[self.selected_index] if self.selected_index is not None else None return self[self.selected_index] if self.selected_index is not None else None
@selected_row.setter @selected_row.setter
def selected_row(self, value): def selected_row(self, value: int) -> None:
try: try:
self.selected_index = self.index(value) self.selected_index = self.index(value)
except ValueError: except ValueError:
pass pass
@property @property
def selected_rows(self): def selected_rows(self) -> List["Row"]:
"""List of selected rows based on :attr:`.selected_indexes`. """List of selected rows based on :attr:`.selected_indexes`.
List of :class:`Row`. *read-only*. List of :class:`Row`. *read-only*.
@@ -219,20 +226,20 @@ class GUITableView:
Whenever the user changes the selection, we expect the view to call :meth:`Table.select`. Whenever the user changes the selection, we expect the view to call :meth:`Table.select`.
""" """
def refresh(self): def refresh(self) -> None:
"""Refreshes the contents of the table widget. """Refreshes the contents of the table widget.
Ensures that the contents of the table widget is synced with the model. This includes Ensures that the contents of the table widget is synced with the model. This includes
selection. selection.
""" """
def start_editing(self): def start_editing(self) -> None:
"""Start editing the currently selected row. """Start editing the currently selected row.
Begin whatever inline editing support that the view supports. Begin whatever inline editing support that the view supports.
""" """
def stop_editing(self): def stop_editing(self) -> None:
"""Stop editing if there's an inline editing in effect. """Stop editing if there's an inline editing in effect.
There's no "aborting" implied in this call, so it's appropriate to send whatever the user There's no "aborting" implied in this call, so it's appropriate to send whatever the user
@@ -260,33 +267,33 @@ class GUITable(Table, GUIObject):
:class:`GUITableView`. :class:`GUITableView`.
""" """
def __init__(self): def __init__(self) -> None:
GUIObject.__init__(self) GUIObject.__init__(self)
Table.__init__(self) Table.__init__(self)
#: The row being currently edited by the user. ``None`` if no edit is taking place. #: The row being currently edited by the user. ``None`` if no edit is taking place.
self.edited = None self.edited: Union["Row", None] = None
self._sort_descriptor = None self._sort_descriptor: Union[SortDescriptor, None] = None
# --- Virtual # --- Virtual
def _do_add(self): def _do_add(self) -> Tuple["Row", int]:
"""(Virtual) Creates a new row, adds it in the table. """(Virtual) Creates a new row, adds it in the table.
Returns ``(row, insert_index)``. Returns ``(row, insert_index)``.
""" """
raise NotImplementedError() raise NotImplementedError()
def _do_delete(self): def _do_delete(self) -> None:
"""(Virtual) Delete the selected rows.""" """(Virtual) Delete the selected rows."""
pass pass
def _fill(self): def _fill(self) -> None:
"""(Virtual/Required) Fills the table with all the rows that this table is supposed to have. """(Virtual/Required) Fills the table with all the rows that this table is supposed to have.
Called by :meth:`refresh`. Does nothing by default. Called by :meth:`refresh`. Does nothing by default.
""" """
pass pass
def _is_edited_new(self): def _is_edited_new(self) -> bool:
"""(Virtual) Returns whether the currently edited row should be considered "new". """(Virtual) Returns whether the currently edited row should be considered "new".
This is used in :meth:`cancel_edits` to know whether the cancellation of the edit means a This is used in :meth:`cancel_edits` to know whether the cancellation of the edit means a
@@ -315,7 +322,7 @@ class GUITable(Table, GUIObject):
self.select([len(self) - 1]) self.select([len(self) - 1])
# --- Public # --- Public
def add(self): def add(self) -> None:
"""Add a new row in edit mode. """Add a new row in edit mode.
Requires :meth:`do_add` to be implemented. The newly added row will be selected and in edit Requires :meth:`do_add` to be implemented. The newly added row will be selected and in edit
@@ -334,7 +341,7 @@ class GUITable(Table, GUIObject):
self.edited = row self.edited = row
self.view.start_editing() self.view.start_editing()
def can_edit_cell(self, column_name, row_index): def can_edit_cell(self, column_name: str, row_index: int) -> bool:
"""Returns whether the cell at ``row_index`` and ``column_name`` can be edited. """Returns whether the cell at ``row_index`` and ``column_name`` can be edited.
A row is, by default, editable as soon as it has an attr with the same name as `column`. A row is, by default, editable as soon as it has an attr with the same name as `column`.
@@ -346,7 +353,7 @@ class GUITable(Table, GUIObject):
row = self[row_index] row = self[row_index]
return row.can_edit_cell(column_name) return row.can_edit_cell(column_name)
def cancel_edits(self): def cancel_edits(self) -> None:
"""Cancels the current edit operation. """Cancels the current edit operation.
If there's an :attr:`edited` row, it will be re-initialized (with :meth:`Row.load`). If there's an :attr:`edited` row, it will be re-initialized (with :meth:`Row.load`).
@@ -364,7 +371,7 @@ class GUITable(Table, GUIObject):
self.edited = None self.edited = None
self.view.refresh() self.view.refresh()
def delete(self): def delete(self) -> None:
"""Delete the currently selected rows. """Delete the currently selected rows.
Requires :meth:`_do_delete` for this to have any effect on the model. Cancels editing if Requires :meth:`_do_delete` for this to have any effect on the model. Cancels editing if
@@ -377,7 +384,7 @@ class GUITable(Table, GUIObject):
if self: if self:
self._do_delete() self._do_delete()
def refresh(self, refresh_view=True): def refresh(self, refresh_view: bool = True) -> None:
"""Empty the table and re-create its rows. """Empty the table and re-create its rows.
:meth:`_fill` is called after we emptied the table to create our rows. Previous sort order :meth:`_fill` is called after we emptied the table to create our rows. Previous sort order
@@ -399,7 +406,7 @@ class GUITable(Table, GUIObject):
if refresh_view: if refresh_view:
self.view.refresh() self.view.refresh()
def save_edits(self): def save_edits(self) -> None:
"""Commit user edits to the model. """Commit user edits to the model.
This is done by calling :meth:`Row.save`. This is done by calling :meth:`Row.save`.
@@ -410,7 +417,7 @@ class GUITable(Table, GUIObject):
self.edited = None self.edited = None
row.save() row.save()
def sort_by(self, column_name, desc=False): def sort_by(self, column_name: str, desc: bool = False) -> None:
"""Sort table by ``column_name``. """Sort table by ``column_name``.
Overrides :meth:`Table.sort_by`. After having performed sorting, calls Overrides :meth:`Table.sort_by`. After having performed sorting, calls
@@ -450,18 +457,18 @@ class Row:
Of course, this is only default behavior. This can be overriden. Of course, this is only default behavior. This can be overriden.
""" """
def __init__(self, table): def __init__(self, table: GUITable) -> None:
super().__init__() super().__init__()
self.table = table self.table = table
def _edit(self): def _edit(self) -> None:
if self.table.edited is self: if self.table.edited is self:
return return
assert self.table.edited is None assert self.table.edited is None
self.table.edited = self self.table.edited = self
# --- Virtual # --- Virtual
def can_edit(self): def can_edit(self) -> bool:
"""(Virtual) Whether the whole row can be edited. """(Virtual) Whether the whole row can be edited.
By default, always returns ``True``. This is for the *whole* row. For individual cells, it's By default, always returns ``True``. This is for the *whole* row. For individual cells, it's
@@ -469,7 +476,7 @@ class Row:
""" """
return True return True
def load(self): def load(self) -> None:
"""(Virtual/Required) Loads up values from the model to be presented in the table. """(Virtual/Required) Loads up values from the model to be presented in the table.
Usually, our model instances contain values that are not quite ready for display. If you Usually, our model instances contain values that are not quite ready for display. If you
@@ -478,7 +485,7 @@ class Row:
""" """
raise NotImplementedError() raise NotImplementedError()
def save(self): def save(self) -> None:
"""(Virtual/Required) Saves user edits into your model. """(Virtual/Required) Saves user edits into your model.
If your table is editable, this is called when the user commits his changes. Usually, these If your table is editable, this is called when the user commits his changes. Usually, these
@@ -487,7 +494,7 @@ class Row:
""" """
raise NotImplementedError() raise NotImplementedError()
def sort_key_for_column(self, column_name): def sort_key_for_column(self, column_name: str) -> Any:
"""(Virtual) Return the value that is to be used to sort by column ``column_name``. """(Virtual) Return the value that is to be used to sort by column ``column_name``.
By default, looks for an attribute with the same name as ``column_name``, but with an By default, looks for an attribute with the same name as ``column_name``, but with an
@@ -500,7 +507,7 @@ class Row:
return getattr(self, column_name) return getattr(self, column_name)
# --- Public # --- Public
def can_edit_cell(self, column_name): def can_edit_cell(self, column_name: str) -> bool:
"""Returns whether cell for column ``column_name`` can be edited. """Returns whether cell for column ``column_name`` can be edited.
By the default, the check is done in many steps: By the default, the check is done in many steps:
@@ -530,7 +537,7 @@ class Row:
return False return False
return bool(getattr(prop, "fset", None)) return bool(getattr(prop, "fset", None))
def get_cell_value(self, attrname): def get_cell_value(self, attrname: str) -> Any:
"""Get cell value for ``attrname``. """Get cell value for ``attrname``.
By default, does a simple ``getattr()``, but it is used to allow subclasses to have By default, does a simple ``getattr()``, but it is used to allow subclasses to have
@@ -540,7 +547,7 @@ class Row:
attrname = "from_" attrname = "from_"
return getattr(self, attrname) return getattr(self, attrname)
def set_cell_value(self, attrname, value): def set_cell_value(self, attrname: str, value: Any) -> None:
"""Set cell value to ``value`` for ``attrname``. """Set cell value to ``value`` for ``attrname``.
By default, does a simple ``setattr()``, but it is used to allow subclasses to have By default, does a simple ``setattr()``, but it is used to allow subclasses to have

View File

@@ -7,6 +7,9 @@
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from typing import Any, Callable, Generator, Iterator, List, Union
class JobCancelled(Exception): class JobCancelled(Exception):
"The user has cancelled the job" "The user has cancelled the job"
@@ -36,7 +39,7 @@ class Job:
""" """
# ---Magic functions # ---Magic functions
def __init__(self, job_proportions, callback): def __init__(self, job_proportions: Union[List[int], int], callback: Callable) -> None:
"""Initialize the Job with 'jobcount' jobs. Start every job with """Initialize the Job with 'jobcount' jobs. Start every job with
start_job(). Every time the job progress is updated, 'callback' is called start_job(). Every time the job progress is updated, 'callback' is called
'callback' takes a 'progress' int param, and a optional 'desc' 'callback' takes a 'progress' int param, and a optional 'desc'
@@ -55,12 +58,12 @@ class Job:
self._currmax = 1 self._currmax = 1
# ---Private # ---Private
def _subjob_callback(self, progress, desc=""): def _subjob_callback(self, progress: int, desc: str = "") -> bool:
"""This is the callback passed to children jobs.""" """This is the callback passed to children jobs."""
self.set_progress(progress, desc) self.set_progress(progress, desc)
return True # if JobCancelled has to be raised, it will be at the highest level return True # if JobCancelled has to be raised, it will be at the highest level
def _do_update(self, desc): def _do_update(self, desc: str) -> None:
"""Calls the callback function with a % progress as a parameter. """Calls the callback function with a % progress as a parameter.
The parameter is a int in the 0-100 range. The parameter is a int in the 0-100 range.
@@ -78,13 +81,16 @@ class Job:
raise JobCancelled() raise JobCancelled()
# ---Public # ---Public
def add_progress(self, progress=1, desc=""): def add_progress(self, progress: int = 1, desc: str = "") -> None:
self.set_progress(self._progress + progress, desc) self.set_progress(self._progress + progress, desc)
def check_if_cancelled(self): def check_if_cancelled(self) -> None:
self._do_update("") self._do_update("")
def iter_with_progress(self, iterable, desc_format=None, every=1, count=None): # TODO type hint iterable
def iter_with_progress(
self, iterable, desc_format: Union[str, None] = None, every: int = 1, count: Union[int, None] = None
) -> Generator[Any, None, None]:
"""Iterate through ``iterable`` while automatically adding progress. """Iterate through ``iterable`` while automatically adding progress.
WARNING: We need our iterable's length. If ``iterable`` is not a sequence (that is, WARNING: We need our iterable's length. If ``iterable`` is not a sequence (that is,
@@ -107,7 +113,7 @@ class Job:
desc = desc_format % (count, count) desc = desc_format % (count, count)
self.set_progress(100, desc) self.set_progress(100, desc)
def start_job(self, max_progress=100, desc=""): def start_job(self, max_progress: int = 100, desc: str = "") -> None:
"""Begin work on the next job. You must not call start_job more than """Begin work on the next job. You must not call start_job more than
'jobcount' (in __init__) times. 'jobcount' (in __init__) times.
'max' is the job units you are to perform. 'max' is the job units you are to perform.
@@ -122,7 +128,7 @@ class Job:
self._currmax = max(1, max_progress) self._currmax = max(1, max_progress)
self._do_update(desc) self._do_update(desc)
def start_subjob(self, job_proportions, desc=""): def start_subjob(self, job_proportions: Union[List[int], int], desc: str = "") -> "Job":
"""Starts a sub job. Use this when you want to split a job into """Starts a sub job. Use this when you want to split a job into
multiple smaller jobs. Pretty handy when starting a process where you multiple smaller jobs. Pretty handy when starting a process where you
know how many subjobs you will have, but don't know the work unit count know how many subjobs you will have, but don't know the work unit count
@@ -132,7 +138,7 @@ class Job:
self.start_job(100, desc) self.start_job(100, desc)
return Job(job_proportions, self._subjob_callback) return Job(job_proportions, self._subjob_callback)
def set_progress(self, progress, desc=""): def set_progress(self, progress: int, desc: str = "") -> None:
"""Sets the progress of the current job to 'progress', and call the """Sets the progress of the current job to 'progress', and call the
callback callback
""" """
@@ -143,29 +149,29 @@ class Job:
class NullJob: class NullJob:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs) -> None:
# Null job does nothing # Null job does nothing
pass pass
def add_progress(self, *args, **kwargs): def add_progress(self, *args, **kwargs) -> None:
# Null job does nothing # Null job does nothing
pass pass
def check_if_cancelled(self): def check_if_cancelled(self) -> None:
# Null job does nothing # Null job does nothing
pass pass
def iter_with_progress(self, sequence, *args, **kwargs): def iter_with_progress(self, sequence, *args, **kwargs) -> Iterator:
return iter(sequence) return iter(sequence)
def start_job(self, *args, **kwargs): def start_job(self, *args, **kwargs) -> None:
# Null job does nothing # Null job does nothing
pass pass
def start_subjob(self, *args, **kwargs): def start_subjob(self, *args, **kwargs) -> "NullJob":
return NullJob() return NullJob()
def set_progress(self, *args, **kwargs): def set_progress(self, *args, **kwargs) -> None:
# Null job does nothing # Null job does nothing
pass pass

View File

@@ -8,6 +8,7 @@
from threading import Thread from threading import Thread
import sys import sys
from typing import Callable, Tuple, Union
from hscommon.jobprogress.job import Job, JobInProgressError, JobCancelled from hscommon.jobprogress.job import Job, JobInProgressError, JobCancelled
@@ -28,15 +29,15 @@ class ThreadedJobPerformer:
last_error = None last_error = None
# --- Protected # --- Protected
def create_job(self): def create_job(self) -> Job:
if self._job_running: if self._job_running:
raise JobInProgressError() raise JobInProgressError()
self.last_progress = -1 self.last_progress: Union[int, None] = -1
self.last_desc = "" self.last_desc = ""
self.job_cancelled = False self.job_cancelled = False
return Job(1, self._update_progress) return Job(1, self._update_progress)
def _async_run(self, *args): def _async_run(self, *args) -> None:
target = args[0] target = args[0]
args = tuple(args[1:]) args = tuple(args[1:])
self._job_running = True self._job_running = True
@@ -52,7 +53,7 @@ class ThreadedJobPerformer:
self._job_running = False self._job_running = False
self.last_progress = None self.last_progress = None
def reraise_if_error(self): def reraise_if_error(self) -> None:
"""Reraises the error that happened in the thread if any. """Reraises the error that happened in the thread if any.
Call this after the caller of run_threaded detected that self._job_running returned to False Call this after the caller of run_threaded detected that self._job_running returned to False
@@ -60,13 +61,13 @@ class ThreadedJobPerformer:
if self.last_error is not None: if self.last_error is not None:
raise self.last_error.with_traceback(self.last_traceback) raise self.last_error.with_traceback(self.last_traceback)
def _update_progress(self, newprogress, newdesc=""): def _update_progress(self, newprogress: int, newdesc: str = "") -> bool:
self.last_progress = newprogress self.last_progress = newprogress
if newdesc: if newdesc:
self.last_desc = newdesc self.last_desc = newdesc
return not self.job_cancelled return not self.job_cancelled
def run_threaded(self, target, args=()): def run_threaded(self, target: Callable, args: Tuple = ()) -> None:
if self._job_running: if self._job_running:
raise JobInProgressError() raise JobInProgressError()
args = (target,) + args args = (target,) + args

View File

@@ -82,7 +82,7 @@ def get_locale_name(lang: str) -> Union[str, None]:
# --- Qt # --- Qt
def install_qt_trans(lang: str = None) -> None: def install_qt_trans(lang: str = None) -> None:
from PyQt5.QtCore import QCoreApplication, QTranslator, QLocale from PyQt6.QtCore import QCoreApplication, QTranslator, QLocale
if not lang: if not lang:
lang = str(QLocale.system().name())[:2] lang = str(QLocale.system().name())[:2]
@@ -139,7 +139,7 @@ def install_gettext_trans_under_qt(base_folder: os.PathLike, lang: str = None) -
# So, we install the gettext locale, great, but we also should try to install qt_*.qm if # So, we install the gettext locale, great, but we also should try to install qt_*.qm if
# available so that strings that are inside Qt itself over which I have no control are in the # available so that strings that are inside Qt itself over which I have no control are in the
# right language. # right language.
from PyQt5.QtCore import QCoreApplication, QTranslator, QLocale, QLibraryInfo from PyQt6.QtCore import QCoreApplication, QTranslator, QLocale, QLibraryInfo
if not lang: if not lang:
lang = str(QLocale.system().name())[:2] lang = str(QLocale.system().name())[:2]
@@ -155,7 +155,7 @@ def install_gettext_trans_under_qt(base_folder: os.PathLike, lang: str = None) -
if ISLINUX: if ISLINUX:
# Under linux, a full Qt installation is already available in the system, we didn't bundle # Under linux, a full Qt installation is already available in the system, we didn't bundle
# up the qm files in our package, so we have to load translations from the system. # up the qm files in our package, so we have to load translations from the system.
qmpath = op.join(QLibraryInfo.location(QLibraryInfo.TranslationsPath), qmname) qmpath = op.join(QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath), qmname)
else: else:
qmpath = op.join(base_folder, qmname) qmpath = op.join(base_folder, qmname)
qtr = QTranslator(QCoreApplication.instance()) qtr = QTranslator(QCoreApplication.instance())

View File

@@ -2,15 +2,16 @@
# Andrew Senetar <arsenetar@gmail.com>, 2022 # Andrew Senetar <arsenetar@gmail.com>, 2022
# Emanuele, 2022 # Emanuele, 2022
# Fuan <jcfrt@posteo.net>, 2022 # Fuan <jcfrt@posteo.net>, 2022
# Giovanni, 2022
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2022\n" "Last-Translator: Giovanni, 2022\n"
"Language-Team: Italian (https://www.transifex.com/voltaicideas/teams/116153/it/)\n" "Language-Team: Italian (https://www.transifex.com/voltaicideas/teams/116153/it/)\n"
"Language: it\n" "Language: it\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: utf-8\n" "Content-Transfer-Encoding: utf-8\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#: qt/app.py:81 #: qt/app.py:81
msgid "Quit" msgid "Quit"
@@ -979,37 +980,40 @@ msgstr "Ignora file più grandi di"
#: qt\app.py:135 qt\app.py:293 #: qt\app.py:135 qt\app.py:293
msgid "Clear Cache" msgid "Clear Cache"
msgstr "" msgstr "Svuota cache"
#: qt\app.py:294 #: qt\app.py:294
msgid "" msgid ""
"Do you really want to clear the cache? This will remove all cached file " "Do you really want to clear the cache? This will remove all cached file "
"hashes and picture analysis." "hashes and picture analysis."
msgstr "" msgstr ""
"Vuoi davvero svuotare la cache? Ciò rimuoverà tutti gli hash dei file "
"memorizzati nella cache e le analisi delle immagini."
#: qt\app.py:299 #: qt\app.py:299
msgid "Cache cleared." msgid "Cache cleared."
msgstr "" msgstr "Cache svuotata"
#: qt\preferences_dialog.py:173 #: qt\preferences_dialog.py:173
msgid "Use dark style" msgid "Use dark style"
msgstr "" msgstr "Usa stile scuro"
#: qt\preferences_dialog.py:241 #: qt\preferences_dialog.py:241
msgid "Profile scan operation" msgid "Profile scan operation"
msgstr "" msgstr "Profila l'operazione di scansione"
#: qt\preferences_dialog.py:242 #: qt\preferences_dialog.py:242
msgid "Profile the scan operation and save logs for optimization." msgid "Profile the scan operation and save logs for optimization."
msgstr "" msgstr ""
"Profila l'operazione di scansione e salva i registri per l'ottimizzazione."
#: qt\preferences_dialog.py:246 #: qt\preferences_dialog.py:246
msgid "Logs located in: <a href=\"{}\">{}</a>" msgid "Logs located in: <a href=\"{}\">{}</a>"
msgstr "" msgstr "I log si trovano in: <a href=\"{}\">{}</a>"
#: qt\preferences_dialog.py:291 #: qt\preferences_dialog.py:291
msgid "Debug" msgid "Debug"
msgstr "" msgstr "Debug"
#: qt\about_box.py:31 #: qt\about_box.py:31
msgid "About {}" msgid "About {}"
@@ -1021,7 +1025,7 @@ msgstr "Versione {}"
#: qt\about_box.py:49 qt\about_box.py:75 #: qt\about_box.py:49 qt\about_box.py:75
msgid "Checking for updates..." msgid "Checking for updates..."
msgstr "" msgstr "Controllo degli aggiornamenti..."
#: qt\about_box.py:54 #: qt\about_box.py:54
msgid "Licensed under GPLv3" msgid "Licensed under GPLv3"
@@ -1029,11 +1033,11 @@ msgstr "Distribuito sotto licenza GPLv3"
#: qt\about_box.py:68 #: qt\about_box.py:68
msgid "No update available." msgid "No update available."
msgstr "" msgstr "Nessun aggiornamento disponibile."
#: qt\about_box.py:71 #: qt\about_box.py:71
msgid "New version {} available, download <a href=\"{}\">here</a>." msgid "New version {} available, download <a href=\"{}\">here</a>."
msgstr "" msgstr "È disponibile la nuova versione {}, scaricabile <a href=\"{}\">qui</a>."
#: qt\error_report_dialog.py:50 #: qt\error_report_dialog.py:50
msgid "Error Report" msgid "Error Report"

View File

@@ -1,138 +1,139 @@
# Translators: # Translators:
# Fuan <jcfrt@posteo.net>, 2021 # Yuji Sasaki, 2022
# Fuan <jcfrt@posteo.net>, 2022
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n" "Last-Translator: Fuan <jcfrt@posteo.net>, 2022\n"
"Language-Team: Japanese (https://www.transifex.com/voltaicideas/teams/116153/ja/)\n" "Language-Team: Japanese (https://www.transifex.com/voltaicideas/teams/116153/ja/)\n"
"Language: ja\n" "Language: ja\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: utf-8\n" "Content-Transfer-Encoding: utf-8\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
#: core\app.py:42 #: core\app.py:44
msgid "There are no marked duplicates. Nothing has been done." msgid "There are no marked duplicates. Nothing has been done."
msgstr "マークされた重複はありません。 何も行われていません。" msgstr "チェックを入れた重複はありません。 何も行われませんでした。"
#: core\app.py:43 #: core\app.py:45
msgid "There are no selected duplicates. Nothing has been done." msgid "There are no selected duplicates. Nothing has been done."
msgstr "選択された重複はありません。 何も行われていません。" msgstr "選択された重複はありません。 何も行われていません。"
#: core\app.py:44 #: core\app.py:46
msgid "" msgid ""
"You're about to open many files at once. Depending on what those files are " "You're about to open many files at once. Depending on what those files are "
"opened with, doing so can create quite a mess. Continue?" "opened with, doing so can create quite a mess. Continue?"
msgstr "一度に多くのファイルを開こうとしています。 これらのファイルを開く対象によっては、これを行うとかなり混乱する可能性があります。 継続する?" msgstr "一度に多くのファイルを開こうとしています。 これらのファイルを開く対象によっては、これを行うとかなり混乱する可能性があります。 継続する?"
#: core\app.py:71 #: core\app.py:73
msgid "Scanning for duplicates" msgid "Scanning for duplicates"
msgstr "重複のスキャン" msgstr "重複のスキャン"
#: core\app.py:72 #: core\app.py:74
msgid "Loading" msgid "Loading"
msgstr "読み込み中" msgstr "読み込み中"
#: core\app.py:73 #: core\app.py:75
msgid "Moving" msgid "Moving"
msgstr "移動します" msgstr "移動します"
#: core\app.py:74 #: core\app.py:76
msgid "Copying" msgid "Copying"
msgstr "コピー中" msgstr "コピー中"
#: core\app.py:75 #: core\app.py:77
msgid "Sending to Trash" msgid "Sending to Trash"
msgstr "ごみ箱に送信します" msgstr "ごみ箱に送信します"
#: core\app.py:289 #: core\app.py:291
msgid "" msgid ""
"A previous action is still hanging in there. You can't start a new one yet. " "A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again." "Wait a few seconds, then try again."
msgstr "前のアクションはまだそこにぶら下がっています。 まだ新しいものを始めることはできません。 数秒待ってから、再試行してください。" msgstr "前のアクションはまだそこにぶら下がっています。 まだ新しいものを始めることはできません。 数秒待ってから、再試行してください。"
#: core\app.py:300 #: core\app.py:302
msgid "No duplicates found." msgid "No duplicates found."
msgstr "重複は見つかりませんでした。" msgstr "重複は見つかりませんでした。"
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr "マークされたファイルはすべて正常にコピーされました。"
#: core\app.py:317 #: core\app.py:317
msgid "All marked files were moved successfully." msgid "All marked files were copied successfully."
msgstr "マークされたファイルすべて正常に移動されました。" msgstr "チェックを入れたファイルすべてコピーしました。"
#: core\app.py:319 #: core\app.py:319
msgid "All marked files were deleted successfully." msgid "All marked files were moved successfully."
msgstr "" msgstr "チェックを入れたファイルをすべて移動しました。"
#: core\app.py:321 #: core\app.py:321
msgid "All marked files were successfully sent to Trash." msgid "All marked files were deleted successfully."
msgstr "マークされたファイルすべてごみ箱に正常に送信されました。" msgstr "チェックを入れたファイルすべて削除しました。"
#: core\app.py:326 #: core\app.py:323
msgid "All marked files were successfully sent to Trash."
msgstr "チェックを入れたファイルをすべてごみ箱に移動しました。"
#: core\app.py:328
msgid "Could not load file: {}" msgid "Could not load file: {}"
msgstr "ファイルを読み込めませんでした:{}" msgstr "ファイルを読み込めませんでした:{}"
#: core\app.py:382 #: core\app.py:384
msgid "'{}' already is in the list." msgid "'{}' already is in the list."
msgstr "「{}」既にリストに含まれています。" msgstr "「{}」既にリストに含まれています。"
#: core\app.py:384 #: core\app.py:386
msgid "'{}' does not exist." msgid "'{}' does not exist."
msgstr "'{}' 存在しません。" msgstr "'{}' 存在しません。"
#: core\app.py:392 #: core\app.py:394
msgid "" msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. " "All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?" "Continue?"
msgstr "選択した%d個の一致は、以降のすべてのスキャンで無視されます。 継続する?" msgstr "選択した%d個の一致は、以降のすべてのスキャンで無視されます。 継続する?"
#: core\app.py:469 #: core\app.py:471
msgid "Select a directory to copy marked files to" msgid "Select a directory to copy marked files to"
msgstr "マークされたファイルをコピーするディレクトリを選択してください" msgstr "マークされたファイルをコピーするディレクトリを選択してください"
#: core\app.py:471 #: core\app.py:473
msgid "Select a directory to move marked files to" msgid "Select a directory to move marked files to"
msgstr "マークされたファイルを移動するディレクトリを選択してください" msgstr "マークされたファイルを移動するディレクトリを選択してください"
#: core\app.py:510 #: core\app.py:512
msgid "Select a destination for your exported CSV" msgid "Select a destination for your exported CSV"
msgstr "エクスポートしたCSVの宛先を選択します。" msgstr "エクスポートしたCSVの宛先を選択します。"
#: core\app.py:516 core\app.py:771 core\app.py:781 #: core\app.py:518 core\app.py:773 core\app.py:783
msgid "Couldn't write to file: {}" msgid "Couldn't write to file: {}"
msgstr "ファイルに書き込めませんでした:{}" msgstr "ファイルに書き込めませんでした:{}"
#: core\app.py:539 #: core\app.py:541
msgid "You have no custom command set up. Set it up in your preferences." msgid "You have no custom command set up. Set it up in your preferences."
msgstr "カスタムコマンドは設定されていません。 お好みで設定してください。" msgstr "カスタムコマンドは設定されていません。 お好みで設定してください。"
#: core\app.py:695 core\app.py:707 #: core\app.py:697 core\app.py:709
msgid "You are about to remove %d files from results. Continue?" msgid "You are about to remove %d files from results. Continue?"
msgstr "結果から%d個のファイルを削除しようとしています。 継続する?" msgstr "結果から%d個のファイルを削除しようとしています。 継続する?"
#: core\app.py:743 #: core\app.py:745
msgid "{} duplicate groups were changed by the re-prioritization." msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{}重複するグループは、再優先順位付けによって変更されました。" msgstr "{}重複するグループは、再優先順位付けによって変更されました。"
#: core\app.py:790 #: core\app.py:792
msgid "The selected directories contain no scannable file." msgid "The selected directories contain no scannable file."
msgstr "選択したディレクトリにはスキャン可能なファイルが含まれていません。" msgstr "選択したディレクトリにはスキャン可能なファイルが含まれていません。"
#: core\app.py:803 #: core\app.py:808
msgid "Collecting files to scan" msgid "Collecting files to scan"
msgstr "スキャンするファイルを収集しています" msgstr "スキャンするファイルを収集しています"
#: core\app.py:850 #: core\app.py:858
msgid "%s (%d discarded)" msgid "%s (%d discarded)"
msgstr "%s (%d 廃棄)" msgstr "%s (%d 廃棄)"
#: core\directories.py:191 #: core\directories.py:190
msgid "Collected {} files to scan" msgid "Collected {} files to scan"
msgstr "" msgstr ""
#: core\directories.py:207 #: core\directories.py:206
msgid "Collected {} folders to scan" msgid "Collected {} folders to scan"
msgstr "" msgstr ""
@@ -200,35 +201,35 @@ msgstr "EXIFタイムスタンプ"
msgid "None" msgid "None"
msgstr "無し" msgstr "無し"
#: core\prioritize.py:100 #: core\prioritize.py:102
msgid "Ends with number" msgid "Ends with number"
msgstr "番号で終わっている" msgstr "番号で終わっている"
#: core\prioritize.py:101 #: core\prioritize.py:103
msgid "Doesn't end with number" msgid "Doesn't end with number"
msgstr "数字で終わっていない" msgstr "数字で終わっていない"
#: core\prioritize.py:102 #: core\prioritize.py:104
msgid "Longest" msgid "Longest"
msgstr "最長" msgstr "最長"
#: core\prioritize.py:103 #: core\prioritize.py:105
msgid "Shortest" msgid "Shortest"
msgstr "最短" msgstr "最短"
#: core\prioritize.py:140 #: core\prioritize.py:142
msgid "Highest" msgid "Highest"
msgstr "最高" msgstr "最高"
#: core\prioritize.py:140 #: core\prioritize.py:142
msgid "Lowest" msgid "Lowest"
msgstr "最低" msgstr "最低"
#: core\prioritize.py:169 #: core\prioritize.py:171
msgid "Newest" msgid "Newest"
msgstr "最新" msgstr "最新"
#: core\prioritize.py:169 #: core\prioritize.py:171
msgid "Oldest" msgid "Oldest"
msgstr "最古" msgstr "最古"

View File

@@ -81,7 +81,7 @@ msgstr "(非対応)"
#: qt/deletion_options.py:49 cocoa/en.lproj/Localizable.strings:0 #: qt/deletion_options.py:49 cocoa/en.lproj/Localizable.strings:0
msgid "Directly delete files" msgid "Directly delete files"
msgstr "ファイルを直接削除する" msgstr "ファイルを完全に削除"
#: qt/deletion_options.py:51 cocoa/en.lproj/Localizable.strings:0 #: qt/deletion_options.py:51 cocoa/en.lproj/Localizable.strings:0
msgid "" msgid ""
@@ -100,7 +100,7 @@ msgstr "キャンセル"
#: qt/details_table.py:16 cocoa/en.lproj/Localizable.strings:0 #: qt/details_table.py:16 cocoa/en.lproj/Localizable.strings:0
msgid "Attribute" msgid "Attribute"
msgstr "アトリビュート" msgstr "属性"
#: qt/details_table.py:16 cocoa/en.lproj/Localizable.strings:0 #: qt/details_table.py:16 cocoa/en.lproj/Localizable.strings:0
msgid "Selected" msgid "Selected"
@@ -163,7 +163,7 @@ msgstr "スキャンの種類:"
#: qt/directories_dialog.py:135 #: qt/directories_dialog.py:135
msgid "More Options" msgid "More Options"
msgstr "もっとオプション" msgstr "詳細設定"
#: qt/directories_dialog.py:139 cocoa/en.lproj/Localizable.strings:0 #: qt/directories_dialog.py:139 cocoa/en.lproj/Localizable.strings:0
msgid "Select folders to scan and press \"Scan\"." msgid "Select folders to scan and press \"Scan\"."
@@ -179,7 +179,7 @@ msgstr "スキャン"
#: qt/directories_dialog.py:230 #: qt/directories_dialog.py:230
msgid "Unsaved results" msgid "Unsaved results"
msgstr "保存されていない結果" msgstr "保存結果"
#: qt/directories_dialog.py:231 cocoa/en.lproj/Localizable.strings:0 #: qt/directories_dialog.py:231 cocoa/en.lproj/Localizable.strings:0
msgid "You have unsaved results, do you really want to quit?" msgid "You have unsaved results, do you really want to quit?"
@@ -280,27 +280,27 @@ msgstr "単語の重み付け"
#: qt/me/preferences_dialog.py:52 qt/se/preferences_dialog.py:32 #: qt/me/preferences_dialog.py:52 qt/se/preferences_dialog.py:32
#: cocoa/en.lproj/Localizable.strings:0 #: cocoa/en.lproj/Localizable.strings:0
msgid "Match similar words" msgid "Match similar words"
msgstr "類似の単語一致する" msgstr "類似の単語一致"
#: qt/me/preferences_dialog.py:54 qt/pe/preferences_dialog.py:21 #: qt/me/preferences_dialog.py:54 qt/pe/preferences_dialog.py:21
#: qt/se/preferences_dialog.py:34 cocoa/en.lproj/Localizable.strings:0 #: qt/se/preferences_dialog.py:34 cocoa/en.lproj/Localizable.strings:0
msgid "Can mix file kind" msgid "Can mix file kind"
msgstr "ファイルの種類を混在させることができる" msgstr "ファイルの種類を混在"
#: qt/me/preferences_dialog.py:56 qt/pe/preferences_dialog.py:23 #: qt/me/preferences_dialog.py:56 qt/pe/preferences_dialog.py:23
#: qt/se/preferences_dialog.py:36 cocoa/en.lproj/Localizable.strings:0 #: qt/se/preferences_dialog.py:36 cocoa/en.lproj/Localizable.strings:0
msgid "Use regular expressions when filtering" msgid "Use regular expressions when filtering"
msgstr "フィルタリング時に正規表現を使用する" msgstr "フィルタに正規表現を使用"
#: qt/me/preferences_dialog.py:58 qt/pe/preferences_dialog.py:25 #: qt/me/preferences_dialog.py:58 qt/pe/preferences_dialog.py:25
#: qt/se/preferences_dialog.py:38 cocoa/en.lproj/Localizable.strings:0 #: qt/se/preferences_dialog.py:38 cocoa/en.lproj/Localizable.strings:0
msgid "Remove empty folders on delete or move" msgid "Remove empty folders on delete or move"
msgstr "削除または移動時に空のフォルダを削除する" msgstr "削除や移動で空になったフォルダを削除"
#: qt/me/preferences_dialog.py:60 qt/pe/preferences_dialog.py:27 #: qt/me/preferences_dialog.py:60 qt/pe/preferences_dialog.py:27
#: qt/se/preferences_dialog.py:59 cocoa/en.lproj/Localizable.strings:0 #: qt/se/preferences_dialog.py:59 cocoa/en.lproj/Localizable.strings:0
msgid "Ignore duplicates hardlinking to the same file" msgid "Ignore duplicates hardlinking to the same file"
msgstr "同じファイルへの重複ハードリンクを無視する" msgstr "同じファイルへの重複ハードリンクを無視"
#: qt/me/preferences_dialog.py:62 qt/pe/preferences_dialog.py:29 #: qt/me/preferences_dialog.py:62 qt/pe/preferences_dialog.py:29
#: qt/se/preferences_dialog.py:62 cocoa/en.lproj/Localizable.strings:0 #: qt/se/preferences_dialog.py:62 cocoa/en.lproj/Localizable.strings:0
@@ -309,11 +309,11 @@ msgstr "デバッグモード(再起動が必要)"
#: qt/pe/preferences_dialog.py:19 cocoa/en.lproj/Localizable.strings:0 #: qt/pe/preferences_dialog.py:19 cocoa/en.lproj/Localizable.strings:0
msgid "Match pictures of different dimensions" msgid "Match pictures of different dimensions"
msgstr "異なる寸法の写真を一致させる" msgstr "異なるサイズの写真を一致"
#: qt/preferences_dialog.py:43 #: qt/preferences_dialog.py:43
msgid "Filter Hardness:" msgid "Filter Hardness:"
msgstr "フィルター硬度:" msgstr "フィルタの強さ:"
#: qt/preferences_dialog.py:69 #: qt/preferences_dialog.py:69
msgid "More Results" msgid "More Results"
@@ -325,7 +325,7 @@ msgstr "より少ない結果"
#: qt/preferences_dialog.py:81 #: qt/preferences_dialog.py:81
msgid "Font size:" msgid "Font size:"
msgstr "フォントサイズ:" msgstr "文字サイズ:"
#: qt/preferences_dialog.py:85 #: qt/preferences_dialog.py:85
msgid "Language:" msgid "Language:"
@@ -333,7 +333,7 @@ msgstr "言語:"
#: qt/preferences_dialog.py:91 cocoa/en.lproj/Localizable.strings:0 #: qt/preferences_dialog.py:91 cocoa/en.lproj/Localizable.strings:0
msgid "Copy and Move:" msgid "Copy and Move:"
msgstr "コピーと移動" msgstr "コピーと移動:"
#: qt/preferences_dialog.py:94 cocoa/en.lproj/Localizable.strings:0 #: qt/preferences_dialog.py:94 cocoa/en.lproj/Localizable.strings:0
msgid "Right in destination" msgid "Right in destination"
@@ -349,7 +349,7 @@ msgstr "絶対パスを再作成"
#: qt/preferences_dialog.py:99 #: qt/preferences_dialog.py:99
msgid "Custom Command (arguments: %d for dupe, %r for ref):" msgid "Custom Command (arguments: %d for dupe, %r for ref):"
msgstr "カスタムコマンド (引数重複の場合はd、参照の場合はr:" msgstr "カスタムコマンド (引数: dは重複・rは参照:"
#: qt/preferences_dialog.py:174 #: qt/preferences_dialog.py:174
msgid "dupeGuru has to restart for language changes to take effect." msgid "dupeGuru has to restart for language changes to take effect."
@@ -719,7 +719,7 @@ msgstr "ウィンドウ"
#: cocoa/en.lproj/Localizable.strings:0 #: cocoa/en.lproj/Localizable.strings:0
msgid "Zoom" msgid "Zoom"
msgstr "ズーム" msgstr "拡大"
#: qt\app.py:158 #: qt\app.py:158
msgid "Exclusion Filters" msgid "Exclusion Filters"
@@ -909,15 +909,15 @@ msgstr "結果"
#: qt\preferences_dialog.py:150 #: qt\preferences_dialog.py:150
msgid "General Interface" msgid "General Interface"
msgstr "一般的なインターフェイス" msgstr "一般"
#: qt\preferences_dialog.py:176 #: qt\preferences_dialog.py:176
msgid "Result Table" msgid "Result Table"
msgstr "結果" msgstr "結果"
#: qt\preferences_dialog.py:205 #: qt\preferences_dialog.py:205
msgid "Details Window" msgid "Details Window"
msgstr "詳細ウィンドウ" msgstr "詳細画面"
#: qt\preferences_dialog.py:285 #: qt\preferences_dialog.py:285
msgid "General" msgid "General"
@@ -985,7 +985,7 @@ msgstr ""
#: qt\about_box.py:31 #: qt\about_box.py:31
msgid "About {}" msgid "About {}"
msgstr "{}について" msgstr "{}について"
#: qt\about_box.py:47 #: qt\about_box.py:47
msgid "Version {}" msgid "Version {}"
@@ -997,7 +997,7 @@ msgstr ""
#: qt\about_box.py:54 #: qt\about_box.py:54
msgid "Licensed under GPLv3" msgid "Licensed under GPLv3"
msgstr "GPLv3としてライセンス供与。" msgstr "GPLv3のもとでライセンスされています"
#: qt\about_box.py:68 #: qt\about_box.py:68
msgid "No update available." msgid "No update available."
@@ -1013,7 +1013,7 @@ msgstr "エラーレポート"
#: qt\error_report_dialog.py:54 #: qt\error_report_dialog.py:54
msgid "Something went wrong. How about reporting the error?" msgid "Something went wrong. How about reporting the error?"
msgstr "何かがうまくいかなかった。 エラーを報告するのはどうですか?" msgstr "不明な理由により失敗しました。問題を報告しませんか?"
#: qt\error_report_dialog.py:60 #: qt\error_report_dialog.py:60
msgid "" msgid ""
@@ -1035,7 +1035,7 @@ msgstr ""
#: qt\error_report_dialog.py:80 #: qt\error_report_dialog.py:80
msgid "Go to Github" msgid "Go to Github"
msgstr "Githubに移動する" msgstr "Githubに移動"
#: qt\preferences.py:24 #: qt\preferences.py:24
msgid "Czech" msgid "Czech"

View File

@@ -7,19 +7,19 @@ These instructions are for the Qt version of the UI on macOS.
- [Python 3.7+][python] - [Python 3.7+][python]
- [Xcode 12.3][xcode] or just Xcode command line tools (older versions can be used if not interested in arm macs) - [Xcode 12.3][xcode] or just Xcode command line tools (older versions can be used if not interested in arm macs)
- [Homebrew][homebrew] - [Homebrew][homebrew]
- [qt5](https://www.qt.io/) - [qt6](https://www.qt.io/)
#### Prerequisite setup #### Prerequisite setup
1. Install Xcode if desired 1. Install Xcode if desired
2. Install [Homebrew][homebrew], if not on the path after install (arm based Macs) create `~/.zshrc` 2. Install [Homebrew][homebrew], if not on the path after install (arm based Macs) create `~/.zshrc`
with `export PATH="/opt/homebrew/bin:$PATH"`. Will need to reload terminal or source the file to take with `export PATH="/opt/homebrew/bin:$PATH"`. Will need to reload terminal or source the file to take
effect. effect.
3. Install qt5 with `brew`. If you are using a version of macos without system python 3.7+ then you will 3. Install qt6 with `brew`. If you are using a version of macos without system python 3.7+ then you will
also need to install that via brew or with pyenv. also need to install that via brew or with pyenv.
$ brew install qt5 $ brew install qt6
NOTE: Using `brew` to install qt5 is to allow pyqt5 to build without a native wheel NOTE: Using `brew` to install qt6 is to allow pyqt6 to build without a native wheel
available. If you are using an intel based mac you can probably skip this step. available. If you are using an intel based mac you can probably skip this step.
4. May need to launch a new terminal to have everything working. 4. May need to launch a new terminal to have everything working.
@@ -27,7 +27,7 @@ also need to install that via brew or with pyenv.
### With build.py ### With build.py
OSX comes with a version of python 3 by default in newer versions of OSX. To produce universal OSX comes with a version of python 3 by default in newer versions of OSX. To produce universal
builds either the 3.8 version shipped in macos or 3.9.1 or newer needs to be used. If needing to builds either the 3.8 version shipped in macos or 3.9.1 or newer needs to be used. If needing to
build pyqt5 from source then the first line below is needed, else it may be omitted. (Path shown is build pyqt6 from source then the first line below is needed, else it may be omitted. (Path shown is
for an arm mac.) for an arm mac.)
$ export PATH="/opt/homebrew/opt/qt/bin:$PATH" $ export PATH="/opt/homebrew/opt/qt/bin:$PATH"

View File

@@ -10,7 +10,7 @@ Vcs-Git: https://github.com/arsenetar/dupeguru.git
Package: {pkgname} Package: {pkgname}
Architecture: {arch} Architecture: {arch}
Depends: ${shlibs:Depends}, python3 (>=3.7), python3-pyqt5, python3-mutagen, python3-semantic-version Depends: ${shlibs:Depends}, python3 (>=3.7), python3-pyqt6, python3-mutagen, python3-semantic-version
Provides: dupeguru-se, dupeguru-me, dupeguru-pe Provides: dupeguru-se, dupeguru-me, dupeguru-pe
Replaces: dupeguru-se, dupeguru-me, dupeguru-pe Replaces: dupeguru-se, dupeguru-me, dupeguru-pe
Conflicts: dupeguru-se, dupeguru-me, dupeguru-pe Conflicts: dupeguru-se, dupeguru-me, dupeguru-pe

View File

@@ -6,36 +6,46 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import Qt, QCoreApplication, QTimer from PyQt6.QtCore import Qt, QCoreApplication, QTimer
from PyQt5.QtGui import QPixmap, QFont from PyQt6.QtGui import QPixmap, QFont, QShowEvent
from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QSizePolicy, QHBoxLayout, QVBoxLayout, QLabel from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QSizePolicy, QHBoxLayout, QVBoxLayout, QLabel, QWidget
from core.util import check_for_update from core.util import check_for_update
from qt.util import move_to_screen_center from qt.util import move_to_screen_center
from hscommon.trans import trget from hscommon.trans import trget
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from qt.app import DupeGuru
tr = trget("ui") tr = trget("ui")
class AboutBox(QDialog): class AboutBox(QDialog):
def __init__(self, parent, app, **kwargs): def __init__(self, parent: QWidget, app: "DupeGuru", **kwargs) -> None:
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint | Qt.MSWindowsFixedSizeDialogHint flags = (
Qt.WindowType.CustomizeWindowHint
| Qt.WindowType.WindowTitleHint
| Qt.WindowType.WindowSystemMenuHint
| Qt.WindowType.MSWindowsFixedSizeDialogHint
)
super().__init__(parent, flags, **kwargs) super().__init__(parent, flags, **kwargs)
self.app = app self.app = app
self._setupUi() self._setupUi()
self.button_box.accepted.connect(self.accept) def _setupUi(self) -> None:
self.button_box.rejected.connect(self.reject)
def _setupUi(self):
self.setWindowTitle(tr("About {}").format(QCoreApplication.instance().applicationName())) self.setWindowTitle(tr("About {}").format(QCoreApplication.instance().applicationName()))
size_policy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) size_policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.setSizePolicy(size_policy) self.setSizePolicy(size_policy)
main_layout = QHBoxLayout(self) main_layout = QHBoxLayout(self)
logo_label = QLabel() logo_label = QLabel()
logo_label.setPixmap(QPixmap(":/%s_big" % self.app.LOGO_NAME)) logo_label.setPixmap(QPixmap(f"images:{self.app.LOGO_NAME}_128.png"))
main_layout.addWidget(logo_label) main_layout.addWidget(logo_label)
detail_layout = QVBoxLayout() detail_layout = QVBoxLayout()
name_label = QLabel() name_label = QLabel()
font = QFont() font = QFont()
font.setWeight(75) font.setWeight(75)
@@ -43,26 +53,35 @@ class AboutBox(QDialog):
name_label.setFont(font) name_label.setFont(font)
name_label.setText(QCoreApplication.instance().applicationName()) name_label.setText(QCoreApplication.instance().applicationName())
detail_layout.addWidget(name_label) detail_layout.addWidget(name_label)
version_label = QLabel() version_label = QLabel()
version_label.setText(tr("Version {}").format(QCoreApplication.instance().applicationVersion())) version_label.setText(tr("Version {}").format(QCoreApplication.instance().applicationVersion()))
detail_layout.addWidget(version_label) detail_layout.addWidget(version_label)
self.update_label = QLabel(tr("Checking for updates...")) self.update_label = QLabel(tr("Checking for updates..."))
self.update_label.setTextInteractionFlags(Qt.TextBrowserInteraction) self.update_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.update_label.setOpenExternalLinks(True) self.update_label.setOpenExternalLinks(True)
detail_layout.addWidget(self.update_label) detail_layout.addWidget(self.update_label)
license_label = QLabel() license_label = QLabel()
license_label.setText(tr("Licensed under GPLv3")) license_label.setText(tr("Licensed under GPLv3"))
detail_layout.addWidget(license_label) detail_layout.addWidget(license_label)
spacer_label = QLabel() spacer_label = QLabel()
spacer_label.setFont(font) spacer_label.setFont(font)
detail_layout.addWidget(spacer_label) detail_layout.addWidget(spacer_label)
self.button_box = QDialogButtonBox()
self.button_box.setOrientation(Qt.Horizontal) button_box = QDialogButtonBox()
self.button_box.setStandardButtons(QDialogButtonBox.Ok) button_box.setOrientation(Qt.Orientation.Horizontal)
detail_layout.addWidget(self.button_box) button_box.setStandardButtons(QDialogButtonBox.StandardButton.Ok)
detail_layout.addWidget(button_box)
main_layout.addLayout(detail_layout) main_layout.addLayout(detail_layout)
def _check_for_update(self): button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
def _check_for_update(self) -> None:
update = check_for_update(QCoreApplication.instance().applicationVersion(), include_prerelease=False) update = check_for_update(QCoreApplication.instance().applicationVersion(), include_prerelease=False)
if update is None: if update is None:
self.update_label.setText(tr("No update available.")) self.update_label.setText(tr("No update available."))
@@ -71,7 +90,7 @@ class AboutBox(QDialog):
tr('New version {} available, download <a href="{}">here</a>.').format(update["version"], update["url"]) tr('New version {} available, download <a href="{}">here</a>.').format(update["version"], update["url"])
) )
def showEvent(self, event): def showEvent(self, event: QShowEvent) -> None:
self.update_label.setText(tr("Checking for updates...")) self.update_label.setText(tr("Checking for updates..."))
# have to do this here as the frameGeometry is not correct until shown # have to do this here as the frameGeometry is not correct until shown
move_to_screen_center(self) move_to_screen_center(self)

106
qt/app.py
View File

@@ -6,15 +6,18 @@
import sys import sys
import os.path as op import os.path as op
from typing import Type
from PyQt5.QtCore import QTimer, QObject, QUrl, pyqtSignal, Qt from PyQt6.QtCore import QTimer, QObject, QUrl, pyqtSignal, Qt
from PyQt5.QtGui import QColor, QDesktopServices, QPalette from PyQt6.QtGui import QColor, QDesktopServices, QPalette
from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox, QStyleFactory, QToolTip from PyQt6.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox, QStyleFactory, QToolTip
from hscommon.trans import trget from hscommon.trans import trget
from hscommon import desktop, plat from hscommon import desktop, plat
from qt.about_box import AboutBox from qt.about_box import AboutBox
from qt.details_dialog import DetailsDialog
from qt.preferences_dialog import PreferencesDialogBase
from qt.recent import Recent from qt.recent import Recent
from qt.util import create_actions from qt.util import create_actions
from qt.progress_window import ProgressWindow from qt.progress_window import ProgressWindow
@@ -42,10 +45,10 @@ tr = trget("ui")
class DupeGuru(QObject): class DupeGuru(QObject):
LOGO_NAME = "logo_se" LOGO_NAME = "dgse_logo"
NAME = "dupeGuru" NAME = "dupeGuru"
def __init__(self, **kwargs): def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.prefs = Preferences() self.prefs = Preferences()
self.prefs.load() self.prefs.load()
@@ -56,7 +59,7 @@ class DupeGuru(QObject):
self._setup() self._setup()
# --- Private # --- Private
def _setup(self): def _setup(self) -> None:
core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto
self._setupActions() self._setupActions()
self.details_dialog = None self.details_dialog = None
@@ -108,7 +111,7 @@ class DupeGuru(QObject):
# that the application haven't launched. # that the application haven't launched.
QTimer.singleShot(0, self.finishedLaunching) QTimer.singleShot(0, self.finishedLaunching)
def _setupActions(self): def _setupActions(self) -> None:
# Setup actions that are common to both the directory dialog and the results window. # Setup actions that are common to both the directory dialog and the results window.
# (name, shortcut, icon, desc, func) # (name, shortcut, icon, desc, func)
ACTIONS = [ ACTIONS = [
@@ -154,7 +157,7 @@ class DupeGuru(QObject):
] ]
create_actions(ACTIONS, self) create_actions(ACTIONS, self)
def _update_options(self): def _update_options(self) -> None:
self.model.options["mix_file_kind"] = self.prefs.mix_file_kind self.model.options["mix_file_kind"] = self.prefs.mix_file_kind
self.model.options["escape_filter_regexp"] = not self.prefs.use_regexp self.model.options["escape_filter_regexp"] = not self.prefs.use_regexp
self.model.options["clean_empty_dirs"] = self.prefs.remove_empty_folders self.model.options["clean_empty_dirs"] = self.prefs.remove_empty_folders
@@ -200,7 +203,7 @@ class DupeGuru(QObject):
self._set_style("dark" if self.prefs.use_dark_style else "light") self._set_style("dark" if self.prefs.use_dark_style else "light")
# --- Private # --- Private
def _get_details_dialog_class(self): def _get_details_dialog_class(self) -> Type[DetailsDialog]:
if self.model.app_mode == AppMode.PICTURE: if self.model.app_mode == AppMode.PICTURE:
return DetailsDialogPicture return DetailsDialogPicture
elif self.model.app_mode == AppMode.MUSIC: elif self.model.app_mode == AppMode.MUSIC:
@@ -208,7 +211,7 @@ class DupeGuru(QObject):
else: else:
return DetailsDialogStandard return DetailsDialogStandard
def _get_preferences_dialog_class(self): def _get_preferences_dialog_class(self) -> Type[PreferencesDialogBase]:
if self.model.app_mode == AppMode.PICTURE: if self.model.app_mode == AppMode.PICTURE:
return PreferencesDialogPicture return PreferencesDialogPicture
elif self.model.app_mode == AppMode.MUSIC: elif self.model.app_mode == AppMode.MUSIC:
@@ -216,7 +219,7 @@ class DupeGuru(QObject):
else: else:
return PreferencesDialogStandard return PreferencesDialogStandard
def _set_style(self, style="light"): def _set_style(self, style: str = "light") -> None:
# Only support this feature on windows for now # Only support this feature on windows for now
if not plat.ISWINDOWS: if not plat.ISWINDOWS:
return return
@@ -224,18 +227,18 @@ class DupeGuru(QObject):
QApplication.setStyle(QStyleFactory.create("Fusion")) QApplication.setStyle(QStyleFactory.create("Fusion"))
palette = QApplication.style().standardPalette() palette = QApplication.style().standardPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53)) palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.WindowText, Qt.white) palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25)) palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53)) palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(53, 53, 53)) palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.ToolTipText, Qt.white) palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.Text, Qt.white) palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53)) palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53))
palette.setColor(QPalette.ColorRole.ButtonText, Qt.white) palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white)
palette.setColor(QPalette.ColorRole.BrightText, Qt.red) palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red)
palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218))
palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218))
palette.setColor(QPalette.ColorRole.HighlightedText, Qt.black) palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black)
palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, QColor(164, 166, 168)) palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, QColor(164, 166, 168))
palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, QColor(164, 166, 168)) palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, QColor(164, 166, 168))
palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, QColor(164, 166, 168)) palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, QColor(164, 166, 168))
@@ -250,29 +253,31 @@ class DupeGuru(QObject):
QApplication.setPalette(palette) QApplication.setPalette(palette)
# --- Public # --- Public
def add_selected_to_ignore_list(self): def add_selected_to_ignore_list(self) -> None:
self.model.add_selected_to_ignore_list() self.model.add_selected_to_ignore_list()
def remove_selected(self): def remove_selected(self) -> None:
self.model.remove_selected(self) self.model.remove_selected()
def confirm(self, title, msg, default_button=QMessageBox.Yes): def confirm(
self, title: str, msg: str, default_button: QMessageBox.StandardButton = QMessageBox.StandardButton.Yes
) -> bool:
active = QApplication.activeWindow() active = QApplication.activeWindow()
buttons = QMessageBox.Yes | QMessageBox.No buttons = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
answer = QMessageBox.question(active, title, msg, buttons, default_button) answer = QMessageBox.question(active, title, msg, buttons, default_button)
return answer == QMessageBox.Yes return answer == QMessageBox.StandardButton.Yes
def invokeCustomCommand(self): def invokeCustomCommand(self) -> None:
self.model.invoke_custom_command() self.model.invoke_custom_command()
def show_details(self): def show_details(self) -> None:
if self.details_dialog is not None: if self.details_dialog is not None:
if not self.details_dialog.isVisible(): if not self.details_dialog.isVisible():
self.details_dialog.show() self.details_dialog.show()
else: else:
self.details_dialog.hide() self.details_dialog.hide()
def showResultsWindow(self): def showResultsWindow(self) -> None:
if self.resultWindow is not None: if self.resultWindow is not None:
if self.use_tabs: if self.use_tabs:
if self.main_window.indexOfWidget(self.resultWindow) < 0: if self.main_window.indexOfWidget(self.resultWindow) < 0:
@@ -282,14 +287,14 @@ class DupeGuru(QObject):
else: else:
self.resultWindow.show() self.resultWindow.show()
def showDirectoriesWindow(self): def showDirectoriesWindow(self) -> None:
if self.directories_dialog is not None: if self.directories_dialog is not None:
if self.use_tabs: if self.use_tabs:
self.main_window.showTab(self.directories_dialog) self.main_window.showTab(self.directories_dialog)
else: else:
self.directories_dialog.show() self.directories_dialog.show()
def shutdown(self): def shutdown(self) -> None:
self.willSavePrefs.emit() self.willSavePrefs.emit()
self.prefs.save() self.prefs.save()
self.model.save() self.model.save()
@@ -304,7 +309,7 @@ class DupeGuru(QObject):
SIGTERM = pyqtSignal() SIGTERM = pyqtSignal()
# --- Events # --- Events
def finishedLaunching(self): def finishedLaunching(self) -> None:
if sys.getfilesystemencoding() == "ascii": if sys.getfilesystemencoding() == "ascii":
# No need to localize this, it's a debugging message. # No need to localize this, it's a debugging message.
msg = ( msg = (
@@ -324,28 +329,28 @@ class DupeGuru(QObject):
self.model.load_from(results) self.model.load_from(results)
self.recentResults.insertItem(results) self.recentResults.insertItem(results)
def clearCacheTriggered(self): def clearCacheTriggered(self) -> None:
title = tr("Clear Cache") title = tr("Clear Cache")
msg = tr("Do you really want to clear the cache? This will remove all cached file hashes and picture analysis.") msg = tr("Do you really want to clear the cache? This will remove all cached file hashes and picture analysis.")
if self.confirm(title, msg, QMessageBox.No): if self.confirm(title, msg, QMessageBox.StandardButton.No):
self.model.clear_picture_cache() self.model.clear_picture_cache()
self.model.clear_hash_cache() self.model.clear_hash_cache()
active = QApplication.activeWindow() active = QApplication.activeWindow()
QMessageBox.information(active, title, tr("Cache cleared.")) QMessageBox.information(active, title, tr("Cache cleared."))
def ignoreListTriggered(self): def ignoreListTriggered(self) -> None:
if self.use_tabs: if self.use_tabs:
self.showTriggeredTabbedDialog(self.ignoreListDialog, tr("Ignore List")) self.showTriggeredTabbedDialog(self.ignoreListDialog, tr("Ignore List"))
else: # floating windows else: # floating windows
self.model.ignore_list_dialog.show() self.model.ignore_list_dialog.show()
def excludeListTriggered(self): def excludeListTriggered(self) -> None:
if self.use_tabs: if self.use_tabs:
self.showTriggeredTabbedDialog(self.excludeListDialog, tr("Exclusion Filters")) self.showTriggeredTabbedDialog(self.excludeListDialog, tr("Exclusion Filters"))
else: # floating windows else: # floating windows
self.model.exclude_list_dialog.show() self.model.exclude_list_dialog.show()
def showTriggeredTabbedDialog(self, dialog, desc_string): def showTriggeredTabbedDialog(self, dialog, desc_string: str) -> None:
"""Add tab for dialog, name the tab with desc_string, then show it.""" """Add tab for dialog, name the tab with desc_string, then show it."""
index = self.main_window.indexOfWidget(dialog) index = self.main_window.indexOfWidget(dialog)
# Create the tab if it doesn't exist already # Create the tab if it doesn't exist already
@@ -354,23 +359,22 @@ class DupeGuru(QObject):
# Show the tab for that widget # Show the tab for that widget
self.main_window.setCurrentIndex(index) self.main_window.setCurrentIndex(index)
def openDebugLogTriggered(self): def openDebugLogTriggered(self) -> None:
debug_log_path = op.join(self.model.appdata, "debug.log") debug_log_path = op.join(self.model.appdata, "debug.log")
desktop.open_path(debug_log_path) desktop.open_path(debug_log_path)
def preferencesTriggered(self): def preferencesTriggered(self) -> None:
preferences_dialog = self._get_preferences_dialog_class()( preferences_dialog = self._get_preferences_dialog_class()(
self.main_window if self.main_window else self.directories_dialog, self self.main_window if self.main_window else self.directories_dialog, self
) )
preferences_dialog.load() preferences_dialog.load()
result = preferences_dialog.exec() result = preferences_dialog.exec()
if result == QDialog.Accepted: if result == QDialog.DialogCode.Accepted:
preferences_dialog.save() preferences_dialog.save()
self.prefs.save() self.prefs.save()
self._update_options() self._update_options()
preferences_dialog.setParent(None)
def quitTriggered(self): def quitTriggered(self) -> None:
if self.details_dialog is not None: if self.details_dialog is not None:
self.details_dialog.close() self.details_dialog.close()
@@ -379,10 +383,10 @@ class DupeGuru(QObject):
else: else:
self.directories_dialog.close() self.directories_dialog.close()
def showAboutBoxTriggered(self): def showAboutBoxTriggered(self) -> None:
self.about_box.show() self.about_box.show()
def showHelpTriggered(self): def showHelpTriggered(self) -> None:
base_path = platform.HELP_PATH base_path = platform.HELP_PATH
help_path = op.abspath(op.join(base_path, "index.html")) help_path = op.abspath(op.join(base_path, "index.html"))
if op.exists(help_path): if op.exists(help_path):
@@ -391,7 +395,7 @@ class DupeGuru(QObject):
url = QUrl("https://dupeguru.voltaicideas.net/help/en/") url = QUrl("https://dupeguru.voltaicideas.net/help/en/")
QDesktopServices.openUrl(url) QDesktopServices.openUrl(url)
def handleSIGTERM(self): def handleSIGTERM(self) -> None:
self.shutdown() self.shutdown()
# --- model --> view # --- model --> view
@@ -401,20 +405,20 @@ class DupeGuru(QObject):
def set_default(self, key, value): def set_default(self, key, value):
self.prefs.set_value(key, value) self.prefs.set_value(key, value)
def show_message(self, msg): def show_message(self, msg: str) -> None:
window = QApplication.activeWindow() window = QApplication.activeWindow()
QMessageBox.information(window, "", msg) QMessageBox.information(window, "", msg)
def ask_yes_no(self, prompt): def ask_yes_no(self, prompt: str) -> bool:
return self.confirm("", prompt) return self.confirm("", prompt)
def create_results_window(self): def create_results_window(self) -> None:
"""Creates resultWindow and details_dialog depending on the selected ``app_mode``.""" """Creates resultWindow and details_dialog depending on the selected ``app_mode``."""
if self.details_dialog is not None: if self.details_dialog is not None:
# The object is not deleted entirely, avoid saving its geometry in the future # The object is not deleted entirely, avoid saving its geometry in the future
# self.willSavePrefs.disconnect(self.details_dialog.appWillSavePrefs) # self.willSavePrefs.disconnect(self.details_dialog.appWillSavePrefs)
# or simply delete it on close which is probably cleaner: # or simply delete it on close which is probably cleaner:
self.details_dialog.setAttribute(Qt.WA_DeleteOnClose) self.details_dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.details_dialog.close() self.details_dialog.close()
# if we don't do the following, Qt will crash when we recreate the Results dialog # if we don't do the following, Qt will crash when we recreate the Results dialog
self.details_dialog.setParent(None) self.details_dialog.setParent(None)
@@ -429,17 +433,17 @@ class DupeGuru(QObject):
self.directories_dialog._updateActionsState() self.directories_dialog._updateActionsState()
self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self) self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self)
def show_results_window(self): def show_results_window(self) -> None:
self.showResultsWindow() self.showResultsWindow()
def show_problem_dialog(self): def show_problem_dialog(self) -> None:
self.problemDialog.show() self.problemDialog.show()
def select_dest_folder(self, prompt): def select_dest_folder(self, prompt: str) -> str:
flags = QFileDialog.ShowDirsOnly flags = QFileDialog.Option.ShowDirsOnly
return QFileDialog.getExistingDirectory(self.resultWindow, prompt, "", flags) return QFileDialog.getExistingDirectory(self.resultWindow, prompt, "", flags)
def select_dest_file(self, prompt, extension): def select_dest_file(self, prompt: str, extension: str) -> str:
files = tr("{} file (*.{})").format(extension.upper(), extension) files = tr("{} file (*.{})").format(extension.upper(), extension)
destination, chosen_filter = QFileDialog.getSaveFileName(self.resultWindow, prompt, "", files) destination, chosen_filter = QFileDialog.getSaveFileName(self.resultWindow, prompt, "", files)
if not destination.endswith(f".{extension}"): if not destination.endswith(f".{extension}"):

View File

@@ -11,8 +11,8 @@ import sys
import os import os
import platform import platform
from PyQt5.QtCore import Qt, QCoreApplication, QSize from PyQt6.QtCore import Qt, QCoreApplication, QSize
from PyQt5.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QDialog,
QVBoxLayout, QVBoxLayout,
QHBoxLayout, QHBoxLayout,
@@ -30,7 +30,7 @@ tr = trget("ui")
class ErrorReportDialog(QDialog): class ErrorReportDialog(QDialog):
def __init__(self, parent, github_url, error, **kwargs): def __init__(self, parent, github_url, error, **kwargs):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint
super().__init__(parent, flags, **kwargs) super().__init__(parent, flags, **kwargs)
self._setupUi() self._setupUi()
name = QCoreApplication.applicationName() name = QCoreApplication.applicationName()
@@ -40,23 +40,23 @@ class ErrorReportDialog(QDialog):
) )
# Under windows, we end up with an error report without linesep if we don't mangle it # Under windows, we end up with an error report without linesep if we don't mangle it
error_text = error_text.replace("\n", os.linesep) error_text = error_text.replace("\n", os.linesep)
self.errorTextEdit.setPlainText(error_text) self.error_text_edit.setPlainText(error_text)
self.github_url = github_url self.github_url = github_url
self.sendButton.clicked.connect(self.goToGithub)
self.dontSendButton.clicked.connect(self.reject)
def _setupUi(self): def _setupUi(self):
self.setWindowTitle(tr("Error Report")) self.setWindowTitle(tr("Error Report"))
self.resize(553, 349) self.resize(553, 349)
self.verticalLayout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
self.label = QLabel(self)
self.label.setText(tr("Something went wrong. How about reporting the error?")) title_label = QLabel(self)
self.label.setWordWrap(True) title_label.setText(tr("Something went wrong. How about reporting the error?"))
self.verticalLayout.addWidget(self.label) title_label.setWordWrap(True)
self.errorTextEdit = QPlainTextEdit(self) main_layout.addWidget(title_label)
self.errorTextEdit.setReadOnly(True)
self.verticalLayout.addWidget(self.errorTextEdit) self.error_text_edit = QPlainTextEdit(self)
self.error_text_edit.setReadOnly(True)
main_layout.addWidget(self.error_text_edit)
msg = tr( msg = tr(
"Error reports should be reported as Github issues. You can copy the error traceback " "Error reports should be reported as Github issues. You can copy the error traceback "
"above and paste it in a new issue.\n\nPlease make sure to run a search for any already " "above and paste it in a new issue.\n\nPlease make sure to run a search for any already "
@@ -67,21 +67,28 @@ class ErrorReportDialog(QDialog):
"Although the application should continue to run after this error, it may be in an " "Although the application should continue to run after this error, it may be in an "
"unstable state, so it is recommended that you restart the application." "unstable state, so it is recommended that you restart the application."
) )
self.label2 = QLabel(msg) instructions_label = QLabel(msg)
self.label2.setWordWrap(True) instructions_label.setWordWrap(True)
self.verticalLayout.addWidget(self.label2) main_layout.addWidget(instructions_label)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.addItem(horizontal_spacer()) button_layout = QHBoxLayout()
self.dontSendButton = QPushButton(self) button_layout.addItem(horizontal_spacer())
self.dontSendButton.setText(tr("Close"))
self.dontSendButton.setMinimumSize(QSize(110, 0)) close_button = QPushButton(self)
self.horizontalLayout.addWidget(self.dontSendButton) close_button.setText(tr("Close"))
self.sendButton = QPushButton(self) close_button.setMinimumSize(QSize(110, 0))
self.sendButton.setText(tr("Go to Github")) button_layout.addWidget(close_button)
self.sendButton.setMinimumSize(QSize(110, 0))
self.sendButton.setDefault(True) report_button = QPushButton(self)
self.horizontalLayout.addWidget(self.sendButton) report_button.setText(tr("Go to Github"))
self.verticalLayout.addLayout(self.horizontalLayout) report_button.setMinimumSize(QSize(110, 0))
report_button.setDefault(True)
button_layout.addWidget(report_button)
main_layout.addLayout(button_layout)
report_button.clicked.connect(self.goToGithub)
close_button.clicked.connect(self.reject)
def goToGithub(self): def goToGithub(self):
open_url(self.github_url) open_url(self.github_url)
@@ -91,6 +98,6 @@ def install_excepthook(github_url):
def my_excepthook(exctype, value, tb): def my_excepthook(exctype, value, tb):
s = "".join(traceback.format_exception(exctype, value, tb)) s = "".join(traceback.format_exception(exctype, value, tb))
dialog = ErrorReportDialog(None, github_url, s) dialog = ErrorReportDialog(None, github_url, s)
dialog.exec_() dialog.exec()
sys.excepthook = my_excepthook sys.excepthook = my_excepthook

View File

@@ -4,8 +4,8 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import QSize from PyQt6.QtCore import QSize
from PyQt5.QtWidgets import QAbstractItemView from PyQt6.QtWidgets import QAbstractItemView
from hscommon.trans import trget from hscommon.trans import trget
from qt.details_dialog import DetailsDialog as DetailsDialogBase from qt.details_dialog import DetailsDialog as DetailsDialogBase
@@ -15,12 +15,12 @@ tr = trget("ui")
class DetailsDialog(DetailsDialogBase): class DetailsDialog(DetailsDialogBase):
def _setupUi(self): def _setupUi(self) -> None:
self.setWindowTitle(tr("Details")) self.setWindowTitle(tr("Details"))
self.resize(502, 295) self.resize(502, 295)
self.setMinimumSize(QSize(250, 250)) self.setMinimumSize(QSize(250, 250))
self.tableView = DetailsTable(self) self.tableView = DetailsTable(self)
self.tableView.setAlternatingRowColors(True) self.tableView.setAlternatingRowColors(True)
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.tableView.setShowGrid(False) self.tableView.setShowGrid(False)
self.setWidget(self.tableView) self.setWidget(self.tableView)

View File

@@ -4,27 +4,22 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import QSize from typing import Callable
from PyQt5.QtWidgets import ( from PyQt6.QtCore import QSize
QVBoxLayout, from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QWidget, QCheckBox
QHBoxLayout,
QLabel,
QSizePolicy,
QSpacerItem,
QWidget,
)
from hscommon.trans import trget from hscommon.trans import trget
from core.app import AppMode from core.app import AppMode
from core.scanner import ScanType from core.scanner import ScanType
from qt.preferences import Preferences
from qt.preferences_dialog import PreferencesDialogBase from qt.preferences_dialog import PreferencesDialogBase, Sections
tr = trget("ui") tr = trget("ui")
class PreferencesDialog(PreferencesDialogBase): class PreferencesDialog(PreferencesDialogBase):
def _setupPreferenceWidgets(self): def _setupPreferenceWidgets(self) -> None:
self._setupFilterHardnessBox() self._setupFilterHardnessBox()
self.widgetsVLayout.addLayout(self.filterHardnessHLayout) self.widgetsVLayout.addLayout(self.filterHardnessHLayout)
self.widget = QWidget(self) self.widget = QWidget(self)
@@ -37,7 +32,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.verticalLayout_4.addWidget(self.label_6) self.verticalLayout_4.addWidget(self.label_6)
self.horizontalLayout_2 = QHBoxLayout() self.horizontalLayout_2 = QHBoxLayout()
self.horizontalLayout_2.setSpacing(0) self.horizontalLayout_2.setSpacing(0)
spacer_item = QSpacerItem(15, 20, QSizePolicy.Fixed, QSizePolicy.Minimum) spacer_item = QSpacerItem(15, 20, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
self.horizontalLayout_2.addItem(spacer_item) self.horizontalLayout_2.addItem(spacer_item)
self._setupAddCheckbox("tagTrackBox", tr("Track"), self.widget) self._setupAddCheckbox("tagTrackBox", tr("Track"), self.widget)
self.horizontalLayout_2.addWidget(self.tagTrackBox) self.horizontalLayout_2.addWidget(self.tagTrackBox)
@@ -70,7 +65,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches) self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches)
self._setupBottomPart() self._setupBottomPart()
def _load(self, prefs, setchecked, section): def _load(self, prefs: Preferences, setchecked: Callable[[QCheckBox, bool], None], section: Sections) -> None:
setchecked(self.tagTrackBox, prefs.scan_tag_track) setchecked(self.tagTrackBox, prefs.scan_tag_track)
setchecked(self.tagArtistBox, prefs.scan_tag_artist) setchecked(self.tagArtistBox, prefs.scan_tag_artist)
setchecked(self.tagAlbumBox, prefs.scan_tag_album) setchecked(self.tagAlbumBox, prefs.scan_tag_album)
@@ -99,7 +94,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.tagGenreBox.setEnabled(tag_based) self.tagGenreBox.setEnabled(tag_based)
self.tagYearBox.setEnabled(tag_based) self.tagYearBox.setEnabled(tag_based)
def _save(self, prefs, ischecked): def _save(self, prefs: Preferences, ischecked: Callable[[QCheckBox], bool]) -> None:
prefs.scan_tag_track = ischecked(self.tagTrackBox) prefs.scan_tag_track = ischecked(self.tagTrackBox)
prefs.scan_tag_artist = ischecked(self.tagArtistBox) prefs.scan_tag_artist = ischecked(self.tagArtistBox)
prefs.scan_tag_album = ischecked(self.tagAlbumBox) prefs.scan_tag_album = ischecked(self.tagAlbumBox)

View File

@@ -1,5 +1,5 @@
from typing import Tuple, List, Union from typing import Tuple, List, Union
from PyQt5.QtGui import QImage from PyQt6.QtGui import QImage
_block = Tuple[int, int, int] _block = Tuple[int, int, int]

View File

@@ -4,21 +4,23 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtWidgets import QFormLayout from typing import Callable
from PyQt5.QtCore import Qt from PyQt6.QtWidgets import QFormLayout, QCheckBox
from PyQt6.QtCore import Qt
from hscommon.trans import trget from hscommon.trans import trget
from hscommon.plat import ISLINUX from hscommon.plat import ISLINUX
from qt.preferences import Preferences
from qt.radio_box import RadioBox from qt.radio_box import RadioBox
from core.scanner import ScanType from core.scanner import ScanType
from core.app import AppMode from core.app import AppMode
from qt.preferences_dialog import PreferencesDialogBase from qt.preferences_dialog import PreferencesDialogBase, Sections
tr = trget("ui") tr = trget("ui")
class PreferencesDialog(PreferencesDialogBase): class PreferencesDialog(PreferencesDialogBase):
def _setupPreferenceWidgets(self): def _setupPreferenceWidgets(self) -> None:
self._setupFilterHardnessBox() self._setupFilterHardnessBox()
self.widgetsVLayout.addLayout(self.filterHardnessHLayout) self.widgetsVLayout.addLayout(self.filterHardnessHLayout)
self._setupAddCheckbox("matchScaledBox", tr("Match pictures of different dimensions")) self._setupAddCheckbox("matchScaledBox", tr("Match pictures of different dimensions"))
@@ -37,12 +39,12 @@ class PreferencesDialog(PreferencesDialogBase):
self.cacheTypeRadio = RadioBox(self, items=["Sqlite", "Shelve"], spread=False) self.cacheTypeRadio = RadioBox(self, items=["Sqlite", "Shelve"], spread=False)
cache_form = QFormLayout() cache_form = QFormLayout()
cache_form.setLabelAlignment(Qt.AlignLeft) cache_form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
cache_form.addRow(tr("Picture cache mode:"), self.cacheTypeRadio) cache_form.addRow(tr("Picture cache mode:"), self.cacheTypeRadio)
self.widgetsVLayout.addLayout(cache_form) self.widgetsVLayout.addLayout(cache_form)
self._setupBottomPart() self._setupBottomPart()
def _setupDisplayPage(self): def _setupDisplayPage(self) -> None:
super()._setupDisplayPage() super()._setupDisplayPage()
self._setupAddCheckbox("details_dialog_override_theme_icons", tr("Override theme icons in viewer toolbar")) self._setupAddCheckbox("details_dialog_override_theme_icons", tr("Override theme icons in viewer toolbar"))
self.details_dialog_override_theme_icons.setToolTip( self.details_dialog_override_theme_icons.setToolTip(
@@ -62,7 +64,7 @@ show scrollbars to span the view around"
) )
self.details_groupbox_layout.insertWidget(index + 2, self.details_dialog_viewers_show_scrollbars) self.details_groupbox_layout.insertWidget(index + 2, self.details_dialog_viewers_show_scrollbars)
def _load(self, prefs, setchecked, section): def _load(self, prefs: Preferences, setchecked: Callable[[QCheckBox, bool], None], section: Sections) -> None:
setchecked(self.matchScaledBox, prefs.match_scaled) setchecked(self.matchScaledBox, prefs.match_scaled)
self.cacheTypeRadio.selected_index = 1 if prefs.picture_cache_type == "shelve" else 0 self.cacheTypeRadio.selected_index = 1 if prefs.picture_cache_type == "shelve" else 0
@@ -73,7 +75,7 @@ show scrollbars to span the view around"
setchecked(self.details_dialog_override_theme_icons, prefs.details_dialog_override_theme_icons) setchecked(self.details_dialog_override_theme_icons, prefs.details_dialog_override_theme_icons)
setchecked(self.details_dialog_viewers_show_scrollbars, prefs.details_dialog_viewers_show_scrollbars) setchecked(self.details_dialog_viewers_show_scrollbars, prefs.details_dialog_viewers_show_scrollbars)
def _save(self, prefs, ischecked): def _save(self, prefs: Preferences, ischecked: Callable[[QCheckBox], bool]) -> None:
prefs.match_scaled = ischecked(self.matchScaledBox) prefs.match_scaled = ischecked(self.matchScaledBox)
prefs.picture_cache_type = "shelve" if self.cacheTypeRadio.selected_index == 1 else "sqlite" prefs.picture_cache_type = "shelve" if self.cacheTypeRadio.selected_index == 1 else "sqlite"
prefs.details_dialog_override_theme_icons = ischecked(self.details_dialog_override_theme_icons) prefs.details_dialog_override_theme_icons = ischecked(self.details_dialog_override_theme_icons)

View File

@@ -4,9 +4,10 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtWidgets import QApplication, QDockWidget from typing import Any, Tuple
from PyQt5.QtCore import Qt, QRect, QObject, pyqtSignal from PyQt6.QtWidgets import QApplication, QDockWidget
from PyQt5.QtGui import QColor from PyQt6.QtCore import Qt, QRect, QObject, pyqtSignal
from PyQt6.QtGui import QColor
from hscommon import trans from hscommon import trans
from hscommon.plat import ISLINUX from hscommon.plat import ISLINUX
@@ -126,7 +127,7 @@ class PreferencesBase(QObject):
def set_value(self, name, value): def set_value(self, name, value):
self._settings.setValue(name, _normalize_for_serialization(value)) self._settings.setValue(name, _normalize_for_serialization(value))
def saveGeometry(self, name, widget): def saveGeometry(self, name, widget) -> None:
# We save geometry under a 7-sized int array: first item is a flag # We save geometry under a 7-sized int array: first item is a flag
# for whether the widget is maximized, second item is a flag for whether # for whether the widget is maximized, second item is a flag for whether
# the widget is docked, third item is a Qt::DockWidgetArea enum value, # the widget is docked, third item is a Qt::DockWidgetArea enum value,
@@ -138,12 +139,12 @@ class PreferencesBase(QObject):
rect_as_list = [r.x(), r.y(), r.width(), r.height()] rect_as_list = [r.x(), r.y(), r.width(), r.height()]
self.set_value(name, [m, d, area] + rect_as_list) self.set_value(name, [m, d, area] + rect_as_list)
def restoreGeometry(self, name, widget): def restoreGeometry(self, name, widget) -> Tuple[bool, Any]:
geometry = self.get_value(name) geometry = self.get_value(name)
if geometry and len(geometry) == 7: if geometry and len(geometry) == 7:
m, d, area, x, y, w, h = geometry m, d, area, x, y, w, h = geometry
if m: if m:
widget.setWindowState(Qt.WindowMaximized) widget.setWindowState(Qt.WindowState.WindowMaximized)
else: else:
r = QRect(x, y, w, h) r = QRect(x, y, w, h)
widget.setGeometry(r) widget.setGeometry(r)
@@ -154,7 +155,7 @@ class PreferencesBase(QObject):
class Preferences(PreferencesBase): class Preferences(PreferencesBase):
def _load_values(self, settings): def _load_values(self, settings) -> None:
get = self.get_value get = self.get_value
self.filter_hardness = get("FilterHardness", self.filter_hardness) self.filter_hardness = get("FilterHardness", self.filter_hardness)
self.mix_file_kind = get("MixFileKind", self.mix_file_kind) self.mix_file_kind = get("MixFileKind", self.mix_file_kind)
@@ -225,7 +226,7 @@ class Preferences(PreferencesBase):
self.match_scaled = get("MatchScaled", self.match_scaled) self.match_scaled = get("MatchScaled", self.match_scaled)
self.picture_cache_type = get("PictureCacheType", self.picture_cache_type) self.picture_cache_type = get("PictureCacheType", self.picture_cache_type)
def reset(self): def reset(self) -> None:
self.filter_hardness = 95 self.filter_hardness = 95
self.mix_file_kind = True self.mix_file_kind = True
self.use_regexp = False self.use_regexp = False
@@ -247,8 +248,8 @@ class Preferences(PreferencesBase):
# By default use internal icons on platforms other than Linux for now # By default use internal icons on platforms other than Linux for now
self.details_dialog_override_theme_icons = False if not ISLINUX else True self.details_dialog_override_theme_icons = False if not ISLINUX else True
self.details_dialog_viewers_show_scrollbars = True self.details_dialog_viewers_show_scrollbars = True
self.result_table_ref_foreground_color = QColor(Qt.blue) self.result_table_ref_foreground_color = QColor(Qt.GlobalColor.blue)
self.result_table_ref_background_color = QColor(Qt.lightGray) self.result_table_ref_background_color = QColor(Qt.GlobalColor.lightGray)
self.result_table_delta_foreground_color = QColor(255, 142, 40) # orange self.result_table_delta_foreground_color = QColor(255, 142, 40) # orange
self.resultWindowIsMaximized = False self.resultWindowIsMaximized = False
self.resultWindowRect = None self.resultWindowRect = None
@@ -276,7 +277,7 @@ class Preferences(PreferencesBase):
self.match_scaled = False self.match_scaled = False
self.picture_cache_type = "sqlite" self.picture_cache_type = "sqlite"
def _save_values(self, settings): def _save_values(self, settings) -> None:
set_ = self.set_value set_ = self.set_value
set_("FilterHardness", self.filter_hardness) set_("FilterHardness", self.filter_hardness)
set_("MixFileKind", self.mix_file_kind) set_("MixFileKind", self.mix_file_kind)

View File

@@ -4,8 +4,9 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import Qt, QSize, pyqtSlot from typing import Union
from PyQt5.QtWidgets import ( from PyQt6.QtCore import Qt, QSize, pyqtSlot
from PyQt6.QtWidgets import (
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QVBoxLayout, QVBoxLayout,
@@ -28,7 +29,7 @@ from PyQt5.QtWidgets import (
QGroupBox, QGroupBox,
QFormLayout, QFormLayout,
) )
from PyQt5.QtGui import QPixmap, QIcon from PyQt6.QtGui import QPixmap, QIcon, QShowEvent
from hscommon import desktop, plat from hscommon import desktop, plat
from hscommon.trans import trget from hscommon.trans import trget
@@ -39,6 +40,11 @@ from enum import Flag, auto
from qt.preferences import Preferences from qt.preferences import Preferences
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from qt.app import DupeGuru
tr = trget("ui") tr = trget("ui")
@@ -52,8 +58,8 @@ class Sections(Flag):
class PreferencesDialogBase(QDialog): class PreferencesDialogBase(QDialog):
def __init__(self, parent, app, **kwargs): def __init__(self, parent: QWidget, app: "DupeGuru", **kwargs):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint
super().__init__(parent, flags, **kwargs) super().__init__(parent, flags, **kwargs)
self.app = app self.app = app
self.supportedLanguages = dict(sorted(get_langnames().items(), key=lambda item: item[1])) self.supportedLanguages = dict(sorted(get_langnames().items(), key=lambda item: item[1]))
@@ -65,7 +71,7 @@ class PreferencesDialogBase(QDialog):
self.buttonBox.accepted.connect(self.accept) self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject) self.buttonBox.rejected.connect(self.reject)
def _setupFilterHardnessBox(self): def _setupFilterHardnessBox(self) -> None:
self.filterHardnessHLayout = QHBoxLayout() self.filterHardnessHLayout = QHBoxLayout()
self.filterHardnessLabel = QLabel(self) self.filterHardnessLabel = QLabel(self)
self.filterHardnessLabel.setText(tr("Filter Hardness:")) self.filterHardnessLabel.setText(tr("Filter Hardness:"))
@@ -76,7 +82,7 @@ class PreferencesDialogBase(QDialog):
self.filterHardnessHLayoutSub1 = QHBoxLayout() self.filterHardnessHLayoutSub1 = QHBoxLayout()
self.filterHardnessHLayoutSub1.setSpacing(12) self.filterHardnessHLayoutSub1.setSpacing(12)
self.filterHardnessSlider = QSlider(self) self.filterHardnessSlider = QSlider(self)
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) size_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
size_policy.setHorizontalStretch(0) size_policy.setHorizontalStretch(0)
size_policy.setVerticalStretch(0) size_policy.setVerticalStretch(0)
size_policy.setHeightForWidth(self.filterHardnessSlider.sizePolicy().hasHeightForWidth()) size_policy.setHeightForWidth(self.filterHardnessSlider.sizePolicy().hasHeightForWidth())
@@ -84,7 +90,7 @@ class PreferencesDialogBase(QDialog):
self.filterHardnessSlider.setMinimum(1) self.filterHardnessSlider.setMinimum(1)
self.filterHardnessSlider.setMaximum(100) self.filterHardnessSlider.setMaximum(100)
self.filterHardnessSlider.setTracking(True) self.filterHardnessSlider.setTracking(True)
self.filterHardnessSlider.setOrientation(Qt.Horizontal) self.filterHardnessSlider.setOrientation(Qt.Orientation.Horizontal)
self.filterHardnessHLayoutSub1.addWidget(self.filterHardnessSlider) self.filterHardnessHLayoutSub1.addWidget(self.filterHardnessSlider)
self.filterHardnessLabel = QLabel(self) self.filterHardnessLabel = QLabel(self)
self.filterHardnessLabel.setText("100") self.filterHardnessLabel.setText("100")
@@ -96,7 +102,7 @@ class PreferencesDialogBase(QDialog):
self.moreResultsLabel = QLabel(self) self.moreResultsLabel = QLabel(self)
self.moreResultsLabel.setText(tr("More Results")) self.moreResultsLabel.setText(tr("More Results"))
self.filterHardnessHLayoutSub2.addWidget(self.moreResultsLabel) self.filterHardnessHLayoutSub2.addWidget(self.moreResultsLabel)
spacer_item = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) spacer_item = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.filterHardnessHLayoutSub2.addItem(spacer_item) self.filterHardnessHLayoutSub2.addItem(spacer_item)
self.fewerResultsLabel = QLabel(self) self.fewerResultsLabel = QLabel(self)
self.fewerResultsLabel.setText(tr("Fewer Results")) self.fewerResultsLabel.setText(tr("Fewer Results"))
@@ -104,7 +110,7 @@ class PreferencesDialogBase(QDialog):
self.filterHardnessVLayout.addLayout(self.filterHardnessHLayoutSub2) self.filterHardnessVLayout.addLayout(self.filterHardnessHLayoutSub2)
self.filterHardnessHLayout.addLayout(self.filterHardnessVLayout) self.filterHardnessHLayout.addLayout(self.filterHardnessVLayout)
def _setupBottomPart(self): def _setupBottomPart(self) -> None:
# The bottom part of the pref panel is always the same in all editions. # The bottom part of the pref panel is always the same in all editions.
self.copyMoveLabel = QLabel(self) self.copyMoveLabel = QLabel(self)
self.copyMoveLabel.setText(tr("Copy and Move:")) self.copyMoveLabel.setText(tr("Copy and Move:"))
@@ -120,7 +126,7 @@ class PreferencesDialogBase(QDialog):
self.customCommandEdit = QLineEdit(self) self.customCommandEdit = QLineEdit(self)
self.widgetsVLayout.addWidget(self.customCommandEdit) self.widgetsVLayout.addWidget(self.customCommandEdit)
def _setupDisplayPage(self): def _setupDisplayPage(self) -> None:
self.ui_groupbox = QGroupBox("&" + tr("General Interface")) self.ui_groupbox = QGroupBox("&" + tr("General Interface"))
layout = QVBoxLayout() layout = QVBoxLayout()
self.languageLabel = QLabel(tr("Language:"), self) self.languageLabel = QLabel(tr("Language:"), self)
@@ -171,7 +177,7 @@ On MacOS, the tab bar will fill up the window's width instead."
formlayout.addRow(tr("Reference background color:"), self.result_table_ref_background_color) formlayout.addRow(tr("Reference background color:"), self.result_table_ref_background_color)
self.result_table_delta_foreground_color = ColorPickerButton(self) self.result_table_delta_foreground_color = ColorPickerButton(self)
formlayout.addRow(tr("Delta foreground color:"), self.result_table_delta_foreground_color) formlayout.addRow(tr("Delta foreground color:"), self.result_table_delta_foreground_color)
formlayout.setLabelAlignment(Qt.AlignLeft) formlayout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
# Keep same vertical spacing as parent layout for consistency # Keep same vertical spacing as parent layout for consistency
formlayout.setVerticalSpacing(self.displayVLayout.spacing()) formlayout.setVerticalSpacing(self.displayVLayout.spacing())
@@ -213,7 +219,7 @@ use the modifier key to drag the floating window around"
details_groupbox.setLayout(self.details_groupbox_layout) details_groupbox.setLayout(self.details_groupbox_layout)
self.displayVLayout.addWidget(details_groupbox) self.displayVLayout.addWidget(details_groupbox)
def _setupDebugPage(self): def _setupDebugPage(self) -> None:
self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)")) self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)"))
self._setupAddCheckbox("profile_scan_box", tr("Profile scan operation")) self._setupAddCheckbox("profile_scan_box", tr("Profile scan operation"))
self.profile_scan_box.setToolTip(tr("Profile the scan operation and save logs for optimization.")) self.profile_scan_box.setToolTip(tr("Profile the scan operation and save logs for optimization."))
@@ -225,7 +231,7 @@ use the modifier key to drag the floating window around"
) )
self.debugVLayout.addWidget(self.debug_location_label) self.debugVLayout.addWidget(self.debug_location_label)
def _setupAddCheckbox(self, name, label, parent=None): def _setupAddCheckbox(self, name: str, label: str, parent: Union[QWidget, None] = None) -> None:
if parent is None: if parent is None:
parent = self parent = self
cb = QCheckBox(parent) cb = QCheckBox(parent)
@@ -236,7 +242,7 @@ use the modifier key to drag the floating window around"
# Edition-specific # Edition-specific
pass pass
def _setupUi(self): def _setupUi(self) -> None:
self.setWindowTitle(tr("Options")) self.setWindowTitle(tr("Options"))
self.setSizeGripEnabled(False) self.setSizeGripEnabled(False)
self.setModal(True) self.setModal(True)
@@ -258,11 +264,13 @@ use the modifier key to drag the floating window around"
# self.mainVLayout.addLayout(self.widgetsVLayout) # self.mainVLayout.addLayout(self.widgetsVLayout)
self.buttonBox = QDialogButtonBox(self) self.buttonBox = QDialogButtonBox(self)
self.buttonBox.setStandardButtons( self.buttonBox.setStandardButtons(
QDialogButtonBox.Cancel | QDialogButtonBox.Ok | QDialogButtonBox.RestoreDefaults QDialogButtonBox.StandardButton.Cancel
| QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.RestoreDefaults
) )
self.mainVLayout.addWidget(self.tabwidget) self.mainVLayout.addWidget(self.tabwidget)
self.mainVLayout.addWidget(self.buttonBox) self.mainVLayout.addWidget(self.buttonBox)
self.layout().setSizeConstraint(QLayout.SetFixedSize) self.layout().setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
self.tabwidget.addTab(self.page_general, tr("General")) self.tabwidget.addTab(self.page_general, tr("General"))
self.tabwidget.addTab(self.page_display, tr("Display")) self.tabwidget.addTab(self.page_display, tr("Display"))
self.tabwidget.addTab(self.page_debug, tr("Debug")) self.tabwidget.addTab(self.page_debug, tr("Debug"))
@@ -270,20 +278,20 @@ use the modifier key to drag the floating window around"
self.widgetsVLayout.addStretch(0) self.widgetsVLayout.addStretch(0)
self.debugVLayout.addStretch(0) self.debugVLayout.addStretch(0)
def _load(self, prefs, setchecked, section): def _load(self, prefs, setchecked, section) -> None:
# Edition-specific # Edition-specific
pass pass
def _save(self, prefs, ischecked): def _save(self, prefs, ischecked) -> None:
# Edition-specific # Edition-specific
pass pass
def load(self, prefs=None, section=Sections.ALL): def load(self, prefs: Preferences = None, section: Sections = Sections.ALL) -> None:
if prefs is None: if prefs is None:
prefs = self.app.prefs prefs = self.app.prefs
def setchecked(cb, b): def setchecked(cb: QCheckBox, b: bool) -> None:
cb.setCheckState(Qt.Checked if b else Qt.Unchecked) cb.setCheckState(Qt.CheckState.Checked if b else Qt.CheckState.Unchecked)
if section & Sections.GENERAL: if section & Sections.GENERAL:
self.filterHardnessSlider.setValue(prefs.filter_hardness) self.filterHardnessSlider.setValue(prefs.filter_hardness)
@@ -323,12 +331,12 @@ use the modifier key to drag the floating window around"
setchecked(self.profile_scan_box, prefs.profile_scan) setchecked(self.profile_scan_box, prefs.profile_scan)
self._load(prefs, setchecked, section) self._load(prefs, setchecked, section)
def save(self): def save(self) -> None:
prefs = self.app.prefs prefs = self.app.prefs
prefs.filter_hardness = self.filterHardnessSlider.value() prefs.filter_hardness = self.filterHardnessSlider.value()
def ischecked(cb): def ischecked(cb: QCheckBox) -> bool:
return cb.checkState() == Qt.Checked return cb.checkState() == Qt.CheckState.Checked
prefs.mix_file_kind = ischecked(self.mixFileKindBox) prefs.mix_file_kind = ischecked(self.mixFileKindBox)
prefs.use_regexp = ischecked(self.useRegexpBox) prefs.use_regexp = ischecked(self.useRegexpBox)
@@ -363,13 +371,13 @@ use the modifier key to drag the floating window around"
self.app.prefs.language = lang_code self.app.prefs.language = lang_code
self._save(prefs, ischecked) self._save(prefs, ischecked)
def resetToDefaults(self, section_to_update): def resetToDefaults(self, section_to_update: Sections) -> None:
self.load(Preferences(), section_to_update) self.load(Preferences(), section_to_update)
# --- Events # --- Events
def buttonClicked(self, button): def buttonClicked(self, button: QPushButton) -> None:
role = self.buttonBox.buttonRole(button) role = self.buttonBox.buttonRole(button)
if role == QDialogButtonBox.ResetRole: if role == QDialogButtonBox.ButtonRole.ResetRole:
current_tab = self.tabwidget.currentWidget() current_tab = self.tabwidget.currentWidget()
section_to_update = Sections.ALL section_to_update = Sections.ALL
if current_tab is self.page_general: if current_tab is self.page_general:
@@ -380,30 +388,31 @@ use the modifier key to drag the floating window around"
section_to_update = Sections.DEBUG section_to_update = Sections.DEBUG
self.resetToDefaults(section_to_update) self.resetToDefaults(section_to_update)
def showEvent(self, event): def showEvent(self, event: QShowEvent) -> None:
# have to do this here as the frameGeometry is not correct until shown # have to do this here as the frameGeometry is not correct until shown
move_to_screen_center(self) move_to_screen_center(self)
super().showEvent(event) super().showEvent(event)
class ColorPickerButton(QPushButton): class ColorPickerButton(QPushButton):
def __init__(self, parent): def __init__(self, parent: QWidget) -> None:
super().__init__(parent) super().__init__(parent)
self.parent = parent
self.color = None self.color = None
self.clicked.connect(self.onClicked) self.clicked.connect(self.onClicked)
@pyqtSlot() @pyqtSlot()
def onClicked(self): def onClicked(self) -> None:
color = QColorDialog.getColor(self.color if self.color is not None else Qt.white, self.parent) color = QColorDialog.getColor(
self.color if self.color is not None else Qt.GlobalColor.white, self.parentWidget()
)
self.setColor(color) self.setColor(color)
def setColor(self, color): def setColor(self, color) -> None:
size = QSize(16, 16) size = QSize(16, 16)
px = QPixmap(size) px = QPixmap(size)
if color is None: if color is None:
size.width = 0 size.setWidth(0)
size.height = 0 size.setHeight(0)
elif not color.isValid(): elif not color.isValid():
return return
else: else:

View File

@@ -6,8 +6,9 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt5.QtWidgets import ( from PyQt6.QtGui import QShowEvent
from PyQt6.QtWidgets import (
QDialog, QDialog,
QVBoxLayout, QVBoxLayout,
QHBoxLayout, QHBoxLayout,
@@ -17,8 +18,10 @@ from PyQt5.QtWidgets import (
QLabel, QLabel,
QTableView, QTableView,
QAbstractItemView, QAbstractItemView,
QWidget,
) )
from core.gui.problem_dialog import ProblemDialog as ProblemDiaglogModel
from qt.util import move_to_screen_center from qt.util import move_to_screen_center
from hscommon.trans import trget from hscommon.trans import trget
from qt.problem_table import ProblemTable from qt.problem_table import ProblemTable
@@ -27,52 +30,56 @@ tr = trget("ui")
class ProblemDialog(QDialog): class ProblemDialog(QDialog):
def __init__(self, parent, model, **kwargs): def __init__(self, parent: QWidget, model: ProblemDiaglogModel, **kwargs) -> None:
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint flags = Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint
super().__init__(parent, flags, **kwargs) super().__init__(parent, flags, **kwargs)
self._setupUi()
self.model = model self.model = model
self.model.view = self self.table_view = QTableView(self)
self.table = ProblemTable(self.model.problem_table, view=self.tableView) self.table = ProblemTable(self.model.problem_table, view=self.table_view)
self._setupUi()
self.revealButton.clicked.connect(self.model.reveal_selected_dupe) def _setupUi(self) -> None:
self.closeButton.clicked.connect(self.accept)
def _setupUi(self):
self.setWindowTitle(tr("Problems!")) self.setWindowTitle(tr("Problems!"))
self.resize(413, 323) self.resize(413, 323)
self.verticalLayout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
self.label = QLabel(self) notice_label = QLabel(self)
msg = tr( msg = tr(
"There were problems processing some (or all) of the files. The cause of " "There were problems processing some (or all) of the files. The cause of "
"these problems are described in the table below. Those files were not " "these problems are described in the table below. Those files were not "
"removed from your results." "removed from your results."
) )
self.label.setText(msg) notice_label.setText(msg)
self.label.setWordWrap(True) notice_label.setWordWrap(True)
self.verticalLayout.addWidget(self.label) main_layout.addWidget(notice_label)
self.tableView = QTableView(self)
self.tableView.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.tableView.setSelectionMode(QAbstractItemView.SingleSelection)
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tableView.setShowGrid(False)
self.tableView.horizontalHeader().setStretchLastSection(True)
self.tableView.verticalHeader().setDefaultSectionSize(18)
self.tableView.verticalHeader().setHighlightSections(False)
self.verticalLayout.addWidget(self.tableView)
self.horizontalLayout = QHBoxLayout()
self.revealButton = QPushButton(self)
self.revealButton.setText(tr("Reveal Selected"))
self.horizontalLayout.addWidget(self.revealButton)
spacer_item = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.horizontalLayout.addItem(spacer_item)
self.closeButton = QPushButton(self)
self.closeButton.setText(tr("Close"))
self.closeButton.setDefault(True)
self.horizontalLayout.addWidget(self.closeButton)
self.verticalLayout.addLayout(self.horizontalLayout)
def showEvent(self, event): self.table_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table_view.setShowGrid(False)
self.table_view.horizontalHeader().setStretchLastSection(True)
self.table_view.verticalHeader().setDefaultSectionSize(18)
self.table_view.verticalHeader().setHighlightSections(False)
main_layout.addWidget(self.table_view)
button_layout = QHBoxLayout()
reveal_button = QPushButton(self)
reveal_button.setText(tr("Reveal Selected"))
button_layout.addWidget(reveal_button)
spacer_item = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
button_layout.addItem(spacer_item)
close_button = QPushButton(self)
close_button.setText(tr("Close"))
close_button.setDefault(True)
button_layout.addWidget(close_button)
main_layout.addLayout(button_layout)
reveal_button.clicked.connect(self.model.reveal_selected_dupe)
close_button.clicked.connect(self.accept)
def showEvent(self, event: QShowEvent) -> None:
# have to do this here as the frameGeometry is not correct until shown # have to do this here as the frameGeometry is not correct until shown
move_to_screen_center(self) move_to_screen_center(self)
super().showEvent(event) super().showEvent(event)

View File

@@ -4,29 +4,23 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import QSize from typing import Callable
from PyQt5.QtWidgets import ( from PyQt6.QtCore import QSize
QSpinBox, from PyQt6.QtWidgets import QSpinBox, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSpacerItem, QWidget, QCheckBox
QVBoxLayout,
QHBoxLayout,
QLabel,
QSizePolicy,
QSpacerItem,
QWidget,
)
from hscommon.trans import trget from hscommon.trans import trget
from core.app import AppMode from core.app import AppMode
from core.scanner import ScanType from core.scanner import ScanType
from qt.preferences import Preferences
from qt.preferences_dialog import PreferencesDialogBase from qt.preferences_dialog import PreferencesDialogBase, Sections
tr = trget("ui") tr = trget("ui")
class PreferencesDialog(PreferencesDialogBase): class PreferencesDialog(PreferencesDialogBase):
def _setupPreferenceWidgets(self): def _setupPreferenceWidgets(self) -> None:
self._setupFilterHardnessBox() self._setupFilterHardnessBox()
self.widgetsVLayout.addLayout(self.filterHardnessHLayout) self.widgetsVLayout.addLayout(self.filterHardnessHLayout)
self.widget = QWidget(self) self.widget = QWidget(self)
@@ -50,7 +44,7 @@ class PreferencesDialog(PreferencesDialogBase):
self._setupAddCheckbox("ignoreSmallFilesBox", tr("Ignore files smaller than"), self.widget) self._setupAddCheckbox("ignoreSmallFilesBox", tr("Ignore files smaller than"), self.widget)
self.horizontalLayout_2.addWidget(self.ignoreSmallFilesBox) self.horizontalLayout_2.addWidget(self.ignoreSmallFilesBox)
self.sizeThresholdSpinBox = QSpinBox(self.widget) self.sizeThresholdSpinBox = QSpinBox(self.widget)
size_policy = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) size_policy = QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
size_policy.setHorizontalStretch(0) size_policy.setHorizontalStretch(0)
size_policy.setVerticalStretch(0) size_policy.setVerticalStretch(0)
size_policy.setHeightForWidth(self.sizeThresholdSpinBox.sizePolicy().hasHeightForWidth()) size_policy.setHeightForWidth(self.sizeThresholdSpinBox.sizePolicy().hasHeightForWidth())
@@ -61,14 +55,14 @@ class PreferencesDialog(PreferencesDialogBase):
self.label_6 = QLabel(self.widget) self.label_6 = QLabel(self.widget)
self.label_6.setText(tr("KB")) self.label_6.setText(tr("KB"))
self.horizontalLayout_2.addWidget(self.label_6) self.horizontalLayout_2.addWidget(self.label_6)
spacer_item1 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) spacer_item1 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.horizontalLayout_2.addItem(spacer_item1) self.horizontalLayout_2.addItem(spacer_item1)
self.verticalLayout_4.addLayout(self.horizontalLayout_2) self.verticalLayout_4.addLayout(self.horizontalLayout_2)
self.horizontalLayout_2a = QHBoxLayout() self.horizontalLayout_2a = QHBoxLayout()
self._setupAddCheckbox("ignoreLargeFilesBox", tr("Ignore files larger than"), self.widget) self._setupAddCheckbox("ignoreLargeFilesBox", tr("Ignore files larger than"), self.widget)
self.horizontalLayout_2a.addWidget(self.ignoreLargeFilesBox) self.horizontalLayout_2a.addWidget(self.ignoreLargeFilesBox)
self.sizeSaturationSpinBox = QSpinBox(self.widget) self.sizeSaturationSpinBox = QSpinBox(self.widget)
size_policy = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) size_policy = QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
self.sizeSaturationSpinBox.setSizePolicy(size_policy) self.sizeSaturationSpinBox.setSizePolicy(size_policy)
self.sizeSaturationSpinBox.setMaximumSize(QSize(300, 16777215)) self.sizeSaturationSpinBox.setMaximumSize(QSize(300, 16777215))
self.sizeSaturationSpinBox.setRange(0, 1000000) self.sizeSaturationSpinBox.setRange(0, 1000000)
@@ -76,7 +70,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.label_6a = QLabel(self.widget) self.label_6a = QLabel(self.widget)
self.label_6a.setText(tr("MB")) self.label_6a.setText(tr("MB"))
self.horizontalLayout_2a.addWidget(self.label_6a) self.horizontalLayout_2a.addWidget(self.label_6a)
spacer_item3 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) spacer_item3 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.horizontalLayout_2a.addItem(spacer_item3) self.horizontalLayout_2a.addItem(spacer_item3)
self.verticalLayout_4.addLayout(self.horizontalLayout_2a) self.verticalLayout_4.addLayout(self.horizontalLayout_2a)
self.horizontalLayout_2b = QHBoxLayout() self.horizontalLayout_2b = QHBoxLayout()
@@ -94,7 +88,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.label_6b = QLabel(self.widget) self.label_6b = QLabel(self.widget)
self.label_6b.setText(tr("MB")) self.label_6b.setText(tr("MB"))
self.horizontalLayout_2b.addWidget(self.label_6b) self.horizontalLayout_2b.addWidget(self.label_6b)
spacer_item2 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) spacer_item2 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.horizontalLayout_2b.addItem(spacer_item2) self.horizontalLayout_2b.addItem(spacer_item2)
self.verticalLayout_4.addLayout(self.horizontalLayout_2b) self.verticalLayout_4.addLayout(self.horizontalLayout_2b)
self._setupAddCheckbox( self._setupAddCheckbox(
@@ -106,7 +100,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.widgetsVLayout.addWidget(self.widget) self.widgetsVLayout.addWidget(self.widget)
self._setupBottomPart() self._setupBottomPart()
def _load(self, prefs, setchecked, section): def _load(self, prefs: Preferences, setchecked: Callable[[QCheckBox, bool], None], section: Sections) -> None:
setchecked(self.matchSimilarBox, prefs.match_similar) setchecked(self.matchSimilarBox, prefs.match_similar)
setchecked(self.wordWeightingBox, prefs.word_weighting) setchecked(self.wordWeightingBox, prefs.word_weighting)
setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files) setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files)
@@ -123,7 +117,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.matchSimilarBox.setEnabled(word_based) self.matchSimilarBox.setEnabled(word_based)
self.wordWeightingBox.setEnabled(word_based) self.wordWeightingBox.setEnabled(word_based)
def _save(self, prefs, ischecked): def _save(self, prefs: Preferences, ischecked: Callable[[QCheckBox], bool]) -> None:
prefs.match_similar = ischecked(self.matchSimilarBox) prefs.match_similar = ischecked(self.matchSimilarBox)
prefs.word_weighting = ischecked(self.wordWeightingBox) prefs.word_weighting = ischecked(self.wordWeightingBox)
prefs.ignore_small_files = ischecked(self.ignoreSmallFilesBox) prefs.ignore_small_files = ischecked(self.ignoreSmallFilesBox)

View File

@@ -11,22 +11,18 @@ import io
import os.path as op import os.path as op
import os import os
import logging import logging
from typing import List, Union
from core.util import executable_folder from core.util import executable_folder
from hscommon.util import first from hscommon.util import first
from hscommon.plat import ISWINDOWS from hscommon.plat import ISWINDOWS
from PyQt5.QtCore import QStandardPaths, QSettings from PyQt6.QtCore import QStandardPaths, QSettings
from PyQt5.QtGui import QPixmap, QIcon, QGuiApplication from PyQt6.QtGui import QPixmap, QIcon, QGuiApplication, QAction
from PyQt5.QtWidgets import ( from PyQt6.QtWidgets import QSpacerItem, QSizePolicy, QHBoxLayout, QWidget
QSpacerItem,
QSizePolicy,
QAction,
QHBoxLayout,
)
def move_to_screen_center(widget): def move_to_screen_center(widget: QWidget) -> None:
frame = widget.frameGeometry() frame = widget.frameGeometry()
if QGuiApplication.screenAt(frame.center()) is None: if QGuiApplication.screenAt(frame.center()) is None:
# if center not on any screen use default screen # if center not on any screen use default screen
@@ -43,21 +39,21 @@ def move_to_screen_center(widget):
widget.move(frame.topLeft()) widget.move(frame.topLeft())
def vertical_spacer(size=None): def vertical_spacer(size: Union[int, None] = None) -> QSpacerItem:
if size: if size:
return QSpacerItem(1, size, QSizePolicy.Fixed, QSizePolicy.Fixed) return QSpacerItem(1, size, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
else: else:
return QSpacerItem(1, 1, QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) return QSpacerItem(1, 1, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.MinimumExpanding)
def horizontal_spacer(size=None): def horizontal_spacer(size: Union[int, None] = None) -> QSpacerItem:
if size: if size:
return QSpacerItem(size, 1, QSizePolicy.Fixed, QSizePolicy.Fixed) return QSpacerItem(size, 1, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
else: else:
return QSpacerItem(1, 1, QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) return QSpacerItem(1, 1, QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed)
def horizontal_wrap(widgets): def horizontal_wrap(widgets: List[Union[QWidget, int, None]]) -> QHBoxLayout:
"""Wrap all widgets in `widgets` in a horizontal layout. """Wrap all widgets in `widgets` in a horizontal layout.
If, instead of placing a widget in your list, you place an int or None, an horizontal spacer If, instead of placing a widget in your list, you place an int or None, an horizontal spacer
@@ -77,7 +73,7 @@ def create_actions(actions, target):
for name, shortcut, icon, desc, func in actions: for name, shortcut, icon, desc, func in actions:
action = QAction(target) action = QAction(target)
if icon: if icon:
action.setIcon(QIcon(QPixmap(":/" + icon))) action.setIcon(QIcon(QPixmap(":/" + icon))) # TODO stop using qrc file path
if shortcut: if shortcut:
action.setShortcut(shortcut) action.setShortcut(shortcut)
action.setText(desc) action.setText(desc)
@@ -100,11 +96,11 @@ def set_accel_keys(menu):
action.setText(newtext) action.setText(newtext)
def get_appdata(portable=False): def get_appdata(portable: bool = False) -> str:
if portable: if portable:
return op.join(executable_folder(), "data") return op.join(executable_folder(), "data")
else: else:
return QStandardPaths.standardLocations(QStandardPaths.AppDataLocation)[0] return QStandardPaths.standardLocations(QStandardPaths.StandardLocation.AppDataLocation)[0]
class SysWrapper(io.IOBase): class SysWrapper(io.IOBase):
@@ -140,18 +136,18 @@ def escape_amp(s):
return s.replace("&", "&&") return s.replace("&", "&&")
def create_qsettings(): def create_qsettings() -> QSettings:
# Create a QSettings instance with the correct arguments. # Create a QSettings instance with the correct arguments.
config_location = op.join(executable_folder(), "settings.ini") config_location = op.join(executable_folder(), "settings.ini")
if op.isfile(config_location): if op.isfile(config_location):
settings = QSettings(config_location, QSettings.IniFormat) settings = QSettings(config_location, QSettings.Format.IniFormat)
settings.setValue("Portable", True) settings.setValue("Portable", True)
elif ISWINDOWS: elif ISWINDOWS:
# On windows use an ini file in the AppDataLocation instead of registry if possible as it # On windows use an ini file in the AppDataLocation instead of registry if possible as it
# makes it easier for a user to clear it out when there are issues. # makes it easier for a user to clear it out when there are issues.
locations = QStandardPaths.standardLocations(QStandardPaths.AppDataLocation) locations = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.AppDataLocation)
if locations: if locations:
settings = QSettings(op.join(locations[0], "settings.ini"), QSettings.IniFormat) settings = QSettings(op.join(locations[0], "settings.ini"), QSettings.Format.IniFormat)
else: else:
settings = QSettings() settings = QSettings()
settings.setValue("Portable", False) settings.setValue("Portable", False)

View File

@@ -1,4 +1,4 @@
pytest>=6,<7 pytest>=6,<7.2
flake8 flake8
black black
pyinstaller>=4.5,<5.0; sys_platform != 'linux' pyinstaller>=4.5,<5.0; sys_platform != 'linux'

View File

@@ -1,9 +1,9 @@
distro>=1.5.0 distro>=1.5.0, <2.0
mutagen>=1.44.0 mutagen>=1.44.0, <2.0
polib>=1.1.0 polib>=1.1.0, <2.0
PyQt5 >=5.14.1,<6.0; sys_platform != 'linux' PyQt6 >=6.3,<7.0; sys_platform != 'linux'
pywin32>=228; sys_platform == 'win32' pywin32>=228; sys_platform == 'win32'
semantic-version>=2.0.0,<3.0.0 semantic-version>=2.0.0,<3.0.0
Send2Trash>=1.3.0 Send2Trash>=1.3.0
sphinx>=3.0.0 sphinx>=5.0.0, <6.0
xxhash>=3.0.0,<4.0.0 xxhash>=3.0.0,<4.0.0

17
run.py
View File

@@ -9,9 +9,9 @@ import sys
import os.path as op import os.path as op
import gc import gc
from PyQt5.QtCore import QCoreApplication from PyQt6.QtCore import QDir
from PyQt5.QtGui import QIcon, QPixmap from PyQt6.QtGui import QIcon
from PyQt5.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication
from hscommon.trans import install_gettext_trans_under_qt from hscommon.trans import install_gettext_trans_under_qt
from qt.error_report_dialog import install_excepthook from qt.error_report_dialog import install_excepthook
@@ -48,9 +48,10 @@ def setup_signals():
def main(): def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
QCoreApplication.setOrganizationName("Hardcoded Software") QApplication.setOrganizationName("Hardcoded Software")
QCoreApplication.setApplicationName(__appname__) QApplication.setApplicationName(__appname__)
QCoreApplication.setApplicationVersion(__version__) QApplication.setApplicationVersion(__version__)
QDir.addSearchPath("images", op.join(BASE_PATH, "images"))
setup_qt_logging() setup_qt_logging()
settings = create_qsettings() settings = create_qsettings()
lang = settings.value("Language") lang = settings.value("Language")
@@ -61,7 +62,7 @@ def main():
# Let the Python interpreter runs every 500ms to handle signals. This is # Let the Python interpreter runs every 500ms to handle signals. This is
# required because Python cannot handle signals while the Qt event loop is # required because Python cannot handle signals while the Qt event loop is
# running. # running.
from PyQt5.QtCore import QTimer from PyQt6.QtCore import QTimer
timer = QTimer() timer = QTimer()
timer.start(500) timer.start(500)
@@ -70,7 +71,7 @@ def main():
# has been installed # has been installed
from qt.app import DupeGuru from qt.app import DupeGuru
app.setWindowIcon(QIcon(QPixmap(f":/{DupeGuru.LOGO_NAME}"))) app.setWindowIcon(QIcon(f"images:{DupeGuru.LOGO_NAME}_32.png"))
global dgapp global dgapp
dgapp = DupeGuru() dgapp = DupeGuru()
install_excepthook("https://github.com/arsenetar/dupeguru/issues") install_excepthook("https://github.com/arsenetar/dupeguru/issues")

View File

@@ -32,15 +32,15 @@ install_requires =
Send2Trash>=1.3.0 Send2Trash>=1.3.0
mutagen>=1.45.1 mutagen>=1.45.1
distro>=1.5.0 distro>=1.5.0
PyQt5 >=5.14.1,<6.0; sys_platform != 'linux' PyQt6 >=6.3.0,<7.0; sys_platform != 'linux'
pywin32>=228; sys_platform == 'win32' pywin32>=228; sys_platform == 'win32'
semantic-version>=2.0.0,<3.0.0 semantic-version>=2.0.0,<3.0.0
xxhash>=3.0.0,<4.0.0 xxhash>=3.0.0,<4.0.0
setup_requires = setup_requires =
sphinx>=3.0.0 sphinx>=5.0.0
polib>=1.1.0 polib>=1.1.0
tests_require = tests_require =
pytest >=6,<7 pytest >=7,<8
include_package_data = true include_package_data = true
[options.entry_points] [options.entry_points]

View File

@@ -245,7 +245,7 @@ Section "Uninstall"
; Remove Files & Folders in Install Folder ; Remove Files & Folders in Install Folder
RMDir /r "$INSTDIR\core" RMDir /r "$INSTDIR\core"
RMDir /r "$INSTDIR\help" RMDir /r "$INSTDIR\help"
RMDir /r "$INSTDIR\PyQt5" RMDir /r "$INSTDIR\PyQt6"
RMDir /r "$INSTDIR\qt" RMDir /r "$INSTDIR\qt"
RMDir /r "$INSTDIR\locale" RMDir /r "$INSTDIR\locale"
Delete "$INSTDIR\*.exe" Delete "$INSTDIR\*.exe"