1
0
mirror of https://github.com/arsenetar/dupeguru.git synced 2026-01-22 06:37:17 +00:00

Finish moving all qtlib py files to qt

This commit is contained in:
2022-05-08 19:22:08 -05:00
parent 18359c3ea6
commit 36280b01e6
36 changed files with 40 additions and 50 deletions

79
qt/about_box.py Normal file
View File

@@ -0,0 +1,79 @@
# Created By: Virgil Dupras
# Created On: 2009-05-09
# 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 PyQt5.QtCore import Qt, QCoreApplication, QTimer
from PyQt5.QtGui import QPixmap, QFont
from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QSizePolicy, QHBoxLayout, QVBoxLayout, QLabel
from core.util import check_for_update
from qt.util import move_to_screen_center
from hscommon.trans import trget
tr = trget("qtlib") # TODO change to ui
class AboutBox(QDialog):
def __init__(self, parent, app, **kwargs):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint | Qt.MSWindowsFixedSizeDialogHint
super().__init__(parent, flags, **kwargs)
self.app = app
self._setupUi()
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
def _setupUi(self):
self.setWindowTitle(tr("About {}").format(QCoreApplication.instance().applicationName()))
size_policy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.setSizePolicy(size_policy)
main_layout = QHBoxLayout(self)
logo_label = QLabel()
logo_label.setPixmap(QPixmap(":/%s_big" % self.app.LOGO_NAME))
main_layout.addWidget(logo_label)
detail_layout = QVBoxLayout()
name_label = QLabel()
font = QFont()
font.setWeight(75)
font.setBold(True)
name_label.setFont(font)
name_label.setText(QCoreApplication.instance().applicationName())
detail_layout.addWidget(name_label)
version_label = QLabel()
version_label.setText(tr("Version {}").format(QCoreApplication.instance().applicationVersion()))
detail_layout.addWidget(version_label)
self.update_label = QLabel(tr("Checking for updates..."))
self.update_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
self.update_label.setOpenExternalLinks(True)
detail_layout.addWidget(self.update_label)
license_label = QLabel()
license_label.setText(tr("Licensed under GPLv3"))
detail_layout.addWidget(license_label)
spacer_label = QLabel()
spacer_label.setFont(font)
detail_layout.addWidget(spacer_label)
self.button_box = QDialogButtonBox()
self.button_box.setOrientation(Qt.Horizontal)
self.button_box.setStandardButtons(QDialogButtonBox.Ok)
detail_layout.addWidget(self.button_box)
main_layout.addLayout(detail_layout)
def _check_for_update(self):
update = check_for_update(QCoreApplication.instance().applicationVersion(), include_prerelease=False)
if update is None:
self.update_label.setText(tr("No update available."))
else:
self.update_label.setText(
tr('New version {} available, download <a href="{}">here</a>.').format(update["version"], update["url"])
)
def showEvent(self, event):
self.update_label.setText(tr("Checking for updates..."))
# have to do this here as the frameGeometry is not correct until shown
move_to_screen_center(self)
super().showEvent(event)
QTimer.singleShot(0, self._check_for_update)

View File

@@ -14,10 +14,10 @@ from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox, QSt
from hscommon.trans import trget
from hscommon import desktop, plat
from qtlib.about_box import AboutBox
from qtlib.recent import Recent
from qtlib.util import create_actions
from qtlib.progress_window import ProgressWindow
from qt.about_box import AboutBox
from qt.recent import Recent
from qt.util import create_actions
from qt.progress_window import ProgressWindow
from core.app import AppMode, DupeGuru as DupeGuruModel
import core.pe.photo

110
qt/column.py Normal file
View File

