1
0
mirror of https://github.com/arsenetar/dupeguru.git synced 2024-12-21 10:59:03 +00:00

Implement dialog and base classes for model/view

This commit is contained in:
glubsy 2020-07-28 16:33:28 +02:00
parent 470307aa3c
commit a26de27c47
11 changed files with 485 additions and 4 deletions

View File

@ -26,11 +26,13 @@ from .pe.photo import get_delta_dimensions
from .util import cmp_value, fix_surrogate_encoding from .util import cmp_value, fix_surrogate_encoding
from . import directories, results, export, fs, prioritize from . import directories, results, export, fs, prioritize
from .ignore import IgnoreList from .ignore import IgnoreList
from .exclude import ExcludeList
from .scanner import ScanType from .scanner import ScanType
from .gui.deletion_options import DeletionOptions from .gui.deletion_options import DeletionOptions
from .gui.details_panel import DetailsPanel from .gui.details_panel import DetailsPanel
from .gui.directory_tree import DirectoryTree from .gui.directory_tree import DirectoryTree
from .gui.ignore_list_dialog import IgnoreListDialog from .gui.ignore_list_dialog import IgnoreListDialog
from .gui.exclude_list_dialog import ExcludeListDialogCore
from .gui.problem_dialog import ProblemDialog from .gui.problem_dialog import ProblemDialog
from .gui.stats_label import StatsLabel from .gui.stats_label import StatsLabel
@ -140,6 +142,7 @@ class DupeGuru(Broadcaster):
self.directories = directories.Directories() self.directories = directories.Directories()
self.results = results.Results(self) self.results = results.Results(self)
self.ignore_list = IgnoreList() self.ignore_list = IgnoreList()
self.exclude_list = ExcludeList(self)
# In addition to "app-level" options, this dictionary also holds options that will be # In addition to "app-level" options, this dictionary also holds options that will be
# sent to the scanner. They don't have default values because those defaults values are # sent to the scanner. They don't have default values because those defaults values are
# defined in the scanner class. # defined in the scanner class.
@ -155,6 +158,7 @@ class DupeGuru(Broadcaster):
self.directory_tree = DirectoryTree(self) self.directory_tree = DirectoryTree(self)
self.problem_dialog = ProblemDialog(self) self.problem_dialog = ProblemDialog(self)
self.ignore_list_dialog = IgnoreListDialog(self) self.ignore_list_dialog = IgnoreListDialog(self)
self.exclude_list_dialog = ExcludeListDialogCore(self)
self.stats_label = StatsLabel(self) self.stats_label = StatsLabel(self)
self.result_table = None self.result_table = None
self.deletion_options = DeletionOptions() self.deletion_options = DeletionOptions()
@ -587,6 +591,9 @@ class DupeGuru(Broadcaster):
p = op.join(self.appdata, "ignore_list.xml") p = op.join(self.appdata, "ignore_list.xml")
self.ignore_list.load_from_xml(p) self.ignore_list.load_from_xml(p)
self.ignore_list_dialog.refresh() self.ignore_list_dialog.refresh()
p = op.join(self.appdata, "exclude_list.xml")
self.exclude_list.load_from_xml(p)
self.exclude_list_dialog.refresh()
def load_from(self, filename): def load_from(self, filename):
"""Start an async job to load results from ``filename``. """Start an async job to load results from ``filename``.
@ -773,6 +780,8 @@ class DupeGuru(Broadcaster):
self.directories.save_to_file(op.join(self.appdata, "last_directories.xml")) self.directories.save_to_file(op.join(self.appdata, "last_directories.xml"))
p = op.join(self.appdata, "ignore_list.xml") p = op.join(self.appdata, "ignore_list.xml")
self.ignore_list.save_to_xml(p) self.ignore_list.save_to_xml(p)
p = op.join(self.appdata, "exclude_list.xml")
self.exclude_list.save_to_xml(p)
self.notify("save_session") self.notify("save_session")
def save_as(self, filename): def save_as(self, filename):

97
core/exclude.py Normal file
View File

