Implement exclude list dialog on the Qt side

This commit is contained in:
glubsy 2020-08-17 04:13:20 +02:00
parent a26de27c47
commit 2eaf7e7893
9 changed files with 400 additions and 92 deletions

View File

@ -26,7 +26,7 @@ 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 .exclude import ExcludeList as 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
@ -139,10 +139,10 @@ class DupeGuru(Broadcaster):
os.makedirs(self.appdata) os.makedirs(self.appdata)
self.app_mode = AppMode.Standard self.app_mode = AppMode.Standard
self.discarded_file_count = 0 self.discarded_file_count = 0
self.exclude_list = ExcludeList()
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.

View File

@ -5,7 +5,6 @@
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
import os import os
import re
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
import logging import logging
@ -14,6 +13,7 @@ from hscommon.path import Path
from hscommon.util import FileOrPath from hscommon.util import FileOrPath
from . import fs from . import fs
from .exclude import ExcludeList
__all__ = [ __all__ = [
"Directories", "Directories",
@ -53,34 +53,17 @@ class Directories:
Then, when the user starts the scan, :meth:`get_files` is called to retrieve all files (wrapped Then, when the user starts the scan, :meth:`get_files` is called to retrieve all files (wrapped
in :mod:`core.fs`) that have to be scanned according to the chosen folders/states. in :mod:`core.fs`) that have to be scanned according to the chosen folders/states.
""" """
# FIXME: if there is zero item in these sets, the for each loops will yield NOTHING
deny_list_str = set() deny_list_str = set()
deny_list_re = set() deny_list_re = set()
deny_list_re_files = set() deny_list_re_files = set()
# ---Override # ---Override
def __init__(self): def __init__(self, excluded=ExcludeList()):
self._dirs = [] self._dirs = []
# {path: state} # {path: state}
self.states = {} self.states = {}
self.deny_list_str.add(r".*Recycle\.Bin$") self._excluded = excluded
self.deny_list_str.add(r"denyme.*")
self.deny_list_str.add(r".*denyme")
self.deny_list_str.add(r".*/test/denyme*")
self.deny_list_str.add(r".*/test/*denyme")
self.deny_list_str.add(r"denyme")
self.deny_list_str.add(r".*\/\..*")
self.deny_list_str.add(r"^\..*")
self.compile_re()
def compile_re(self):
for expr in self.deny_list_str:
try:
self.deny_list_re.add(re.compile(expr))
if os.sep not in expr:
self.deny_list_re_files.add(re.compile(expr))
except Exception as e:
logging.debug(f"Invalid regular expression \"{expr}\" in exclude list: {e}")
print(f"re_all: {self.deny_list_re}\nre_files: {self.deny_list_re_files}")
def __contains__(self, path): def __contains__(self, path):
for p in self._dirs: for p in self._dirs:
@ -217,7 +200,7 @@ class Directories:
for folder in self._get_folders(from_folder, j): for folder in self._get_folders(from_folder, j):
yield folder yield folder
def get_state(self, path, denylist=deny_list_re): def get_state(self, path, deny_list_re=deny_list_re):
"""Returns the state of ``path``. """Returns the state of ``path``.
:rtype: :class:`DirectoryState` :rtype: :class:`DirectoryState`
@ -225,7 +208,7 @@ class Directories:
# direct match? easy result. # direct match? easy result.
if path in self.states: if path in self.states:
return self.states[path] return self.states[path]
state = self._default_state_for_path(path, denylist) or DirectoryState.Normal state = self._default_state_for_path(path, deny_list_re) or DirectoryState.Normal
prevlen = 0 prevlen = 0
# we loop through the states to find the longest matching prefix # we loop through the states to find the longest matching prefix
for p, s in self.states.items(): for p, s in self.states.items():

View File

@ -4,38 +4,172 @@
from .markable import Markable from .markable import Markable
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
import re
from os import sep
import logging
import functools
from hscommon.util import FileOrPath from hscommon.util import FileOrPath
import time
default_regexes = [r".*thumbs", r"\.DS.Store", r"\.Trash", r".*Trash-Bin"]
forbidden_regexes = [r".*", r"\/.*", r".*\/.*"]
def timer(func):
@functools.wraps(func)
def wrapper_timer(*args):
start = time.perf_counter_ns()
value = func(*args)
end = time.perf_counter_ns()
print(f"DEBUG: func {func.__name__!r} took {end - start} ns.")
return value
return wrapper_timer
def memoize(func):
func.cache = dict()
@functools.wraps(func)
def _memoize(*args):
if args not in func.cache:
func.cache[args] = func(*args)
return func.cache[args]
return _memoize
class AlreadyThereException(Exception):
"""Expression already in the list"""
def __init__(self, arg="Expression is already in excluded list."):
super().__init__(arg)
class ExcludeList(Markable): class ExcludeList(Markable):
"""Exclude list of regular expression strings to filter out directories """Exclude list of regular expression strings to filter out directories
and files that we want to avoid scanning.""" and files that we want to avoid scanning.
The list() class allows us to preserve item order without too much hassle.
The downside is we have to compare strings every time we look for an item in the list
since we use regex strings as keys.
[regex:str, compilable:bool, error:Exception, compiled:Pattern])
"""
# ---Override # ---Override
def __init__(self, app): def __init__(self):
Markable.__init__(self) Markable.__init__(self)
self.app = app self._excluded = []
self._excluded = [] # set of strings
self._count = 0 self._count = 0
self._excluded_compiled = set()
def __debug_test(self):
self.test_regexes = [
r".*Recycle\.Bin$", r"denyme.*", r".*denyme", r".*/test/denyme*",
r".*/test/*denyme", r"denyme", r".*\/\..*", r"^\..*"]
for regex in self.test_regexes:
try:
self.add(regex)
except Exception as e:
print(f"Exception loading test regex {regex}: {e}")
continue
try:
self.mark(regex)
except Exception as e:
print(f"Exception marking test regex {regex}: {e}")
def __iter__(self): def __iter__(self):
for regex in self._excluded: """Iterate in order."""
for item in self._excluded:
regex = item[0]
yield self.is_marked(regex), regex yield self.is_marked(regex), regex
def __len__(self): def __len__(self):
return self._count return self._count
def _is_markable(self, row): def is_markable(self, regex):
return True return self._is_markable(regex)
def _is_markable(self, regex):
"""Return the cached result of "compilable" property"""
# FIXME save result of compilation via memoization
# return self._excluded.get(regex)[0]
for item in self._excluded:
if item[0] == regex:
return item[1]
return False # FIXME should not be needed
def _did_mark(self, regex):
for item in self._excluded:
if item[0] == regex:
# no need to test if already present since it's a set()
self._excluded_compiled.add(item[3])
def _did_unmark(self, regex):
self._remove_compiled(regex)
def _remove_compiled(self, regex):
for item in self._excluded_compiled:
if regex in item.pattern:
self._excluded_compiled.remove(item)
break
# @timer
@memoize
def _do_compile(self, expr):
try:
return re.compile(expr)
except Exception as e:
raise(e)
# @timer
# @memoize # probably not worth memoizing this one if we memoize the above
def compile_re(self, regex):
compiled = None
try:
compiled = self._do_compile(regex)
except Exception as e:
return False, e, compiled
return True, None, compiled
def error(self, regex):
"""Return the compilation error Exception for regex. It should have a "msg" attr."""
for item in self._excluded:
if item[0] == regex:
return item[2]
@property
def compiled(self):
"""Should be used by other classes to retrieve the up-to-date list of patterns."""
return self._excluded_compiled
@property
def compiled_files(self):
"""Should be used by other classes to retrieve the up-to-date list of patterns
for files only."""
return [compiled_pattern for compiled_pattern in self.compiled if sep not in compiled_pattern.pattern]
# ---Public # ---Public
def add(self, regex): def add(self, regex, forced=False):
self._excluded.insert(0, regex) """This interface should throw exceptions if there is an error during regex compilation"""
if self.isExcluded(regex):
# This exception should never be ignored
raise AlreadyThereException()
if regex in forbidden_regexes:
raise Exception("Forbidden (dangerous) expression.")
iscompilable, exception, compiled = self.compile_re(regex)
if not iscompilable and not forced:
# This exception can be ignored, but taken into account to avoid adding to compiled set
raise exception
else:
self._do_add(regex, iscompilable, exception, compiled)
def _do_add(self, regex, iscompilable, exception, compiled):
# We need to insert at the top
self._excluded.insert(0, [regex, iscompilable, exception, compiled])
self._count = len(self._excluded) self._count = len(self._excluded)
def isExcluded(self, regex): def isExcluded(self, regex):
if regex in self._excluded: for item in self._excluded:
return True if regex == item[0]:
return True
return False return False
def clear(self): def clear(self):
@ -43,21 +177,48 @@ class ExcludeList(Markable):
self._count = 0 self._count = 0
def remove(self, regex): def remove(self, regex):
return self._excluded.remove(regex) for item in self._excluded:
if item[0] == regex:
self._excluded.remove(item)
self._remove_compiled(regex)
def rename(self, regex, newregex): def rename(self, regex, newregex):
if regex not in self._excluded: # if regex not in self._excluded or regex == newregex:
# return
if regex == newregex:
return
found = False
for item in self._excluded:
if regex == item[0]:
found = True
break
if not found:
return 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): was_marked = self.is_marked(regex)
item = self._excluded.pop(regex) is_compilable, exception, compiled = self.compile_re(newregex)
self._excluded.insert(new_index, item) for item in self._excluded:
if item[0] == regex:
# We overwrite the found entry
self._excluded[self._excluded.index(item)] =\
[newregex, is_compilable, exception, compiled]
if is_compilable and was_marked:
# Not marked by default when added, add it back
self.mark(newregex)
# def change_index(self, regex, new_index):
# """Internal list must be a list, not dict."""
# item = self._excluded.pop(regex)
# self._excluded.insert(new_index, item)
def restore_defaults(self):
for _, regex in self:
if regex not in default_regexes:
self.unmark(regex)
for default_regex in default_regexes:
if not self.isExcluded(default_regex):
self.add(default_regex)
self.mark(default_regex)
def load_from_xml(self, infile): def load_from_xml(self, infile):
"""Loads the ignore list from a XML created with save_to_xml. """Loads the ignore list from a XML created with save_to_xml.
@ -67,20 +228,29 @@ class ExcludeList(Markable):
try: try:
root = ET.parse(infile).getroot() root = ET.parse(infile).getroot()
except Exception as e: except Exception as e:
print(f"Error while loading {infile}: {e}") logging.warning(f"Error while loading {infile}: {e}")
return self.restore_defaults()
self.__debug_test()
return e
marked = set() marked = set()
exclude_elems = (e for e in root if e.tag == "exclude") exclude_elems = (e for e in root if e.tag == "exclude")
for exclude_item in exclude_elems: for exclude_item in exclude_elems:
regex_string = exclude_item.get("regex") regex_string = exclude_item.get("regex")
if not regex_string: if not regex_string:
continue continue
self.add(regex_string) try:
# "forced" avoids compilation exceptions and adds anyway
self.add(regex_string, forced=True)
except AlreadyThereException:
logging.error(f"Regex \"{regex_string}\" loaded from XML was already present in the list.")
continue
if exclude_item.get("marked") == "y": if exclude_item.get("marked") == "y":
marked.add(regex_string) marked.add(regex_string)
for item in marked:
# this adds item to the Markable "marked" set for item in marked:
self.mark(item) self.mark(item)
self.__debug_test()
def save_to_xml(self, outfile): def save_to_xml(self, outfile):
"""Create a XML file that can be used by load_from_xml. """Create a XML file that can be used by load_from_xml.
@ -88,10 +258,143 @@ class ExcludeList(Markable):
outfile can be a file object or a filename. outfile can be a file object or a filename.
""" """
root = ET.Element("exclude_list") root = ET.Element("exclude_list")
for regex in self._excluded: # reversed in order to keep order of entries when reloading from xml later
for item in reversed(self._excluded):
exclude_node = ET.SubElement(root, "exclude") exclude_node = ET.SubElement(root, "exclude")
exclude_node.set("regex", str(regex)) exclude_node.set("regex", str(item[0]))
exclude_node.set("marked", ("y" if self.is_marked(regex) else "n")) exclude_node.set("marked", ("y" if self.is_marked(item[0]) else "n"))
tree = ET.ElementTree(root) tree = ET.ElementTree(root)
with FileOrPath(outfile, "wb") as fp: with FileOrPath(outfile, "wb") as fp:
tree.write(fp, encoding="utf-8") tree.write(fp, encoding="utf-8")
class ExcludeDict(ExcludeList):
"""Version implemented around a dictionary instead of a list, which implies
to keep the index of each string-key as its sub-element and keep it updated
whenever insert/remove is done."""
def __init__(self):
Markable.__init__(self)
# { "regex": { "index": int, "compilable": bool, "error": str, "compiled": Pattern or None}}
# Note: "compilable" key should only be updated on add / rename
self._excluded = {}
self._count = 0
self._excluded_compiled = set()
def __iter__(self):
"""Iterate in order."""
for regex in ordered_keys(self._excluded):
yield self.is_marked(regex), regex
def __len__(self):
return self._count
def is_markable(self, regex):
return self._is_markable(regex)
def _is_markable(self, regex):
"""Return the cached result of "compilable" property"""
exists = self._excluded.get(regex)
if exists:
return exists.get("compilable")
return False
def _did_mark(self, regex):
# self._excluded[regex][0] = True # is compilable
try:
self._excluded_compiled.add(self._excluded[regex]["compiled"])
except Exception as e:
print(f"Exception while adding regex {regex} to compiled set: {e}")
return
def _did_unmark(self, regex):
self._remove_compiled(regex)
def is_compilable(self, regex):
"""Returns the cached "compilable" value"""
return self._excluded[regex]["compilable"]
def error(self, regex):
"""Return the compilation error message for regex string"""
return self._excluded.get(regex).get("error")
@property
def compiled(self):
"""Should be used by other classes to retrieve the up-to-date list of patterns."""
return self._excluded_compiled
@property
def compiled_files(self):
"""Should be used by other classes to retrieve the up-to-date list of patterns
for files only."""
return [compiled_pattern for compiled_pattern in self.compiled if sep not in compiled_pattern.pattern]
# ---Public
def _do_add(self, regex, iscompilable, exception, compiled):
# We always insert at the top, so index should be 0 and other indices should be pushed by one
for value in self._excluded.values():
value["index"] += 1
self._excluded[regex] = {"index": 0, "compilable": iscompilable, "error": exception, "compiled": compiled}
self._count = len(self._excluded)
def isExcluded(self, regex):
if regex in self._excluded.keys():
return True
return False
def clear(self):
self._excluded = {}
self._count = 0
def remove(self, regex):
old_value = self._excluded.pop(regex)
# Bring down all indices which where above it
index = old_value["index"]
if index == len(self._excluded):
self._remove_compiled(regex)
return
for value in self._excluded.values():
if value.get("index") > old_value["index"]:
value["index"] -= 1
self._remove_compiled(regex)
def rename(self, regex, newregex):
if regex == newregex or regex not in self._excluded.keys():
return
was_marked = self.is_marked(regex)
previous = self._excluded.pop(regex)
iscompilable, error, compiled = self.compile_re(newregex)
self._excluded[newregex] = {"index": previous["index"], "compilable": iscompilable, "error": error, "compiled": compiled}
if was_marked and iscompilable:
self.mark(newregex)
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")
# reversed in order to keep order of entries when reloading from xml later
reversed_list = []
for key in ordered_keys(self._excluded):
reversed_list.append(key)
for item in reversed(reversed_list):
exclude_node = ET.SubElement(root, "exclude")
exclude_node.set("regex", str(item))
exclude_node.set("marked", ("y" if self.is_marked(item) else "n"))
tree = ET.ElementTree(root)
with FileOrPath(outfile, "wb") as fp:
tree.write(fp, encoding="utf-8")
def ordered_keys(_dict):
"""Returns an iterator over the keys of dictionary sorted by "index" key"""
if not len(_dict):
return
list_of_items = []
for item in _dict.items():
list_of_items.append(item)
list_of_items.sort(key=lambda x: x[1].get("index"))
for item in list_of_items:
yield item[0]

View File

@ -8,8 +8,6 @@
# from hscommon.trans import tr # from hscommon.trans import tr
from .exclude_list_table import ExcludeListTable from .exclude_list_table import ExcludeListTable
default_regexes = [".*thumbs", "\.DS.Store", "\.Trash", "Trash-Bin"]
class ExcludeListDialogCore: class ExcludeListDialogCore:
# --- View interface # --- View interface
@ -22,13 +20,7 @@ class ExcludeListDialogCore:
self.exclude_list_table = ExcludeListTable(self, app) # GUITable, this is the "model" self.exclude_list_table = ExcludeListTable(self, app) # GUITable, this is the "model"
def restore_defaults(self): def restore_defaults(self):
for _, regex in self.exclude_list: self.exclude_list.restore_defaults()
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() self.refresh()
def refresh(self): def refresh(self):
@ -55,9 +47,11 @@ class ExcludeListDialogCore:
return False return False
def add(self, regex): def add(self, regex):
self.exclude_list.add(regex) try:
self.exclude_list.add(regex)
except Exception as e:
raise(e)
self.exclude_list.mark(regex) self.exclude_list.mark(regex)
# TODO make checks here before adding to GUI
self.exclude_list_table.add(regex) self.exclude_list_table.add(regex)
def show(self): def show(self):

View File

@ -12,21 +12,18 @@ tr = trget("ui")
class ExcludeListTable(GUITable, DupeGuruGUIObject): class ExcludeListTable(GUITable, DupeGuruGUIObject):
COLUMNS = [ COLUMNS = [
Column("marked", ""), Column("marked", ""),
Column("regex", tr("Regex")) Column("regex", tr("Regular Expressions"))
] ]
def __init__(self, exclude_list_dialog, app): def __init__(self, exclude_list_dialog, app):
GUITable.__init__(self) GUITable.__init__(self)
DupeGuruGUIObject.__init__(self, app) DupeGuruGUIObject.__init__(self, app)
# self.columns = Columns(self, prefaccess=app, savename="ExcludeTable")
self.columns = Columns(self) self.columns = Columns(self)
self.dialog = exclude_list_dialog self.dialog = exclude_list_dialog
def rename_selected(self, newname): def rename_selected(self, newname):
row = self.selected_row row = self.selected_row
if row is None: 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 return False
row._data = None row._data = None
return self.dialog.rename_selected(newname) return self.dialog.rename_selected(newname)
@ -95,7 +92,7 @@ class ExcludeListRow(Row):
@property @property
def markable(self): def markable(self):
return True return self._app.exclude_list.is_markable(self.regex)
@property @property
def marked(self): def marked(self):
@ -108,10 +105,18 @@ class ExcludeListRow(Row):
else: else:
self._app.exclude_list.unmark(self.regex) self._app.exclude_list.unmark(self.regex)
@property
def error(self):
# This assumes error() returns an Exception()
message = self._app.exclude_list.error(self.regex)
if hasattr(message, "msg"):
return self._app.exclude_list.error(self.regex).msg
else:
return message # Exception object
# @property # @property
# def regex(self): # def regex(self):
# return self.regex # return self.regex
# @regex.setter # @regex.setter
# def regex(self, value): # def regex(self, value):
# self._app.exclude_list.add(self._regex, value) # self._app.exclude_list.add(self._regex, value)

BIN
images/dialog-error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -10,5 +10,6 @@
<file alias="zoom_out">../images/old_zoom_out.png</file> <file alias="zoom_out">../images/old_zoom_out.png</file>
<file alias="zoom_original">../images/old_zoom_original.png</file> <file alias="zoom_original">../images/old_zoom_original.png</file>
<file alias="zoom_best_fit">../images/old_zoom_best_fit.png</file> <file alias="zoom_best_fit">../images/old_zoom_best_fit.png</file>
<file alias="error">../images/dialog-error.png</file>
</qresource> </qresource>
</RCC> </RCC>

View File

@ -9,6 +9,7 @@ from PyQt5.QtWidgets import (
) )
from .exclude_list_table import ExcludeListTable, ExcludeView from .exclude_list_table import ExcludeListTable, ExcludeView
from core.exclude import AlreadyThereException
from hscommon.trans import trget from hscommon.trans import trget
tr = trget("ui") tr = trget("ui")
@ -17,16 +18,18 @@ class ExcludeListDialog(QDialog):
def __init__(self, app, parent, model, **kwargs): def __init__(self, app, parent, model, **kwargs):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
super().__init__(parent, flags, **kwargs) super().__init__(parent, flags, **kwargs)
self.app = app
self.specific_actions = frozenset() self.specific_actions = frozenset()
self._setupUI() self._setupUI()
self.model = model # ExcludeListDialogCore self.model = model # ExcludeListDialogCore
self.model.view = self self.model.view = self
self.table = ExcludeListTable(app, view=self.tableView) self.table = ExcludeListTable(app, view=self.tableView) # Qt ExcludeListTable
self.buttonAdd.clicked.connect(self.addItem) self.buttonAdd.clicked.connect(self.addStringFromLineEdit)
self.buttonRemove.clicked.connect(self.removeItem) self.buttonRemove.clicked.connect(self.removeSelected)
self.buttonRestore.clicked.connect(self.restoreDefaults) self.buttonRestore.clicked.connect(self.restoreDefaults)
self.buttonClose.clicked.connect(self.accept) self.buttonClose.clicked.connect(self.accept)
self.buttonHelp.clicked.connect(self.display_help_message)
def _setupUI(self): def _setupUI(self):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@ -35,6 +38,7 @@ class ExcludeListDialog(QDialog):
self.buttonRemove = QPushButton(tr("Remove Selected")) self.buttonRemove = QPushButton(tr("Remove Selected"))
self.buttonRestore = QPushButton(tr("Restore defaults")) self.buttonRestore = QPushButton(tr("Restore defaults"))
self.buttonClose = QPushButton(tr("Close")) self.buttonClose = QPushButton(tr("Close"))
self.buttonHelp = QPushButton(tr("Help"))
self.linedit = QLineEdit() self.linedit = QLineEdit()
self.tableView = ExcludeView() self.tableView = ExcludeView()
triggers = ( triggers = (
@ -43,25 +47,26 @@ class ExcludeListDialog(QDialog):
| QAbstractItemView.SelectedClicked | QAbstractItemView.SelectedClicked
) )
self.tableView.setEditTriggers(triggers) self.tableView.setEditTriggers(triggers)
self.tableView.horizontalHeader().setVisible(True)
self.tableView.setSelectionMode(QTableView.ExtendedSelection) self.tableView.setSelectionMode(QTableView.ExtendedSelection)
self.tableView.setSelectionBehavior(QTableView.SelectRows) self.tableView.setSelectionBehavior(QTableView.SelectRows)
# vheader = self.tableView.verticalHeader() self.tableView.setShowGrid(False)
# vheader.setSectionsMovable(True) vheader = self.tableView.verticalHeader()
# vheader.setVisible(True) vheader.setSectionsMovable(True)
# vheader.setDefaultSectionSize(50) vheader.setVisible(False)
hheader = self.tableView.horizontalHeader() hheader = self.tableView.horizontalHeader()
hheader.setSectionsMovable(False) hheader.setSectionsMovable(False)
hheader.setSectionResizeMode(QHeaderView.Fixed) hheader.setSectionResizeMode(QHeaderView.Fixed)
hheader.setStretchLastSection(True) hheader.setStretchLastSection(True)
hheader.setHighlightSections(False) hheader.setHighlightSections(False)
hheader.setVisible(True)
gridlayout.addWidget(self.linedit, 0, 0) gridlayout.addWidget(self.linedit, 0, 0)
gridlayout.addWidget(self.buttonAdd, 0, 1, Qt.AlignLeft) gridlayout.addWidget(self.buttonAdd, 0, 1, Qt.AlignLeft)
gridlayout.addWidget(self.buttonRemove, 1, 1, Qt.AlignLeft) gridlayout.addWidget(self.buttonRemove, 1, 1, Qt.AlignLeft)
gridlayout.addWidget(self.buttonRestore, 2, 1, Qt.AlignLeft) gridlayout.addWidget(self.buttonRestore, 2, 1, Qt.AlignLeft)
gridlayout.addWidget(self.tableView, 1, 0, 4, 1) gridlayout.addWidget(self.buttonHelp, 3, 1, Qt.AlignLeft)
gridlayout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), 3, 1) gridlayout.addWidget(self.tableView, 1, 0, 5, 1)
gridlayout.addWidget(self.buttonClose, 4, 1) gridlayout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), 4, 1)
gridlayout.addWidget(self.buttonClose, 5, 1)
layout.addLayout(gridlayout) layout.addLayout(gridlayout)
# --- model --> view # --- model --> view
@ -69,15 +74,28 @@ class ExcludeListDialog(QDialog):
super().show() super().show()
@pyqtSlot() @pyqtSlot()
def addItem(self): def addStringFromLineEdit(self):
text = self.linedit.text() text = self.linedit.text()
if not text: if not text:
return return
self.model.add(text) try:
self.model.add(text)
except AlreadyThereException:
self.app.show_message("Expression already in the list.")
return
except Exception as e:
self.app.show_message(f"Expression is invalid: {e}")
return
self.linedit.clear() self.linedit.clear()
def removeItem(self): def removeSelected(self):
self.model.remove_selected() self.model.remove_selected()
def restoreDefaults(self): def restoreDefaults(self):
self.model.restore_defaults() self.model.restore_defaults()
def display_help_message(self):
self.app.show_message("""\
These python regular expressions will filter out files and directory paths \
specified here.\nDuring directory selection, paths filtered here will be added as \
"Skipped" by default, but regular files will be ignored altogether during scans.""")