@@ -0,0 +1,110 @@
# Created By: Virgil Dupras
# Created On: 2009-11-25
# 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 PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QHeaderView
class Column:
def __init__(
self,
attrname,
default_width,
editor=None,
alignment=Qt.AlignLeft,
cant_truncate=False,
painter=None,
resize_to_fit=False,
):
self.attrname = attrname
self.default_width = default_width
self.editor = editor
# See moneyguru #15. Painter attribute was added to allow custom painting of amount value and
# currency information. Can be used as a pattern for custom painting of any column.
self.painter = painter
self.alignment = alignment
# This is to indicate, during printing, that a column can't have its data truncated.
self.cant_truncate = cant_truncate
self.resize_to_fit = resize_to_fit
class Columns:
def __init__(self, model, columns, header_view):
self.model = model
self._header_view = header_view
self._header_view.setDefaultAlignment(Qt.AlignLeft)
def setspecs(col, modelcol):
modelcol.default_width = col.default_width
modelcol.editor = col.editor
modelcol.painter = col.painter
modelcol.resize_to_fit = col.resize_to_fit
modelcol.alignment = col.alignment
modelcol.cant_truncate = col.cant_truncate
if columns:
for col in columns:
modelcol = self.model.column_by_name(col.attrname)
setspecs(col, modelcol)
else:
col = Column("", 100)
for modelcol in self.model.column_list:
setspecs(col, modelcol)
self.model.view = self
self._header_view.sectionMoved.connect(self.header_section_moved)
self._header_view.sectionResized.connect(self.header_section_resized)
# See moneyguru #14 and #15. This was added in order to allow automatic resizing of columns.
for column in self.model.column_list:
if column.resize_to_fit:
self._header_view.setSectionResizeMode(column.logical_index, QHeaderView.ResizeToContents)
# --- Public
def set_columns_width(self, widths):
# `widths` can be None. If it is, then default widths are set.
columns = self.model.column_list
if not widths:
widths = [column.default_width for column in columns]
for column, width in zip(columns, widths):
if width == 0: # column was hidden before.
width = column.default_width
self._header_view.resizeSection(column.logical_index, width)
def set_columns_order(self, column_indexes):
if not column_indexes:
return
for dest_index, column_index in enumerate(column_indexes):
# moveSection takes 2 visual index arguments, so we have to get our visual index first
visual_index = self._header_view.visualIndex(column_index)
self._header_view.moveSection(visual_index, dest_index)
# --- Events
def header_section_moved(self, logical_index, old_visual_index, new_visual_index):
attrname = self.model.column_by_index(logical_index).name
self.model.move_column(attrname, new_visual_index)
def header_section_resized(self, logical_index, old_size, new_size):
attrname = self.model.column_by_index(logical_index).name
self.model.resize_column(attrname, new_size)
# --- model --> view
def restore_columns(self):
columns = self.model.ordered_columns
indexes = [col.logical_index for col in columns]
self.set_columns_order(indexes)
widths = [col.width for col in self.model.column_list]
if not any(widths):
widths = None
self.set_columns_width(widths)
for column in self.model.column_list:
visible = self.model.column_is_visible(column.name)
self._header_view.setSectionHidden(column.logical_index, not visible)
def set_column_visible(self, colname, visible):
column = self.model.column_by_name(colname)
self._header_view.setSectionHidden(column.logical_index, not visible)

View File

@@ -10,7 +10,7 @@ from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QCheckBox, QDialogButtonBox
from hscommon.trans import trget
from qtlib.radio_box import RadioBox
from qt.radio_box import RadioBox
tr = trget("ui")

View File

@@ -9,7 +9,7 @@
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDockWidget, QWidget
from qtlib.util import move_to_screen_center
from qt.util import move_to_screen_center
from .details_table import DetailsModel
from hscommon.plat import ISLINUX

View File

@@ -4,7 +4,7 @@
<file alias="logo_se_big">../images/dgse_logo_128.png</file>
<file alias="plus">../images/plus_8.png</file>
<file alias="minus">../images/minus_8.png</file>
<file alias="search_clear_13">../qtlib/images/search_clear_13.png</file>
<file alias="search_clear_13">../images/search_clear_13.png</file>
<file alias="exchange">../images/exchange_purple_upscaled.png</file>
<file alias="zoom_in">../images/old_zoom_in.png</file>
<file alias="zoom_out">../images/old_zoom_out.png</file>

View File

@@ -27,9 +27,9 @@ from PyQt5.QtGui import QPixmap, QIcon
from hscommon.trans import trget
from core.app import AppMode
from qtlib.radio_box import RadioBox
from qtlib.recent import Recent
from qtlib.util import move_to_screen_center, create_actions
from qt.radio_box import RadioBox
from qt.recent import Recent
from qt.util import move_to_screen_center, create_actions
from . import platform
from .directories_model import DirectoriesModel, DirectoriesDelegate

View File

@@ -18,7 +18,7 @@ from PyQt5.QtWidgets import (
from PyQt5.QtGui import QBrush
from hscommon.trans import trget
from qtlib.tree_model import RefNode, TreeModel
from qt.tree_model import RefNode, TreeModel
tr = trget("ui")

96
qt/error_report_dialog.py Normal file
View File

@@ -0,0 +1,96 @@
# Created By: Virgil Dupras
# Created On: 2009-05-23
# 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 traceback
import sys
import os
import platform
from PyQt5.QtCore import Qt, QCoreApplication, QSize
from PyQt5.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPlainTextEdit,
QPushButton,
)
from hscommon.trans import trget
from hscommon.desktop import open_url
from qt.util import horizontal_spacer
tr = trget("qtlib") # TODO change to ui
class ErrorReportDialog(QDialog):
def __init__(self, parent, github_url, error, **kwargs):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
super().__init__(parent, flags, **kwargs)
self._setupUi()
name = QCoreApplication.applicationName()
version = QCoreApplication.applicationVersion()
error_text = "Application Name: {}\nVersion: {}\nPython: {}\nOperating System: {}\n\n{}".format(
name, version, platform.python_version(), platform.platform(), error
)
# 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)
self.errorTextEdit.setPlainText(error_text)
self.github_url = github_url
self.sendButton.clicked.connect(self.goToGithub)
self.dontSendButton.clicked.connect(self.reject)
def _setupUi(self):
self.setWindowTitle(tr("Error Report"))
self.resize(553, 349)
self.verticalLayout = QVBoxLayout(self)
self.label = QLabel(self)
self.label.setText(tr("Something went wrong. How about reporting the error?"))
self.label.setWordWrap(True)
self.verticalLayout.addWidget(self.label)
self.errorTextEdit = QPlainTextEdit(self)
self.errorTextEdit.setReadOnly(True)
self.verticalLayout.addWidget(self.errorTextEdit)
msg = tr(
"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 "
"existing issues beforehand. Also make sure to test the very latest version available from the repository, "
"since the bug you are experiencing might have already been patched.\n\n"
"What usually really helps is if you add a description of how you got the error. Thanks!"
"\n\n"
"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."
)
self.label2 = QLabel(msg)
self.label2.setWordWrap(True)
self.verticalLayout.addWidget(self.label2)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.addItem(horizontal_spacer())
self.dontSendButton = QPushButton(self)
self.dontSendButton.setText(tr("Close"))
self.dontSendButton.setMinimumSize(QSize(110, 0))
self.horizontalLayout.addWidget(self.dontSendButton)
self.sendButton = QPushButton(self)
self.sendButton.setText(tr("Go to Github"))
self.sendButton.setMinimumSize(QSize(110, 0))
self.sendButton.setDefault(True)
self.horizontalLayout.addWidget(self.sendButton)
self.verticalLayout.addLayout(self.horizontalLayout)
def goToGithub(self):
open_url(self.github_url)
def install_excepthook(github_url):
def my_excepthook(exctype, value, tb):
s = "".join(traceback.format_exception(exctype, value, tb))
dialog = ErrorReportDialog(None, github_url, s)
dialog.exec_()
sys.excepthook = my_excepthook