@ -0,0 +1,97 @@
# 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
from .markable import Markable
from xml.etree import ElementTree as ET
from hscommon.util import FileOrPath
class ExcludeList(Markable):
"""Exclude list of regular expression strings to filter out directories
and files that we want to avoid scanning."""
# ---Override
def __init__(self, app):
Markable.__init__(self)
self.app = app
self._excluded = [] # set of strings
self._count = 0
def __iter__(self):
for regex in self._excluded:
yield self.is_marked(regex), regex
def __len__(self):
return self._count
def _is_markable(self, row):
return True
# ---Public
def add(self, regex):
self._excluded.insert(0, regex)
self._count = len(self._excluded)
def isExcluded(self, regex):
if regex in self._excluded:
return True
return False
def clear(self):
self._excluded = []
self._count = 0
def remove(self, regex):
return self._excluded.remove(regex)
def rename(self, regex, newregex):
if regex not in self._excluded:
return
marked = self.is_marked(regex)
index = self._excluded.index(regex)
self._excluded[index] = newregex
if marked:
# Not marked by default when added
self.mark(self._excluded[index])
def change_index(self, regex, new_index):
item = self._excluded.pop(regex)
self._excluded.insert(new_index, item)
def load_from_xml(self, infile):
"""Loads the ignore list from a XML created with save_to_xml.
infile can be a file object or a filename.
"""
try:
root = ET.parse(infile).getroot()
except Exception as e:
print(f"Error while loading {infile}: {e}")
return
marked = set()
exclude_elems = (e for e in root if e.tag == "exclude")
for exclude_item in exclude_elems:
regex_string = exclude_item.get("regex")
if not regex_string:
continue
self.add(regex_string)
if exclude_item.get("marked") == "y":
marked.add(regex_string)
for item in marked:
# this adds item to the Markable "marked" set
self.mark(item)
def save_to_xml(self, outfile):
"""Create a XML file that can be used by load_from_xml.
outfile can be a file object or a filename.
"""
root = ET.Element("exclude_list")
for regex in self._excluded:
exclude_node = ET.SubElement(root, "exclude")
exclude_node.set("regex", str(regex))
exclude_node.set("marked", ("y" if self.is_marked(regex) else "n"))
tree = ET.ElementTree(root)
with FileOrPath(outfile, "wb") as fp:
tree.write(fp, encoding="utf-8")

View File

@ -0,0 +1,64 @@
# Created On: 2012/03/13
# 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
# from hscommon.trans import tr
from .exclude_list_table import ExcludeListTable
default_regexes = [".*thumbs", "\.DS.Store", "\.Trash", "Trash-Bin"]
class ExcludeListDialogCore:
# --- View interface
# show()
#
def __init__(self, app):
self.app = app
self.exclude_list = self.app.exclude_list # Markable from exclude.py
self.exclude_list_table = ExcludeListTable(self, app) # GUITable, this is the "model"
def restore_defaults(self):
for _, regex in self.exclude_list:
if regex not in default_regexes:
self.exclude_list.unmark(regex)
for default_regex in default_regexes:
if not self.exclude_list.isExcluded(default_regex):
self.exclude_list.add(default_regex)
self.exclude_list.mark(default_regex)
self.refresh()
def refresh(self):
self.exclude_list_table.refresh()
def remove_selected(self):
for row in self.exclude_list_table.selected_rows:
self.exclude_list_table.remove(row)
self.exclude_list.remove(row.regex)
self.refresh()
def rename_selected(self, newregex):
"""Renames the selected regex to ``newregex``.
If there's more than one selected row, the first one is used.
:param str newregex: The regex to rename the row's regex to.
"""
try:
r = self.exclude_list_table.selected_rows[0]
self.exclude_list.rename(r.regex, newregex)
self.refresh()
return True
except Exception as e:
print(f"dupeGuru Warning: {e}")
return False
def add(self, regex):
self.exclude_list.add(regex)
self.exclude_list.mark(regex)
# TODO make checks here before adding to GUI
self.exclude_list_table.add(regex)
def show(self):
self.view.show()

View File

