From 2eaf7e7893be7e09cba17a65c6ef80eaa741398a Mon Sep 17 00:00:00 2001 From: glubsy Date: Mon, 17 Aug 2020 04:13:20 +0200 Subject: [PATCH] Implement exclude list dialog on the Qt side --- core/app.py | 4 +- core/directories.py | 29 +-- core/exclude.py | 365 +++++++++++++++++++++++++++++--- core/gui/exclude_list_dialog.py | 16 +- core/gui/exclude_list_table.py | 17 +- images/dialog-error.png | Bin 0 -> 1398 bytes qt/dg.qrc | 1 + qt/exclude_list_dialog.py | 46 ++-- qt/exclude_list_table.py | 14 +- 9 files changed, 400 insertions(+), 92 deletions(-) create mode 100644 images/dialog-error.png diff --git a/core/app.py b/core/app.py index 53627c8c..46cff0a8 100644 --- a/core/app.py +++ b/core/app.py @@ -26,7 +26,7 @@ from .pe.photo import get_delta_dimensions from .util import cmp_value, fix_surrogate_encoding from . import directories, results, export, fs, prioritize from .ignore import IgnoreList -from .exclude import ExcludeList +from .exclude import ExcludeList as ExcludeList from .scanner import ScanType from .gui.deletion_options import DeletionOptions from .gui.details_panel import DetailsPanel @@ -139,10 +139,10 @@ class DupeGuru(Broadcaster): os.makedirs(self.appdata) self.app_mode = AppMode.Standard self.discarded_file_count = 0 + self.exclude_list = ExcludeList() self.directories = directories.Directories() self.results = results.Results(self) self.ignore_list = IgnoreList() - self.exclude_list = ExcludeList(self) # 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 # defined in the scanner class. diff --git a/core/directories.py b/core/directories.py index 9a372166..5f465818 100644 --- a/core/directories.py +++ b/core/directories.py @@ -5,7 +5,6 @@ # http://www.gnu.org/licenses/gpl-3.0.html import os -import re from xml.etree import ElementTree as ET import logging @@ -14,6 +13,7 @@ from hscommon.path import Path from hscommon.util import FileOrPath from . import fs +from .exclude import ExcludeList __all__ = [ "Directories", @@ -53,34 +53,17 @@ class Directories: 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. """ + # FIXME: if there is zero item in these sets, the for each loops will yield NOTHING deny_list_str = set() deny_list_re = set() deny_list_re_files = set() # ---Override - def __init__(self): + def __init__(self, excluded=ExcludeList()): self._dirs = [] # {path: state} self.states = {} - self.deny_list_str.add(r".*Recycle\.Bin$") - 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}") + self._excluded = excluded def __contains__(self, path): for p in self._dirs: @@ -217,7 +200,7 @@ class Directories: for folder in self._get_folders(from_folder, j): 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``. :rtype: :class:`DirectoryState` @@ -225,7 +208,7 @@ class Directories: # direct match? easy result. if path in self.states: 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 # we loop through the states to find the longest matching prefix for p, s in self.states.items(): diff --git a/core/exclude.py b/core/exclude.py index 03b2dc31..74c44c2d 100644 --- a/core/exclude.py +++ b/core/exclude.py @@ -4,38 +4,172 @@ from .markable import Markable from xml.etree import ElementTree as ET +import re +from os import sep +import logging +import functools 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): """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 - def __init__(self, app): + def __init__(self): Markable.__init__(self) - self.app = app - self._excluded = [] # set of strings + self._excluded = [] 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): - for regex in self._excluded: + """Iterate in order.""" + for item in self._excluded: + regex = item[0] yield self.is_marked(regex), regex def __len__(self): return self._count - def _is_markable(self, row): - return True + def is_markable(self, regex): + 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 - def add(self, regex): - self._excluded.insert(0, regex) + def add(self, regex, forced=False): + """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) def isExcluded(self, regex): - if regex in self._excluded: - return True + for item in self._excluded: + if regex == item[0]: + return True return False def clear(self): @@ -43,21 +177,48 @@ class ExcludeList(Markable): self._count = 0 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): - 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 - 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) + was_marked = self.is_marked(regex) + is_compilable, exception, compiled = self.compile_re(newregex) + 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): """Loads the ignore list from a XML created with save_to_xml. @@ -67,20 +228,29 @@ class ExcludeList(Markable): try: root = ET.parse(infile).getroot() except Exception as e: - print(f"Error while loading {infile}: {e}") - return + logging.warning(f"Error while loading {infile}: {e}") + self.restore_defaults() + self.__debug_test() + return e + 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) + 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": marked.add(regex_string) - for item in marked: - # this adds item to the Markable "marked" set - self.mark(item) + + for item in marked: + self.mark(item) + self.__debug_test() def save_to_xml(self, outfile): """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. """ 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.set("regex", str(regex)) - exclude_node.set("marked", ("y" if self.is_marked(regex) else "n")) + exclude_node.set("regex", str(item[0])) + exclude_node.set("marked", ("y" if self.is_marked(item[0]) else "n")) tree = ET.ElementTree(root) with FileOrPath(outfile, "wb") as fp: 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] diff --git a/core/gui/exclude_list_dialog.py b/core/gui/exclude_list_dialog.py index 1d258033..e35d4c9f 100644 --- a/core/gui/exclude_list_dialog.py +++ b/core/gui/exclude_list_dialog.py @@ -8,8 +8,6 @@ # from hscommon.trans import tr from .exclude_list_table import ExcludeListTable -default_regexes = [".*thumbs", "\.DS.Store", "\.Trash", "Trash-Bin"] - class ExcludeListDialogCore: # --- View interface @@ -22,13 +20,7 @@ class ExcludeListDialogCore: 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.exclude_list.restore_defaults() self.refresh() def refresh(self): @@ -55,9 +47,11 @@ class ExcludeListDialogCore: return False 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) - # TODO make checks here before adding to GUI self.exclude_list_table.add(regex) def show(self): diff --git a/core/gui/exclude_list_table.py b/core/gui/exclude_list_table.py index 0d495a86..6a0294f7 100644 --- a/core/gui/exclude_list_table.py +++ b/core/gui/exclude_list_table.py @@ -12,21 +12,18 @@ tr = trget("ui") class ExcludeListTable(GUITable, DupeGuruGUIObject): COLUMNS = [ Column("marked", ""), - Column("regex", tr("Regex")) + Column("regex", tr("Regular Expressions")) ] 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) @@ -95,7 +92,7 @@ class ExcludeListRow(Row): @property def markable(self): - return True + return self._app.exclude_list.is_markable(self.regex) @property def marked(self): @@ -108,10 +105,18 @@ class ExcludeListRow(Row): else: 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 # def regex(self): # return self.regex # @regex.setter # def regex(self, value): - # self._app.exclude_list.add(self._regex, value) \ No newline at end of file + # self._app.exclude_list.add(self._regex, value) diff --git a/images/dialog-error.png b/images/dialog-error.png new file mode 100644 index 0000000000000000000000000000000000000000..625c7ff83899dd179075d2c0f0e38a172f424e87 GIT binary patch literal 1398 zcmV-+1&R8JP)B6i9Q-Ev1`6sDvauxc&06ryRm*JrTPZf9^rdcgitKCAW6otNT zc+I90bh~20Uqbkb2|sw6>ZR)f@HLSq34CyHdb&Ju!y8s=3kyaPj(Q#%1c+rJg#xiE z?)meqoIBU9A3v_iwoiNTuIH+5Tou4KMQ)<-gYwkW4R3n4yHy4%6@q%5Xk~?{)dE07 z$Yv1R#u^*LOl*|Pocq_myz~3^`5MQsdWPy3`vK&ueh?x*xaqy`T^_&T4NdRVDZ=ID z?XY}!erhHF&W&zFXc+AK#V=ak=H_RP!Rz0l8utbGipaxk$N9l+9{SKy(Q)V=JcvdS zi1Y=>PwnYx?D=_){QB3e^$Qn%{4~|4k(0rW_>#!Y4UzBOHVH3;A)S5uw!-_V2Sl*A zh=1e=w|Mx&%jL1L$9+}gjXeRrEn?d6^IIfsFPJ9Y@#COMUb*V~a0!v?5}Z8AZ65Wg za-mp!+t)?zby)x(oDNS_unV9JPa0WRunU$&kkIal;y8uihIhJC zMc{3_01SBM*xXz-SX$zLO7+UN<<_USa&>kVO!Ic%78wCB(&suN_Zymi`$LP6rGlKbHLSCO7H(yzlvB%;q(JN7y!g zvx&ed0LzC9X18lc?KZ*YCSsb9SW;V7I_>$8k)0|*1hOgHE#IxE(E!V$<+^bMCjhMH zsxEy^q}5(sEha;ez3X!_j4dGB-m+z3IL^*;3aPp~IJ@oQPA7GGUNi`>CnwohTWc%) z5x`soh+{M{K|nt#y=4px5J5TwQh?tA7ytraO{t7p4K)lDuGn#A z?d`4VmzP7}hfh+CwgH}|`n$mM_BS>+V{3Hus=~F8rBHxsmHOhMAHiGmuVVyWacOzE zBZGs;z`%8?I?|uwOI{pBUwoG8sXPErRsClihTm9PTx_PT!m_Tu>YVMVDO#SV zn=31B2yg58N!Emywc71NE9>ii3W053ebrgz_QV9CZL@gsVrK;&|7_LOo&YZa>ud0W zRo7jrZ)|k!@o}VBysE0PES#irPejk2JLmmF^)0W4MG-0Y{3;fa5nyBv=H3YJA9bAD zPS$DzMjWSaF|_|}h!_TDwTe-xu+eBl>#p1RCcN|K@MmBZSW(q_FMt{_0*qRTUja|K z6Fjw4ELtO_l5L8ho<|%6JI@A@G!(I{^gc&yn^vczYmG*DE{^wo7CwIhPG#Y1zC;|hS3z=;o1_Hp_F>RoussVlUUkb7oV@J=PasU7T07*qoM6N<$ Eg8I^+NdN!< literal 0 HcmV?d00001 diff --git a/qt/dg.qrc b/qt/dg.qrc index 760f2a85..7b2846bf 100644 --- a/qt/dg.qrc +++ b/qt/dg.qrc @@ -10,5 +10,6 @@ ../images/old_zoom_out.png ../images/old_zoom_original.png ../images/old_zoom_best_fit.png + ../images/dialog-error.png diff --git a/qt/exclude_list_dialog.py b/qt/exclude_list_dialog.py index 3c8a1872..96389568 100644 --- a/qt/exclude_list_dialog.py +++ b/qt/exclude_list_dialog.py @@ -9,6 +9,7 @@ from PyQt5.QtWidgets import ( ) from .exclude_list_table import ExcludeListTable, ExcludeView +from core.exclude import AlreadyThereException from hscommon.trans import trget tr = trget("ui") @@ -17,16 +18,18 @@ class ExcludeListDialog(QDialog): def __init__(self, app, parent, model, **kwargs): flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint super().__init__(parent, flags, **kwargs) + self.app = app self.specific_actions = frozenset() self._setupUI() self.model = model # ExcludeListDialogCore 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.buttonRemove.clicked.connect(self.removeItem) + self.buttonAdd.clicked.connect(self.addStringFromLineEdit) + self.buttonRemove.clicked.connect(self.removeSelected) self.buttonRestore.clicked.connect(self.restoreDefaults) self.buttonClose.clicked.connect(self.accept) + self.buttonHelp.clicked.connect(self.display_help_message) def _setupUI(self): layout = QVBoxLayout(self) @@ -35,6 +38,7 @@ class ExcludeListDialog(QDialog): self.buttonRemove = QPushButton(tr("Remove Selected")) self.buttonRestore = QPushButton(tr("Restore defaults")) self.buttonClose = QPushButton(tr("Close")) + self.buttonHelp = QPushButton(tr("Help")) self.linedit = QLineEdit() self.tableView = ExcludeView() triggers = ( @@ -43,25 +47,26 @@ class ExcludeListDialog(QDialog): | 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) + self.tableView.setShowGrid(False) + vheader = self.tableView.verticalHeader() + vheader.setSectionsMovable(True) + vheader.setVisible(False) hheader = self.tableView.horizontalHeader() hheader.setSectionsMovable(False) hheader.setSectionResizeMode(QHeaderView.Fixed) hheader.setStretchLastSection(True) hheader.setHighlightSections(False) + hheader.setVisible(True) 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) + gridlayout.addWidget(self.buttonHelp, 3, 1, Qt.AlignLeft) + gridlayout.addWidget(self.tableView, 1, 0, 5, 1) + gridlayout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), 4, 1) + gridlayout.addWidget(self.buttonClose, 5, 1) layout.addLayout(gridlayout) # --- model --> view @@ -69,15 +74,28 @@ class ExcludeListDialog(QDialog): super().show() @pyqtSlot() - def addItem(self): + def addStringFromLineEdit(self): text = self.linedit.text() if not text: 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() - def removeItem(self): + def removeSelected(self): self.model.remove_selected() def restoreDefaults(self): 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.""") diff --git a/qt/exclude_list_table.py b/qt/exclude_list_table.py index d6f57003..5f729dfa 100644 --- a/qt/exclude_list_table.py +++ b/qt/exclude_list_table.py @@ -2,8 +2,8 @@ # 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.QtCore import Qt, QModelIndex +from PyQt5.QtGui import QFont, QFontMetrics, QIcon from PyQt5.QtWidgets import QTableView from qtlib.column import Column @@ -20,7 +20,7 @@ class ExcludeListTable(Table): 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) + # view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder) font = view.font() font.setPointSize(app.prefs.tableFontSize) view.setFont(font) @@ -32,6 +32,10 @@ class ExcludeListTable(Table): if column.name == "marked": if role == Qt.CheckStateRole and row.markable: 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 if role == Qt.DisplayRole: return row.data[column.name] @@ -43,12 +47,12 @@ class ExcludeListTable(Table): return None def _getFlags(self, row, column): - flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + flags = Qt.ItemIsEnabled if column.name == "marked": if row.markable: flags |= Qt.ItemIsUserCheckable elif column.name == "regex": - flags |= Qt.ItemIsEditable + flags |= Qt.ItemIsEditable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled return flags def _setData(self, row, column, value, role):