import os
import logging
from copy import deepcopy
from base64 import b64encode
from qt import (QWidget, QFormLayout, QVBoxLayout, QLabel, QLineEdit,
QHBoxLayout, QFrame, QPushButton, QToolBox, QCheckBox,
QSpinBox, QListView, QToolButton, QDialogButtonBox,
QAbstractListModel, Qt, QStyledItemDelegate, QStyle,
QApplication, QRectF, QTextDocument, QFileDialog, pyqtSignal,
QModelIndex, QItemSelectionModel, QMessageBox, QDialog, QSize,
QFont, QFontComboBox)
from retype.extras.utils import update
from retype.constants import default_config, iswindows
logger = logging.getLogger(__name__)
DEFAULTS = default_config
[docs]def hline():
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
return line
[docs]def pxspinbox(value=0, suffix=" px"):
sb = QSpinBox()
sb.setSuffix(suffix)
sb.setMaximum(10000)
sb.setValue(value)
return sb
[docs]def npxspinbox(value):
sb = pxspinbox()
sb.setMinimum(-10000)
sb.setValue(value)
return sb
[docs]def descl(text):
desc = QLabel(text)
desc.setWordWrap(True)
return desc
[docs]class CustomisationDialog(QDialog):
[docs] def __init__(self, config, window, saveConfig, prevView,
getBookViewFontSize, parent=None):
QDialog.__init__(self, parent, Qt.WindowType.WindowCloseButtonHint)
# The base config (no uncommitted modifications)
self.config = deepcopy(DEFAULTS)
update(self.config, config)
# The config with uncommitted modifications (any modifications will be
# applied to this one)
self.config_edited = deepcopy(self.config)
self.saveConfig = saveConfig
self.prevView = prevView
self.window = window
self.getBookViewFontSize = getBookViewFontSize
self._initUI()
self.setModal(True)
self.setWindowTitle("Customise retype")
[docs] def sizeHint(self):
return QSize(400, 500)
def _initUI(self):
self.selectors = {}
tbox = QToolBox()
tbox.addItem(self._pathSettings(), "Paths")
tbox.addItem(self._consoleSettings(), "Console")
tbox.addItem(self._bookviewSettings(), "Book View")
tbox.addItem(self._rdictSettings(), "Replacements")
tbox.addItem(self._windowSettings(), "Window geometry")
lyt = QVBoxLayout(self)
lyt.addWidget(tbox)
lyt.addWidget(hline())
self.revert_btn = QPushButton("Revert")
self.revert_btn.setToolTip("Revert changes")
self.revert_btn.setEnabled(False)
StandardButton = QDialogButtonBox.StandardButton
btnbox = QDialogButtonBox(StandardButton.Close |
StandardButton.Save |
StandardButton.RestoreDefaults)
btnbox.addButton(self.revert_btn,
QDialogButtonBox.ButtonRole.DestructiveRole)
lyt.addWidget(btnbox)
btnbox.accepted.connect(self.accept)
btnbox.rejected.connect(self.reject)
self.revert_btn.clicked.connect(self.revert)
self.restore_btn = btnbox.button(StandardButton.RestoreDefaults)
self.restore_btn.clicked.connect(self.restoreDefaults)
self.restore_btn.setEnabled(self.config != DEFAULTS)
def _pathSettings(self):
plib = QWidget()
lyt = QFormLayout(plib)
# user_dir
lyt.addRow(QLabel("Location for the save and config files."))
self.selectors['user_dir'] = PathSelector(
self.config_edited['user_dir'])
self.selectors['user_dir'].changed.connect(
lambda t: self.update("user_dir", t))
lyt.addRow("User dir:", self.selectors['user_dir'])
lyt.addRow(hline())
# library_paths
lyt.addRow(QLabel("Library search paths:"))
self.selectors['library_paths'] = LibraryPathsWidget(
self.config_edited['library_paths'])
self.selectors['library_paths'].changed.connect(
lambda paths: self.update("library_paths", paths))
lyt.addRow(self.selectors['library_paths'])
return plib
def _consoleSettings(self):
pcon = QWidget()
lyt = QFormLayout(pcon)
# prompt
lyt.addRow(descl("Prompt console commands must be prefixed by. Can be\
any length, including empty if you do not want to prefix them with anything."
))
self.selectors['prompt'] = PromptEdit(self.config_edited['prompt'])
self.selectors['prompt'].textChanged.connect(
lambda t: self.update("prompt", t))
lyt.addRow("Prompt:", self.selectors['prompt'])
# console font
self.selectors['console_font'] = ConsoleFontSelector(
self.config_edited['console_font'])
self.selectors['console_font'].currentFontChanged.connect(
lambda f: self.update("console_font", f.family()))
lyt.addRow("Console font:", self.selectors['console_font'])
# Windows-only: system console
if iswindows:
lyt.addRow(hline())
hide_sysconsole_checkbox = CheckBox(
"Hide System Console window on UI load (Windows-only)")
hide_sysconsole_checkbox.setChecked(
self.config_edited.get('hide_sysconsole', True))
hide_sysconsole_checkbox.stateChanged.connect(
lambda t: self.update("hide_sysconsole", t))
self.selectors['hide_sysconsole'] = hide_sysconsole_checkbox
lyt.addRow(hide_sysconsole_checkbox)
return pcon
def _rdictSettings(self):
prep = QWidget()
lyt = QFormLayout(prep)
lyt.addRow(descl("Configure substrings that can be typeable\
by any one of the set comma-separated list of replacements. This is useful\
for unicode characters that you don’t have an easy way to input. Each\
replacement should be of equal length to the original substring."))
self.selectors['rdict'] = RDictWidget(
deepcopy(self.config_edited['rdict']))
self.selectors['rdict'].changed.connect(
lambda rdict: self.update("rdict", rdict))
lyt.addRow(self.selectors['rdict'])
return prep
def _windowSettings(self):
self.selectors['window'] = WindowGeometrySelector(
self.window, self.config_edited['window'])
self.selectors['window'].changed.connect(
lambda dims: self.update("window", dims))
self.window.closing.connect(self.maybeSaveCertainThings)
return self.selectors['window']
def _bookviewSettings(self):
self.selectors['bookview'] = BookViewSettingsWidget(
self.config_edited['bookview'])
self.selectors['bookview'].changed.connect(
lambda x: self.update("bookview", x))
return self.selectors['bookview']
[docs] def update(self, name, new_value):
self.config_edited[name] = new_value
logger.debug("config_edited updated to: {}".format(self.config_edited))
self.revert_btn.setEnabled(self.config_edited != self.config)
self.restore_btn.setEnabled(self.config_edited != DEFAULTS)
[docs] def accept(self):
# User dir validation
if not os.path.exists(self.config_edited['user_dir']):
ret = QMessageBox.warning(
self, "retype",
"Cannot find specified user_dir path.\n"
"Reset it to former value?",
QMessageBox.Yes | QMessageBox.No)
if ret == QMessageBox.Yes:
self.config_edited['user_dir'] = self.config['user_dir']
self.selectors['user_dir'].set_(self.config['user_dir'])
return
# Library paths validation
for path in self.config_edited['library_paths']:
if not os.path.exists(path):
ret = QMessageBox.warning(
self, "retype",
"At least one library search path is invalid.")
return
# Replacements validation
if '' in self.config_edited['rdict'] or \
[] in self.config_edited['rdict'].values():
ret = QMessageBox.warning(
self, "retype", "At least one replacement is invalid.")
return
# Save
self.saveConfig.emit(self.config_edited)
# Update base config
self.config = deepcopy(self.config_edited)
self.revert_btn.setEnabled(False)
[docs] def setSelectors(self, config):
for key, selector in self.selectors.items():
selector.set_(config[key])
[docs] def restoreDefaults(self):
self.config_edited = deepcopy(default_config)
self.setSelectors(self.config_edited)
self.restore_btn.setEnabled(False)
[docs] def revert(self):
self.config_edited = deepcopy(self.config)
self.setSelectors(self.config_edited)
self.revert_btn.setEnabled(False)
[docs] def maybeSaveCertainThings(self):
shouldSave = False
if self.config['window']['save_on_quit']:
logger.debug("Saving window geometry")
values = self.selectors['window'].valuesByWindow()
self.config['window'].update(values)
shouldSave = True
if self.config['window'].get('save_splitters_on_quit', True):
logger.debug("Saving splitters states")
for name, splitter in self.window.splitters.items():
self.config['window'][f'{name}_splitter_state'] =\
b64encode(splitter.saveState()).decode('ascii')
shouldSave = True
if self.config['bookview']['save_font_size_on_quit']:
logger.debug("Saving BookView’s font size")
self.config['bookview']['font_size'] = self.getBookViewFontSize()
shouldSave = True
if shouldSave:
self.saveConfig.emit(self.config)
[docs]class CheckBox(QCheckBox):
changed = pyqtSignal(bool)
[docs] def __init__(self, desc, parent=None):
QCheckBox.__init__(self, desc, parent)
[docs] def value(self):
return self.isChecked()
[docs] def set_(self, value):
self.setChecked(value)
[docs]class PathSelector(QWidget):
changed = pyqtSignal(str)
[docs] def __init__(self, path, parent=None, window_title="Select path"):
QWidget.__init__(self, parent)
self.window_title = window_title
lyt = QHBoxLayout(self)
lyt.setContentsMargins(0, 0, 0, 0)
self.path_edit = QLineEdit(path)
self.path_edit.textChanged.connect(lambda t: self.changed.emit(t))
lyt.addWidget(self.path_edit)
self.browse_button = QToolButton(self)
# TODO: add browse icon
self.browse_button.setText("...")
self.browse_button.clicked.connect(self.browse)
lyt.addWidget(self.browse_button)
self.setFocusProxy(self.path_edit)
[docs] def browse(self):
# The set focus lines are a workaround to a qt bug which causes the
# application to crash after the QFileDialog is invoked from a
# delegate editor
self.browse_button.setFocus()
path = QFileDialog.getExistingDirectory(self, self.window_title)
self.browse_button.setFocus()
if path:
self.path_edit.setText(path)
[docs] def value(self):
return self.path_edit.text()
[docs] def set_(self, value):
self.path_edit.setText(value)
[docs]class Delegate(QStyledItemDelegate):
[docs] def __init__(self, parent=None):
QStyledItemDelegate.__init__(self, parent)
self.spacing = 5
[docs] def toDoc(self, index):
doc = QTextDocument()
doc.setHtml(index.data())
return doc
[docs] def paint(self, painter, option, index):
painter.save()
painter.setClipRect(QRectF(option.rect))
if hasattr(QStyle, 'CE_ItemViewItem'):
QApplication.style().drawControl(
QStyle.ControlElement.CE_ItemViewItem, option, painter)
elif option.state & QStyle.StateFlag.State_Selected:
painter.fillRect(option.rect, option.palette.highlight())
start = option.rect.topLeft()
start.setY(start.y() + self.spacing)
painter.translate(start)
self.toDoc(index).drawContents(painter)
painter.restore()
[docs] def sizeHint(self, option, index):
size = self.toDoc(index).size().toSize()
size.setHeight(size.height() + self.spacing*2)
return size
[docs]class PathDelegate(Delegate):
[docs] def __init__(self, parent=None):
Delegate.__init__(self, parent)
[docs] def createEditor(self, parent, option, index):
return PathSelector(index.data(Qt.ItemDataRole.EditRole), parent)
[docs] def setModelData(self, editor, model, index):
model.setData(index, editor.value(), Qt.EditRole)
[docs]class LibraryPathsModel(QAbstractListModel):
changed = pyqtSignal(list)
INVALID_TEMPLATE = '''<span style="color:red">{}</span>'''
[docs] def __init__(self, paths, parent=None):
QAbstractListModel.__init__(self, parent)
self.paths = paths
[docs] def rowCount(self, parent):
return len(self.paths)
[docs] def data(self, index, role):
row = index.row()
if row < 0 or row >= len(self.paths):
return None
path = self.paths[row]
if role == Qt.ItemDataRole.DisplayRole:
if os.path.exists(path):
return path
else:
return self.INVALID_TEMPLATE.format(path)
elif role == Qt.ItemDataRole.EditRole:
return path
return None
[docs] def setData(self, index, data, role):
if index.isValid() and role == Qt.ItemDataRole.EditRole:
self.paths[index.row()] = str(data)
self.dataChanged.emit(index, index, [role])
self.changed.emit(self.paths)
return True
return False
[docs] def flags(self, index):
if not index.isValid():
return Qt.ItemFlag.ItemIsEnabled
return (QAbstractListModel.flags(self, index) |
Qt.ItemFlag.ItemIsEditable)
[docs] def insertRows(self, position=None, rows=1, parent=QModelIndex()):
if position is None:
position = len(self.paths)
self.beginInsertRows(QModelIndex(), position, position+rows-1)
for row in range(rows):
self.paths.insert(position + row, "")
self.changed.emit(self.paths)
self.endInsertRows()
return True
[docs] def removeRows(self, position, rows, parent):
self.beginRemoveRows(QModelIndex(), position, position+rows-1)
for row in range(rows):
del self.paths[position + row]
self.changed.emit(self.paths)
self.endRemoveRows()
return True
[docs]class PromptEdit(QLineEdit):
[docs] def __init__(self, prompt, parent=None):
QLineEdit.__init__(self, parent)
self.set_(prompt)
[docs] def set_(self, prompt):
self.setText(prompt)
[docs]class ConsoleFontSelector(QFontComboBox):
[docs] def __init__(self, font, parent=None):
QLineEdit.__init__(self, parent)
self.set_(font)
[docs] def set_(self, font):
self.setCurrentFont(QFont(font))
[docs]class RDictModel(QAbstractListModel):
TEMPLATE = '''<p><b>Substring:</b> <code>'<u style="color:blue">{0}</u>' ({1})</code><br>
Replacements list: <code><b>{2}</b></code></p>'''
INVALID_TEMPLATE = '<div style="color:red">' + TEMPLATE + '</div>'
changed = pyqtSignal(dict)
[docs] def __init__(self, rdict, parent=None):
QAbstractListModel.__init__(self, parent)
self.rdict = rdict
self.order = list(rdict)
[docs] def rowCount(self, parent):
return len(self.rdict)
[docs] def data(self, index, role):
row = index.row()
if row < 0 or row >= len(self.order):
return None
substring = self.order[row]
escaped_unicode = str(substring.encode("unicode_escape")
.decode("latin1"))
if role == Qt.ItemDataRole.DisplayRole:
if substring == '' or not self.rdict[substring]:
return self.INVALID_TEMPLATE.format(substring, escaped_unicode,
self.rdict[substring])
return self.TEMPLATE.format(substring, escaped_unicode,
self.rdict[substring])
elif role == Qt.ItemDataRole.EditRole:
return (substring, self.rdict[substring])
return None
[docs] def insertRows(self, position=None, rows=1, parent=QModelIndex()):
if position is None:
position = len(self.order)
self.beginInsertRows(QModelIndex(), position, position+rows-1)
for row in range(rows):
if '' in self.rdict:
break
self.order.insert(position + row, "")
self.rdict[''] = ""
self.changed.emit(self.rdict)
self.endInsertRows()
return True
[docs] def removeRows(self, position, rows, parent):
self.beginRemoveRows(QModelIndex(), position, position+rows-1)
for row in range(rows):
del self.rdict[self.order[position + row]]
del self.order[position + row]
self.changed.emit(self.rdict)
self.endRemoveRows()
return True
[docs] def flags(self, index):
if not index.isValid():
return Qt.ItemFlag.ItemIsEnabled
return (QAbstractListModel.flags(self, index) |
Qt.ItemFlag.ItemIsEditable)
[docs] def setData(self, index, data, role):
if index.isValid() and role == Qt.ItemDataRole.EditRole:
del self.rdict[self.order[index.row()]]
self.order[index.row()] = data[0]
self.rdict[data[0]] = data[1]
self.dataChanged.emit(index, index, [role])
self.changed.emit(self.rdict)
return True
return False
[docs]class RDictEntryEditor(QWidget):
[docs] def __init__(self, substr, reps, parent=None):
QWidget.__init__(self, parent)
lyt = QFormLayout(self)
self.substr_e = QLineEdit(substr)
self.reps_e = QLineEdit(','.join(reps))
lyt.addRow("Substring:", self.substr_e)
lyt.addRow("Replacements (separated by ,):", self.reps_e)
# Background
self.setStyleSheet("background-color:#CDE8FF")
self.setAttribute(Qt.WA_StyledBackground, True)
lyt.setContentsMargins(0, 0, 0, 0)
lyt.setSpacing(1)
[docs] def substr(self):
return self.substr_e.text()
[docs] def reps(self):
return self.reps_e.text().split(',')
[docs]class RDictDelegate(Delegate):
[docs] def __init__(self, parent=None):
Delegate.__init__(self, parent)
[docs] def createEditor(self, parent, option, index):
data = index.data(Qt.ItemDataRole.EditRole)
return RDictEntryEditor(*data, parent)
[docs] def setModelData(self, editor, model, index):
substr = editor.substr()
reps = editor.reps()
# remove duplicates
reps = list(dict.fromkeys(reps))
# remove reps that are of incorrect length
for i, rep in enumerate(reps):
if len(rep) != len(substr):
del reps[i]
model.setData(index, [substr, reps], Qt.ItemDataRole.EditRole)
[docs]class WindowGeometrySelector(QWidget):
changed = pyqtSignal(dict)
[docs] def __init__(self, window, dims, parent=None):
QWidget.__init__(self, parent)
self.window = window
self.dims = dims
lyt = QFormLayout(self)
self.selectors = {}
self.selectors['save_splitters_on_quit'] = QCheckBox(
"Save state of splitters on quit")
self.selectors['save_splitters_on_quit'].stateChanged.connect(
lambda state: self.updateDim('save_splitters_on_quit', state))
self.selectors['save_on_quit'] = QCheckBox(
"Save window size and position on quit")
self.selectors['save_on_quit'].stateChanged.connect(self.setSaveOnQuit)
lyt.addRow(self.selectors['save_splitters_on_quit'])
lyt.addRow(self.selectors['save_on_quit'])
lyt.addRow(hline())
# when save on quit is checked, the following is greyed out
self.selectors['x'] = npxspinbox(dims['x'] or 0)
self.selectors['y'] = npxspinbox(dims['y'] or 0)
self.selectors['w'] = pxspinbox(dims['w'])
self.selectors['h'] = pxspinbox(dims['h'])
self.cur_btn = QPushButton("Set values according to current window")
self.cur_btn.clicked.connect(self.setSelectorsValuesByWindow)
self.dim_selectors = {k: v for k, v in self.selectors.items()
if k in 'xywh'}
for name, selector in self.dim_selectors.items():
label = name.title() + ':'
lyt.addRow(label, selector)
self.connectSelector(name, selector.valueChanged)
lyt.addRow(self.cur_btn)
self.selectors['save_splitters_on_quit'].setChecked(
dims['save_splitters_on_quit'])
self.selectors['save_on_quit'].setChecked(dims['save_on_quit'])
[docs] def setSaveOnQuit(self, state):
controls_to_toggle = [*list(self.dim_selectors.values()), self.cur_btn]
for control in controls_to_toggle:
disabled_bool = True if state else False
control.setDisabled(disabled_bool)
self.updateDim('save_on_quit', state)
[docs] def valuesByWindow(self):
pos = self.window.pos()
size = self.window.size()
values = {}
values['x'] = pos.x()
values['y'] = pos.y()
values['w'] = size.width()
values['h'] = size.height()
return values
[docs] def setSelectorsValuesByWindow(self):
values = self.valuesByWindow()
self.set_(values)
[docs] def connectSelector(self, name, signal):
signal.connect(lambda val: self.updateDim(name, val))
[docs] def updateDim(self, name, val):
self.dims[name] = val
self.changed.emit(self.dims)
[docs] def set_(self, dims):
for key, selector in self.selectors.items():
if key in ['save_splitters_on_quit', 'save_on_quit']:
selector.setChecked(dims[key])
continue
value = dims[key] if dims[key] is not None else 0
selector.setValue(value)