@ -0,0 +1,117 @@
# 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
from .base import DupeGuruGUIObject
from hscommon.gui.table import GUITable, Row
from hscommon.gui.column import Column, Columns
from hscommon.trans import trget
tr = trget("ui")
class ExcludeListTable(GUITable, DupeGuruGUIObject):
COLUMNS = [
Column("marked", ""),
Column("regex", tr("Regex"))
]
def __init__(self, exclude_list_dialog, app):
GUITable.__init__(self)
DupeGuruGUIObject.__init__(self, app)
# self.columns = Columns(self, prefaccess=app, savename="ExcludeTable")
self.columns = Columns(self)
self.dialog = exclude_list_dialog
def rename_selected(self, newname):
row = self.selected_row
if row is None:
# There's all kinds of way the current row can be swept off during rename. When it
# happens, selected_row will be None.
return False
row._data = None
return self.dialog.rename_selected(newname)
# --- Virtual
def _do_add(self, regex):
"""(Virtual) Creates a new row, adds it in the table.
Returns ``(row, insert_index)``.
"""
# Return index 0 to insert at the top
return ExcludeListRow(self, self.dialog.exclude_list.is_marked(regex), regex), 0
def _do_delete(self):
self.dalog.exclude_list.remove(self.selected_row.regex)
# --- Override
def add(self, regex):
row, insert_index = self._do_add(regex)
self.insert(insert_index, row)
# self.select([insert_index])
self.view.refresh()
def _fill(self):
for enabled, regex in self.dialog.exclude_list:
self.append(ExcludeListRow(self, enabled, regex))
# def remove(self):
# super().remove(super().selected_rows)
# def _update_selection(self):
# # rows = self.selected_rows
# # self.dialog._select_rows(list(map(attrgetter("_dupe"), rows)))
# self.dialog.remove_selected()
def refresh(self, refresh_view=True):
"""Override to avoid keeping previous selection in case of multiple rows
selected previously."""
self.cancel_edits()
del self[:]
self._fill()
# sd = self._sort_descriptor
# if sd is not None:
# super().sort_by(self, column_name=sd.column, desc=sd.desc)
if refresh_view:
self.view.refresh()
class ExcludeListRow(Row):
def __init__(self, table, enabled, regex):
Row.__init__(self, table)
self._app = table.app
self._data = None
self.enabled_original = enabled
self.regex_original = regex
self.enabled = str(enabled)
self.regex = str(regex)
@property
def data(self):
def get_display_info(row):
return {"marked": row.enabled, "regex": row.regex}
if self._data is None:
self._data = get_display_info(self)
return self._data
@property
def markable(self):
return True
@property
def marked(self):
return self._app.exclude_list.is_marked(self.regex)
@marked.setter
def marked(self, value):
if value:
self._app.exclude_list.mark(self.regex)
else:
self._app.exclude_list.unmark(self.regex)
# @property
# def regex(self):
# return self.regex
# @regex.setter
# def regex(self, value):
# self._app.exclude_list.add(self._regex, value)

View File

@ -17,7 +17,7 @@ class IgnoreListDialog:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.ignore_list = self.app.ignore_list self.ignore_list = self.app.ignore_list
self.ignore_list_table = IgnoreListTable(self) self.ignore_list_table = IgnoreListTable(self) # GUITable
def clear(self): def clear(self):
if not self.ignore_list: if not self.ignore_list:

View File