View File

@@ -5,8 +5,8 @@
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont, QFontMetrics, QIcon, QColor
from qtlib.column import Column
from qtlib.table import Table
from qt.column import Column
from qt.table import Table
from hscommon.trans import trget
tr = trget("ui")

View File

@@ -16,7 +16,7 @@ from PyQt5.QtWidgets import (
)
from hscommon.trans import trget
from qtlib.util import horizontal_wrap
from qt.util import horizontal_wrap
from .ignore_list_table import IgnoreListTable
tr = trget("ui")

View File

@@ -5,8 +5,8 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from qtlib.column import Column
from qtlib.table import Table
from qt.column import Column
from qt.table import Table
class IgnoreListTable(Table):

View File

@@ -4,7 +4,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from qtlib.column import Column
from qt.column import Column
from ..results_model import ResultsModel as ResultsModelBase

View File

@@ -8,7 +8,7 @@ from PyQt5.QtWidgets import QFormLayout
from PyQt5.QtCore import Qt
from hscommon.trans import trget
from hscommon.plat import ISLINUX
from qtlib.radio_box import RadioBox
from qt.radio_box import RadioBox
from core.scanner import ScanType
from core.app import AppMode

View File

@@ -4,7 +4,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from qtlib.column import Column
from qt.column import Column
from ..results_model import ResultsModel as ResultsModelBase

View File

@@ -33,7 +33,7 @@ from hscommon import desktop, plat
from hscommon.trans import trget
from hscommon.plat import ISLINUX
from qtlib.util import horizontal_wrap, move_to_screen_center
from qt.util import horizontal_wrap, move_to_screen_center
from qt.preferences import get_langnames
from enum import Flag, auto

View File

@@ -24,8 +24,8 @@ from PyQt5.QtWidgets import (
)
from hscommon.trans import trget
from qtlib.selectable_list import ComboboxModel, ListviewModel
from qtlib.util import vertical_spacer
from qt.selectable_list import ComboboxModel, ListviewModel
from qt.util import vertical_spacer
from core.gui.prioritize_dialog import PrioritizeDialog as PrioritizeDialogModel
tr = trget("ui")

View File

@@ -19,7 +19,7 @@ from PyQt5.QtWidgets import (
QAbstractItemView,
)
from qtlib.util import move_to_screen_center
from qt.util import move_to_screen_center
from hscommon.trans import trget
from .problem_table import ProblemTable

View File

@@ -6,8 +6,8 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from qtlib.column import Column
from qtlib.table import Table
from qt.column import Column
from qt.table import Table
class ProblemTable(Table):

61
qt/progress_window.py Normal file
View File

@@ -0,0 +1,61 @@
# Copyright 2016 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 PyQt5.QtCore import Qt, QTimer
from PyQt5.QtWidgets import QProgressDialog
from hscommon.trans import tr
class ProgressWindow:
def __init__(self, parent, model):
self._window = None
self.parent = parent
self.model = model
model.view = self
# We don't have access to QProgressDialog's labels directly, so we se the model label's view
# to self and we'll refresh them together.
self.model.jobdesc_textfield.view = self
self.model.progressdesc_textfield.view = self
# --- Callbacks
def refresh(self): # Labels
if self._window is not None:
self._window.setWindowTitle(self.model.jobdesc_textfield.text)
self._window.setLabelText(self.model.progressdesc_textfield.text)
def set_progress(self, last_progress):
if self._window is not None:
if last_progress < 0:
self._window.setRange(0, 0)
else:
self._window.setRange(0, 100)
self._window.setValue(last_progress)
def show(self):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
self._window = QProgressDialog("", tr("Cancel"), 0, 100, self.parent, flags)
self._window.setModal(True)
self._window.setAutoReset(False)
self._window.setAutoClose(False)
self._timer = QTimer(self._window)
self._timer.timeout.connect(self.model.pulse)
self._window.show()
self._window.canceled.connect(self.model.cancel)
self._timer.start(500)
def close(self):
# it seems it is possible for close to be called without a corresponding
# show, only perform a close if there is a window to close
if self._window is not None:
self._timer.stop()
del self._timer
# For some weird reason, canceled() signal is sent upon close, whether the user canceled
# or not. If we don't want a false cancellation, we have to disconnect it.
self._window.canceled.disconnect()
self._window.close()
self._window.setParent(None)
self._window = None