View File

@ -2,8 +2,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 Qt, QModelIndex, pyqtSignal from PyQt5.QtCore import Qt, QModelIndex
from PyQt5.QtGui import QBrush, QFont, QFontMetrics, QColor from PyQt5.QtGui import QFont, QFontMetrics, QIcon
from PyQt5.QtWidgets import QTableView from PyQt5.QtWidgets import QTableView
from qtlib.column import Column from qtlib.column import Column
@ -20,7 +20,7 @@ class ExcludeListTable(Table):
def __init__(self, app, view, **kwargs): def __init__(self, app, view, **kwargs):
model = app.model.exclude_list_dialog.exclude_list_table # pointer to GUITable model = app.model.exclude_list_dialog.exclude_list_table # pointer to GUITable
super().__init__(model, view, **kwargs) super().__init__(model, view, **kwargs)
view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder) # view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder)
font = view.font() font = view.font()
font.setPointSize(app.prefs.tableFontSize) font.setPointSize(app.prefs.tableFontSize)
view.setFont(font) view.setFont(font)
@ -32,6 +32,10 @@ class ExcludeListTable(Table):
if column.name == "marked": if column.name == "marked":
if role == Qt.CheckStateRole and row.markable: if role == Qt.CheckStateRole and row.markable:
return Qt.Checked if row.marked else Qt.Unchecked return Qt.Checked if row.marked else Qt.Unchecked
if role == Qt.ToolTipRole and not row.markable:
return "Compilation error: " + row.get_cell_value("error")
if role == Qt.DecorationRole and not row.markable:
return QIcon.fromTheme("dialog-error", QIcon(":/error"))
return None return None
if role == Qt.DisplayRole: if role == Qt.DisplayRole:
return row.data[column.name] return row.data[column.name]
@ -43,12 +47,12 @@ class ExcludeListTable(Table):
return None return None
def _getFlags(self, row, column): def _getFlags(self, row, column):
flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable flags = Qt.ItemIsEnabled
if column.name == "marked": if column.name == "marked":
if row.markable: if row.markable:
flags |= Qt.ItemIsUserCheckable flags |= Qt.ItemIsUserCheckable
elif column.name == "regex": elif column.name == "regex":
flags |= Qt.ItemIsEditable flags |= Qt.ItemIsEditable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled
return flags return flags
def _setData(self, row, column, value, role): def _setData(self, row, column, value, role):