@ -27,6 +27,7 @@ from .result_window import ResultWindow
from .directories_dialog import DirectoriesDialog from .directories_dialog import DirectoriesDialog
from .problem_dialog import ProblemDialog from .problem_dialog import ProblemDialog
from .ignore_list_dialog import IgnoreListDialog from .ignore_list_dialog import IgnoreListDialog
from .exclude_list_dialog import ExcludeListDialog
from .deletion_options import DeletionOptions from .deletion_options import DeletionOptions
from .se.details_dialog import DetailsDialog as DetailsDialogStandard from .se.details_dialog import DetailsDialog as DetailsDialogStandard
from .me.details_dialog import DetailsDialog as DetailsDialogMusic from .me.details_dialog import DetailsDialog as DetailsDialogMusic
@ -87,10 +88,16 @@ class DupeGuru(QObject):
parent=self.main_window, parent=self.main_window,
model=self.model.ignore_list_dialog) model=self.model.ignore_list_dialog)
self.ignoreListDialog.accepted.connect(self.main_window.onDialogAccepted) self.ignoreListDialog.accepted.connect(self.main_window.onDialogAccepted)
self.excludeListDialog = self.main_window.createPage(
"ExcludeListDialog",
app=self,
parent=self.main_window,
model=self.model.exclude_list_dialog)
else: else:
self.ignoreListDialog = IgnoreListDialog( self.ignoreListDialog = IgnoreListDialog(
parent=parent_window, model=self.model.ignore_list_dialog parent=parent_window, model=self.model.ignore_list_dialog)
) self.excludeDialog = ExcludeListDialog(parent=parent_window)
self.deletionOptions = DeletionOptions( self.deletionOptions = DeletionOptions(
parent=parent_window, parent=parent_window,
@ -130,6 +137,7 @@ class DupeGuru(QObject):
tr("Clear Picture Cache"), tr("Clear Picture Cache"),
self.clearPictureCacheTriggered, self.clearPictureCacheTriggered,
), ),
("actionExcludeList", "", "", tr("Exclude list"), self.excludeListTriggered),
("actionShowHelp", "F1", "", tr("dupeGuru Help"), self.showHelpTriggered), ("actionShowHelp", "F1", "", tr("dupeGuru Help"), self.showHelpTriggered),
("actionAbout", "", "", tr("About dupeGuru"), self.showAboutBoxTriggered), ("actionAbout", "", "", tr("About dupeGuru"), self.showAboutBoxTriggered),
( (
@ -276,10 +284,20 @@ class DupeGuru(QObject):
# if not self.main_window.tabWidget.isTabVisible(index): # if not self.main_window.tabWidget.isTabVisible(index):
self.main_window.setTabVisible(index, True) self.main_window.setTabVisible(index, True)
self.main_window.setCurrentIndex(index) self.main_window.setCurrentIndex(index)
return
else: else:
self.model.ignore_list_dialog.show() self.model.ignore_list_dialog.show()
def excludeListTriggered(self):
if self.main_window:
index = self.main_window.indexOfWidget(self.excludeListDialog)
if index < 0:
index = self.main_window.addTab(
self.excludeListDialog, "Exclude List", switch=True)
self.main_window.setTabVisible(index, True)
self.main_window.setCurrentIndex(index)
else:
self.excludeListDialog.show()
def openDebugLogTriggered(self): def openDebugLogTriggered(self):
debugLogPath = op.join(self.model.appdata, "debug.log") debugLogPath = op.join(self.model.appdata, "debug.log")
desktop.open_path(debugLogPath) desktop.open_path(debugLogPath)

View File

@ -132,6 +132,7 @@ class DirectoriesDialog(QMainWindow):
self.menuView.addAction(self.app.actionDirectoriesWindow) self.menuView.addAction(self.app.actionDirectoriesWindow)
self.menuView.addAction(self.actionShowResultsWindow) self.menuView.addAction(self.actionShowResultsWindow)
self.menuView.addAction(self.app.actionIgnoreList) self.menuView.addAction(self.app.actionIgnoreList)
self.menuView.addAction(self.app.actionExcludeList)
self.menuView.addSeparator() self.menuView.addSeparator()
self.menuView.addAction(self.app.actionPreferences) self.menuView.addAction(self.app.actionPreferences)

83
qt/exclude_list_dialog.py Normal file
View File

@ -0,0 +1,83 @@
# 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
from PyQt5.QtCore import Qt, pyqtSlot
from PyQt5.QtWidgets import (
QPushButton, QLineEdit, QVBoxLayout, QGridLayout, QDialog,
QTableView, QAbstractItemView, QSpacerItem, QSizePolicy, QHeaderView
)
from .exclude_list_table import ExcludeListTable, ExcludeView
from hscommon.trans import trget
tr = trget("ui")
class ExcludeListDialog(QDialog):
def __init__(self, app, parent, model, **kwargs):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
super().__init__(parent, flags, **kwargs)
self.specific_actions = frozenset()
self._setupUI()
self.model = model # ExcludeListDialogCore
self.model.view = self
self.table = ExcludeListTable(app, view=self.tableView)
self.buttonAdd.clicked.connect(self.addItem)
self.buttonRemove.clicked.connect(self.removeItem)
self.buttonRestore.clicked.connect(self.restoreDefaults)
self.buttonClose.clicked.connect(self.accept)
def _setupUI(self):
layout = QVBoxLayout(self)
gridlayout = QGridLayout()
self.buttonAdd = QPushButton(tr("Add"))
self.buttonRemove = QPushButton(tr("Remove Selected"))
self.buttonRestore = QPushButton(tr("Restore defaults"))
self.buttonClose = QPushButton(tr("Close"))
self.linedit = QLineEdit()
self.tableView = ExcludeView()
triggers = (
QAbstractItemView.DoubleClicked
| QAbstractItemView.EditKeyPressed
| QAbstractItemView.SelectedClicked
)
self.tableView.setEditTriggers(triggers)
self.tableView.horizontalHeader().setVisible(True)
self.tableView.setSelectionMode(QTableView.ExtendedSelection)
self.tableView.setSelectionBehavior(QTableView.SelectRows)
# vheader = self.tableView.verticalHeader()
# vheader.setSectionsMovable(True)
# vheader.setVisible(True)
# vheader.setDefaultSectionSize(50)
hheader = self.tableView.horizontalHeader()
hheader.setSectionsMovable(False)
hheader.setSectionResizeMode(QHeaderView.Fixed)
hheader.setStretchLastSection(True)
hheader.setHighlightSections(False)
gridlayout.addWidget(self.linedit, 0, 0)
gridlayout.addWidget(self.buttonAdd, 0, 1, Qt.AlignLeft)
gridlayout.addWidget(self.buttonRemove, 1, 1, Qt.AlignLeft)
gridlayout.addWidget(self.buttonRestore, 2, 1, Qt.AlignLeft)
gridlayout.addWidget(self.tableView, 1, 0, 4, 1)
gridlayout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), 3, 1)
gridlayout.addWidget(self.buttonClose, 4, 1)
layout.addLayout(gridlayout)
# --- model --> view
def show(self):
super().show()
@pyqtSlot()
def addItem(self):
text = self.linedit.text()
if not text:
return
self.model.add(text)
self.linedit.clear()
def removeItem(self):
self.model.remove_selected()
def restoreDefaults(self):
self.model.restore_defaults()