88
qt/radio_box.py Normal file
View File

@@ -0,0 +1,88 @@
# Created On: 2010-06-02
# 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 PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QWidget, QHBoxLayout, QRadioButton
from qt.util import horizontal_spacer
class RadioBox(QWidget):
def __init__(self, parent=None, items=None, spread=True, **kwargs):
# If spread is False, insert a spacer in the layout so that the items don't use all the
# space they're given but rather align left.
if items is None:
items = []
super().__init__(parent, **kwargs)
self._buttons = []
self._labels = items
self._selected_index = 0
self._spacer = horizontal_spacer() if not spread else None
self._layout = QHBoxLayout(self)
self._update_buttons()
# --- Private
def _update_buttons(self):
if self._spacer is not None:
self._layout.removeItem(self._spacer)
to_remove = self._buttons[len(self._labels) :]
for button in to_remove:
self._layout.removeWidget(button)
button.setParent(None)
del self._buttons[len(self._labels) :]
to_add = self._labels[len(self._buttons) :]
for _ in to_add:
button = QRadioButton(self)
self._buttons.append(button)
self._layout.addWidget(button)
button.toggled.connect(self.buttonToggled)
if self._spacer is not None:
self._layout.addItem(self._spacer)
if not self._buttons:
return
for button, label in zip(self._buttons, self._labels):
button.setText(label)
self._update_selection()
def _update_selection(self):
self._selected_index = max(0, min(self._selected_index, len(self._buttons) - 1))
selected = self._buttons[self._selected_index]
selected.setChecked(True)
# --- Event Handlers
def buttonToggled(self):
for i, button in enumerate(self._buttons):
if button.isChecked():
self._selected_index = i
self.itemSelected.emit(i)
break
# --- Signals
itemSelected = pyqtSignal(int)
# --- Properties
@property
def buttons(self):
return self._buttons[:]
@property
def items(self):
return self._labels[:]
@items.setter
def items(self, value):
self._labels = value
self._update_buttons()
@property
def selected_index(self):
return self._selected_index
@selected_index.setter
def selected_index(self, value):
self._selected_index = value
self._update_selection()

94
qt/recent.py Normal file
View File

@@ -0,0 +1,94 @@
# Created By: Virgil Dupras
# Created On: 2009-11-12
# 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 collections import namedtuple
from PyQt5.QtCore import pyqtSignal, QObject
from PyQt5.QtWidgets import QAction
from hscommon.trans import trget
from hscommon.util import dedupe
tr = trget("qtlib") # TODO move to ui
MenuEntry = namedtuple("MenuEntry", "menu fixedItemCount")
class Recent(QObject):
def __init__(self, app, pref_name, max_item_count=10, **kwargs):
super().__init__(**kwargs)
self._app = app
self._menuEntries = []
self._prefName = pref_name
self._maxItemCount = max_item_count
self._items = []
self._loadFromPrefs()
self._app.willSavePrefs.connect(self._saveToPrefs)
# --- Private
def _loadFromPrefs(self):
items = getattr(self._app.prefs, self._prefName)
if not isinstance(items, list):
items = []
self._items = items
def _insertItem(self, item):
self._items = dedupe([item] + self._items)[: self._maxItemCount]
def _refreshMenu(self, menu_entry):
menu, fixed_item_count = menu_entry
for action in menu.actions()[fixed_item_count:]:
menu.removeAction(action)
for item in self._items:
action = QAction(item, menu)
action.setData(item)
action.triggered.connect(self.menuItemWasClicked)
menu.addAction(action)
menu.addSeparator()
action = QAction(tr("Clear List"), menu)
action.triggered.connect(self.clear)
menu.addAction(action)
def _refreshAllMenus(self):
for menu_entry in self._menuEntries:
self._refreshMenu(menu_entry)
def _saveToPrefs(self):
setattr(self._app.prefs, self._prefName, self._items)
# --- Public
def addMenu(self, menu):
menu_entry = MenuEntry(menu, len(menu.actions()))
self._menuEntries.append(menu_entry)
self._refreshMenu(menu_entry)
def clear(self):
self._items = []
self._refreshAllMenus()
self.itemsChanged.emit()
def insertItem(self, item):
self._insertItem(str(item))
self._refreshAllMenus()
self.itemsChanged.emit()
def isEmpty(self):
return not bool(self._items)
# --- Event Handlers
def menuItemWasClicked(self):
action = self.sender()
if action is not None:
item = action.data()
self.mustOpenItem.emit(item)
self._refreshAllMenus()
# --- Signals
mustOpenItem = pyqtSignal(str)
itemsChanged = pyqtSignal()

View File

@@ -24,8 +24,8 @@ from PyQt5.QtWidgets import (
)
from hscommon.trans import trget
from qtlib.util import move_to_screen_center, horizontal_wrap, create_actions
from qtlib.search_edit import SearchEdit
from qt.util import move_to_screen_center, horizontal_wrap, create_actions
from qt.search_edit import SearchEdit
from core.app import AppMode
from .results_model import ResultsView

View File

@@ -10,7 +10,7 @@ from PyQt5.QtCore import Qt, pyqtSignal, QModelIndex
from PyQt5.QtGui import QBrush, QFont, QFontMetrics
from PyQt5.QtWidgets import QTableView
from qtlib.table import Table
from qt.table import Table
class ResultsModel(Table):

View File

@@ -4,7 +4,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from qtlib.column import Column
from qt.column import Column
from ..results_model import ResultsModel as ResultsModelBase

120
qt/search_edit.py Normal file
View File

@@ -0,0 +1,120 @@
# Created By: Virgil Dupras
# Created On: 2009-12-10
# 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 PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtGui import QIcon, QPixmap, QPainter, QPalette
from PyQt5.QtWidgets import QToolButton, QLineEdit, QStyle, QStyleOptionFrame
from hscommon.trans import trget
tr = trget("qtlib") # TODO change to ui
# IMPORTANT: For this widget to work propertly, you have to add "search_clear_13" from the
# "images" folder in your resources.
class LineEditButton(QToolButton):
def __init__(self, parent, **kwargs):
super().__init__(parent, **kwargs)
pixmap = QPixmap(":/search_clear_13")
self.setIcon(QIcon(pixmap))
self.setIconSize(pixmap.size())
self.setCursor(Qt.ArrowCursor)
self.setPopupMode(QToolButton.InstantPopup)
stylesheet = "QToolButton { border: none; padding: 0px; }"
self.setStyleSheet(stylesheet)
class ClearableEdit(QLineEdit):
def __init__(self, parent=None, is_clearable=True, **kwargs):
super().__init__(parent, **kwargs)
self._is_clearable = is_clearable
if is_clearable:
self._clearButton = LineEditButton(self)
frame_width = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth)
padding_right = self._clearButton.sizeHint().width() + frame_width + 1
stylesheet = f"QLineEdit {{ padding-right:{padding_right}px; }}"
self.setStyleSheet(stylesheet)
self._updateClearButton()
self._clearButton.clicked.connect(self._clearSearch)
self.textChanged.connect(self._textChanged)
# --- Private
def _clearSearch(self):
self.clear()
def _updateClearButton(self):
self._clearButton.setVisible(self._hasClearableContent())
def _hasClearableContent(self):
return bool(self.text())
# --- QLineEdit overrides
def resizeEvent(self, event):
if self._is_clearable:
frame_width = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth)
rect = self.rect()
right_hint = self._clearButton.sizeHint()
right_x = rect.right() - frame_width - right_hint.width()
right_y = (rect.bottom() - right_hint.height()) // 2
self._clearButton.move(right_x, right_y)
# --- Event Handlers
def _textChanged(self, text):
if self._is_clearable:
self._updateClearButton()
class SearchEdit(ClearableEdit):
def __init__(self, parent=None, immediate=False):
# immediate: send searchChanged signals at each keystroke.
ClearableEdit.__init__(self, parent, is_clearable=True)
self.inactiveText = tr("Search...")
self.immediate = immediate
self.returnPressed.connect(self._returnPressed)
# --- Overrides
def _clearSearch(self):
ClearableEdit._clearSearch(self)
self.searchChanged.emit()
def _textChanged(self, text):
ClearableEdit._textChanged(self, text)
if self.immediate:
self.searchChanged.emit()
def keyPressEvent(self, event):
key = event.key()
if key == Qt.Key_Escape:
self._clearSearch()
else:
ClearableEdit.keyPressEvent(self, event)
def paintEvent(self, event):
ClearableEdit.paintEvent(self, event)
if not bool(self.text()) and self.inactiveText and not self.hasFocus():
panel = QStyleOptionFrame()
self.initStyleOption(panel)
text_rect = self.style().subElementRect(QStyle.SE_LineEditContents, panel, self)
left_margin = 2
right_margin = self._clearButton.iconSize().width()
text_rect.adjust(left_margin, 0, -right_margin, 0)
painter = QPainter(self)
disabled_color = self.palette().brush(QPalette.Disabled, QPalette.Text).color()
painter.setPen(disabled_color)
painter.drawText(text_rect, Qt.AlignLeft | Qt.AlignVCenter, self.inactiveText)
# --- Event Handlers
def _returnPressed(self):
if not self.immediate:
self.searchChanged.emit()
# --- Signals
searchChanged = pyqtSignal() # Emitted when return is pressed or when the test is cleared

100
qt/selectable_list.py Normal file
View File