84
qt/exclude_list_table.py Normal file
View File

@ -0,0 +1,84 @@
# 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
from PyQt5.QtCore import Qt, QModelIndex, pyqtSignal
from PyQt5.QtGui import QBrush, QFont, QFontMetrics, QColor
from PyQt5.QtWidgets import QTableView
from qtlib.column import Column
from qtlib.table import Table
class ExcludeListTable(Table):
"""Model for exclude list"""
COLUMNS = [
Column("marked", defaultWidth=15),
Column("regex", defaultWidth=230)
]
def __init__(self, app, view, **kwargs):
model = app.model.exclude_list_dialog.exclude_list_table # pointer to GUITable
super().__init__(model, view, **kwargs)
view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder)
font = view.font()
font.setPointSize(app.prefs.tableFontSize)
view.setFont(font)
fm = QFontMetrics(font)
view.verticalHeader().setDefaultSectionSize(fm.height() + 2)
app.willSavePrefs.connect(self.appWillSavePrefs)
def _getData(self, row, column, role):
if column.name == "marked":
if role == Qt.CheckStateRole and row.markable:
return Qt.Checked if row.marked else Qt.Unchecked
return None
if role == Qt.DisplayRole:
return row.data[column.name]
elif role == Qt.FontRole:
return QFont(self.view.font())
elif role == Qt.EditRole:
if column.name == "regex":
return row.data[column.name]
return None
def _getFlags(self, row, column):
flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
if column.name == "marked":
if row.markable:
flags |= Qt.ItemIsUserCheckable
elif column.name == "regex":
flags |= Qt.ItemIsEditable
return flags
def _setData(self, row, column, value, role):
if role == Qt.CheckStateRole:
if column.name == "marked":
row.marked = bool(value)
return True
elif role == Qt.EditRole:
if column.name == "regex":
return self.model.rename_selected(value)
return False
def sort(self, column, order):
column = self.model.COLUMNS[column]
self.model.sort(column.name, order == Qt.AscendingOrder)
# --- Events
def appWillSavePrefs(self):
self.model.columns.save_columns()
# --- model --> view
def invalidate_markings(self):
# redraw view
# HACK. this is the only way I found to update the widget without reseting everything
self.view.scroll(0, 1)
self.view.scroll(0, -1)
class ExcludeView(QTableView):
def mouseDoubleClickEvent(self, event):
# FIXME this doesn't seem to do anything relevant
self.doubleClicked.emit(QModelIndex())
# We don't call the superclass' method because the default behavior is to rename the cell.

View File

@ -10,6 +10,8 @@ from qtlib.table import Table
class IgnoreListTable(Table): class IgnoreListTable(Table):
""" Ignore list model"""
COLUMNS = [ COLUMNS = [
Column("path1", defaultWidth=230), Column("path1", defaultWidth=230),
Column("path2", defaultWidth=230), Column("path2", defaultWidth=230),

View File

@ -18,6 +18,7 @@ from qtlib.util import moveToScreenCenter, createActions
from .directories_dialog import DirectoriesDialog from .directories_dialog import DirectoriesDialog
from .result_window import ResultWindow from .result_window import ResultWindow
from .ignore_list_dialog import IgnoreListDialog from .ignore_list_dialog import IgnoreListDialog
from .exclude_list_dialog import ExcludeListDialog
tr = trget("ui") tr = trget("ui")
@ -157,6 +158,11 @@ class TabWindow(QMainWindow):
parent = kwargs.get("parent", self) parent = kwargs.get("parent", self)
model = kwargs.get("model") model = kwargs.get("model")
page = IgnoreListDialog(parent, model) page = IgnoreListDialog(parent, model)
elif cls == "ExcludeListDialog":
app = kwargs.get("app", app)
parent = kwargs.get("parent", self)
model = kwargs.get("model")
page = ExcludeListDialog(app, parent, model)
self.pages[cls] = page self.pages[cls] = page
return page return page