@@ -0,0 +1,100 @@
# Created By: Virgil Dupras
# Created On: 2011-09-06
# 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 PyQt5.QtCore import Qt, QAbstractListModel, QItemSelection, QItemSelectionModel
class SelectableList(QAbstractListModel):
def __init__(self, model, view, **kwargs):
super().__init__(**kwargs)
self._updating = False
self.view = view
self.model = model
self.view.setModel(self)
self.model.view = self
# --- Override
def data(self, index, role):
if not index.isValid():
return None
# We need EditRole for QComboBoxes with setEditable(True)
if role in {Qt.DisplayRole, Qt.EditRole}:
return self.model[index.row()]
return None
def rowCount(self, index):
if index.isValid():
return 0
return len(self.model)
# --- Virtual
def _updateSelection(self):
raise NotImplementedError()
def _restoreSelection(self):
raise NotImplementedError()
# --- model --> view
def refresh(self):
self._updating = True
self.beginResetModel()
self.endResetModel()
self._updating = False
self._restoreSelection()
def update_selection(self):
self._restoreSelection()
class ComboboxModel(SelectableList):
def __init__(self, model, view, **kwargs):
super().__init__(model, view, **kwargs)
self.view.currentIndexChanged[int].connect(self.selectionChanged)
# --- Override
def _updateSelection(self):
index = self.view.currentIndex()
if index != self.model.selected_index:
self.model.select(index)
def _restoreSelection(self):
index = self.model.selected_index
if index is not None:
self.view.setCurrentIndex(index)
# --- Events
def selectionChanged(self, index):
if not self._updating:
self._updateSelection()
class ListviewModel(SelectableList):
def __init__(self, model, view, **kwargs):
super().__init__(model, view, **kwargs)
self.view.selectionModel().selectionChanged[(QItemSelection, QItemSelection)].connect(self.selectionChanged)
# --- Override
def _updateSelection(self):
new_indexes = [modelIndex.row() for modelIndex in self.view.selectionModel().selectedRows()]
if new_indexes != self.model.selected_indexes:
self.model.select(new_indexes)
def _restoreSelection(self):
new_selection = QItemSelection()
for index in self.model.selected_indexes:
new_selection.select(self.createIndex(index, 0), self.createIndex(index, 0))
self.view.selectionModel().select(new_selection, QItemSelectionModel.ClearAndSelect)
if len(new_selection.indexes()):
current_index = new_selection.indexes()[0]
self.view.selectionModel().setCurrentIndex(current_index, QItemSelectionModel.Current)
self.view.scrollTo(current_index)
# --- Events
def selectionChanged(self, index):
if not self._updating:
self._updateSelection()

View File

@@ -14,7 +14,7 @@ from PyQt5.QtWidgets import (
QStackedWidget,
)
from hscommon.trans import trget
from qtlib.util import move_to_screen_center, create_actions
from qt.util import move_to_screen_center, create_actions
from .directories_dialog import DirectoriesDialog
from .result_window import ResultWindow
from .ignore_list_dialog import IgnoreListDialog

160
qt/table.py Normal file
View File

@@ -0,0 +1,160 @@
# Created By: Virgil Dupras
# Created On: 2009-11-01
# 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 typing
from PyQt5.QtCore import (
Qt,
QAbstractTableModel,
QModelIndex,
QItemSelectionModel,
QItemSelection,
)
from .column import Columns, Column
class Table(QAbstractTableModel):
# Flags you want when index.isValid() is False. In those cases, _getFlags() is never called.
INVALID_INDEX_FLAGS = Qt.ItemFlag.ItemIsEnabled
COLUMNS: typing.List[Column] = []
def __init__(self, model, view, **kwargs):
super().__init__(**kwargs)
self.model = model
self.view = view
self.view.setModel(self)
self.model.view = self
if hasattr(self.model, "_columns"):
self._columns = Columns(self.model._columns, self.COLUMNS, view.horizontalHeader())
self.view.selectionModel().selectionChanged[(QItemSelection, QItemSelection)].connect(self.selectionChanged)
def _updateModelSelection(self):
# Takes the selection on the view's side and update the model with it.
# an _updateViewSelection() call will normally result in an _updateModelSelection() call.
# to avoid infinite loops, we check that the selection will actually change before calling
# model.select()
new_indexes = [modelIndex.row() for modelIndex in self.view.selectionModel().selectedRows()]
if new_indexes != self.model.selected_indexes:
self.model.select(new_indexes)
def _updateViewSelection(self):
# Takes the selection on the model's side and update the view with it.
new_selection = QItemSelection()
column_count = self.columnCount(QModelIndex())
for index in self.model.selected_indexes:
new_selection.select(self.createIndex(index, 0), self.createIndex(index, column_count - 1))
self.view.selectionModel().select(new_selection, QItemSelectionModel.ClearAndSelect)
if len(new_selection.indexes()):
current_index = new_selection.indexes()[0]
self.view.selectionModel().setCurrentIndex(current_index, QItemSelectionModel.Current)
self.view.scrollTo(current_index)
# --- Data Model methods
# Virtual
def _getData(self, row, column, role):
if role in (Qt.DisplayRole, Qt.EditRole):
attrname = column.name
return row.get_cell_value(attrname)
elif role == Qt.TextAlignmentRole:
return column.alignment
return None
# Virtual
def _getFlags(self, row, column):
flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
if row.can_edit_cell(column.name):
flags |= Qt.ItemIsEditable
return flags
# Virtual
def _setData(self, row, column, value, role):
if role == Qt.EditRole:
attrname = column.name
if attrname == "from":
attrname = "from_"
setattr(row, attrname, value)
return True
return False
def columnCount(self, index):
return self.model._columns.columns_count()
def data(self, index, role):
if not index.isValid():
return None
row = self.model[index.row()]
column = self.model._columns.column_by_index(index.column())
return self._getData(row, column, role)
def flags(self, index):
if not index.isValid():
return self.INVALID_INDEX_FLAGS
row = self.model[index.row()]
column = self.model._columns.column_by_index(index.column())
return self._getFlags(row, column)
def headerData(self, section, orientation, role):
if orientation != Qt.Horizontal:
return None
if section >= self.model._columns.columns_count():
return None
column = self.model._columns.column_by_index(section)
if role == Qt.DisplayRole:
return column.display
elif role == Qt.TextAlignmentRole:
return column.alignment
else:
return None
def revert(self):
self.model.cancel_edits()
def rowCount(self, index):
if index.isValid():
return 0
return len(self.model)
def setData(self, index, value, role):
if not index.isValid():
return False
row = self.model[index.row()]
column = self.model._columns.column_by_index(index.column())
return self._setData(row, column, value, role)
def sort(self, section, order):
column = self.model._columns.column_by_index(section)
attrname = column.name
self.model.sort_by(attrname, desc=order == Qt.DescendingOrder)
def submit(self):
self.model.save_edits()
return True
# --- Events
def selectionChanged(self, selected, deselected):
self._updateModelSelection()
# --- model --> view
def refresh(self):
self.beginResetModel()
self.endResetModel()
self._updateViewSelection()
def show_selected_row(self):
if self.model.selected_index is not None:
self.view.showRow(self.model.selected_index)
def start_editing(self):
self.view.editSelected()
def stop_editing(self):
self.view.setFocus() # enough to stop editing
def update_selection(self):
self._updateViewSelection()

178
qt/tree_model.py Normal file
View File

@@ -0,0 +1,178 @@
# Created By: Virgil Dupras
# Created On: 2009-09-14
# 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 logging
from PyQt5.QtCore import QAbstractItemModel, QModelIndex
class NodeContainer:
def __init__(self):
self._subnodes = None
self._ref2node = {}
# --- Protected
def _create_node(self, ref, row):
# This returns a TreeNode instance from ref
raise NotImplementedError()
def _get_children(self):
# This returns a list of ref instances, not TreeNode instances
raise NotImplementedError()
# --- Public
def invalidate(self):
# Invalidates cached data and list of subnodes without resetting ref2node.
self._subnodes = None
# --- Properties
@property
def subnodes(self):
if self._subnodes is None:
children = self._get_children()
self._subnodes = []
for index, child in enumerate(children):
if child in self._ref2node:
node = self._ref2node[child]
node.row = index
else:
node = self._create_node(child, index)
self._ref2node[child] = node
self._subnodes.append(node)
return self._subnodes
class TreeNode(NodeContainer):
def __init__(self, model, parent, row):
NodeContainer.__init__(self)
self.model = model
self.parent = parent
self.row = row
@property
def index(self):
return self.model.createIndex(self.row, 0, self)
class RefNode(TreeNode):
"""Node pointing to a reference node.
Use this if your Qt model wraps around a tree model that has iterable nodes.
"""
def __init__(self, model, parent, ref, row):
TreeNode.__init__(self, model, parent, row)
self.ref = ref
def _create_node(self, ref, row):
return RefNode(self.model, self, ref, row)
def _get_children(self):
return list(self.ref)
# We use a specific TreeNode subclass to easily spot dummy nodes, especially in exception tracebacks.
class DummyNode(TreeNode):
pass
class TreeModel(QAbstractItemModel, NodeContainer):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._dummy_nodes = set() # dummy nodes' reference have to be kept to avoid segfault
# --- Private
def _create_dummy_node(self, parent, row):
# In some cases (drag & drop row removal, to be precise), there's a temporary discrepancy
# between a node's subnodes and what the model think it has. This leads to invalid indexes
# being queried. Rather than going through complicated row removal crap, it's simpler to
# just have rows with empty data replacing removed rows for the millisecond that the drag &
# drop lasts. Override this to return a node of the correct type.
return DummyNode(self, parent, row)
def _last_index(self):
"""Index of the very last item in the tree."""
current_index = QModelIndex()
row_count = self.rowCount(current_index)
while row_count > 0:
current_index = self.index(row_count - 1, 0, current_index)
row_count = self.rowCount(current_index)
return current_index
# --- Overrides
def index(self, row, column, parent):
if not self.subnodes:
return QModelIndex()
node = parent.internalPointer() if parent.isValid() else self
try:
return self.createIndex(row, column, node.subnodes[row])
except IndexError:
logging.debug(
"Wrong tree index called (%r, %r, %r). Returning DummyNode",
row,
column,
node,
)
parent_node = parent.internalPointer() if parent.isValid() else None
dummy = self._create_dummy_node(parent_node, row)
self._dummy_nodes.add(dummy)
return self.createIndex(row, column, dummy)
def parent(self, index):
if not index.isValid():
return QModelIndex()
node = index.internalPointer()
if node.parent is None:
return QModelIndex()
else:
return self.createIndex(node.parent.row, 0, node.parent)
def reset(self):
super().beginResetModel()
self.invalidate()
self._ref2node = {}
self._dummy_nodes = set()
super().endResetModel()
def rowCount(self, parent=QModelIndex()):
node = parent.internalPointer() if parent.isValid() else self
return len(node.subnodes)
# --- Public
def findIndex(self, row_path):
"""Returns the QModelIndex at `row_path`
`row_path` is a sequence of node rows. For example, [1, 2, 1] is the 2nd child of the
3rd child of the 2nd child of the root.
"""
result = QModelIndex()
for row in row_path:
result = self.index(row, 0, result)
return result
@staticmethod
def pathForIndex(index):
reversed_path = []
while index.isValid():
reversed_path.append(index.row())
index = index.parent()
return list(reversed(reversed_path))
def refreshData(self):
"""Updates the data on all nodes, but without having to perform a full reset.
A full reset on a tree makes us lose selection and expansion states. When all we ant to do
is to refresh the data on the nodes without adding or removing a node, a call on
dataChanged() is better. But of course, Qt makes our life complicated by asking us topLeft
and bottomRight indexes. This is a convenience method refreshing the whole tree.
"""
column_count = self.columnCount()
top_left = self.index(0, 0, QModelIndex())
bottom_left = self._last_index()
bottom_right = self.sibling(bottom_left.row(), column_count - 1, bottom_left)
self.dataChanged.emit(top_left, bottom_right)

139
qt/util.py Normal file
View File

@@ -0,0 +1,139 @@
# Created By: Virgil Dupras
# Created On: 2011-02-01
# 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 sys
import io
import os.path as op
import os
import logging
from core.util import executable_folder
from hscommon.util import first
from PyQt5.QtCore import QStandardPaths
from PyQt5.QtGui import QPixmap, QIcon, QGuiApplication
from PyQt5.QtWidgets import (
QSpacerItem,
QSizePolicy,
QAction,
QHBoxLayout,
)
def move_to_screen_center(widget):
frame = widget.frameGeometry()
if QGuiApplication.screenAt(frame.center()) is None:
# if center not on any screen use default screen
screen = QGuiApplication.screens()[0].availableGeometry()
else:
screen = QGuiApplication.screenAt(frame.center()).availableGeometry()
# moves to center of screen if partially off screen
if screen.contains(frame) is False:
# make sure the frame is not larger than screen
# resize does not seem to take frame size into account (move does)
widget.resize(frame.size().boundedTo(screen.size() - (frame.size() - widget.size())))
frame = widget.frameGeometry()
frame.moveCenter(screen.center())
widget.move(frame.topLeft())
def vertical_spacer(size=None):
if size:
return QSpacerItem(1, size, QSizePolicy.Fixed, QSizePolicy.Fixed)
else:
return QSpacerItem(1, 1, QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)
def horizontal_spacer(size=None):
if size:
return QSpacerItem(size, 1, QSizePolicy.Fixed, QSizePolicy.Fixed)
else:
return QSpacerItem(1, 1, QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)
def horizontal_wrap(widgets):
"""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
with the width corresponding to the int will be placed (0 or None means an expanding spacer).
"""
layout = QHBoxLayout()
for widget in widgets:
if widget is None or isinstance(widget, int):
layout.addItem(horizontal_spacer(size=widget))
else:
layout.addWidget(widget)
return layout
def create_actions(actions, target):
# actions are list of (name, shortcut, icon, desc, func)
for name, shortcut, icon, desc, func in actions:
action = QAction(target)
if icon:
action.setIcon(QIcon(QPixmap(":/" + icon)))
if shortcut:
action.setShortcut(shortcut)
action.setText(desc)
action.triggered.connect(func)
setattr(target, name, action)
def set_accel_keys(menu):
actions = menu.actions()
titles = [a.text() for a in actions]
available_characters = {c.lower() for s in titles for c in s if c.isalpha()}
for action in actions:
text = action.text()
c = first(c for c in text if c.lower() in available_characters)
if c is None:
continue
i = text.index(c)
newtext = text[:i] + "&" + text[i:]
available_characters.remove(c.lower())
action.setText(newtext)
def get_appdata(portable=False):
if portable:
return op.join(executable_folder(), "data")
else:
return QStandardPaths.standardLocations(QStandardPaths.AppDataLocation)[0]
class SysWrapper(io.IOBase):
def write(self, s):
if s.strip(): # don't log empty stuff
logging.warning(s)
def setup_qt_logging(level=logging.WARNING, log_to_stdout=False):
# Under Qt, we log in "debug.log" in appdata. Moreover, when under cx_freeze, we have a
# problem because sys.stdout and sys.stderr are None, so we need to replace them with a
# wrapper that logs with the logging module.
appdata = get_appdata()
if not op.exists(appdata):
os.makedirs(appdata)
# Setup logging
# Have to use full configuration over basicConfig as FileHandler encoding was not being set.
filename = op.join(appdata, "debug.log") if not log_to_stdout else None
log = logging.getLogger()
handler = logging.FileHandler(filename, "a", "utf-8")
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter)
log.addHandler(handler)
if sys.stderr is None: # happens under a cx_freeze environment
sys.stderr = SysWrapper()
if sys.stdout is None:
sys.stdout = SysWrapper()
def escape_amp(s):
# Returns `s` with escaped ampersand (& --> &&). QAction text needs to have & escaped because
# that character is used to define "accel keys".
return s.replace("&", "&&")