""" novelWriter – GUI Project Settings ================================== File History: Created: 2018-09-29 [0.0.1] GuiProjectSettings Rewritten: 2024-01-26 [2.3b1] GuiProjectSettings This file is a part of novelWriter Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ # noqa from __future__ import annotations import csv import logging from pathlib import Path from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt6.QtGui import QCloseEvent, QColor from PyQt6.QtWidgets import ( QAbstractItemView, QApplication, QColorDialog, QDialogButtonBox, QFileDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMenu, QStackedWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) from novelwriter import CONFIG, SHARED from novelwriter.common import formatFileFilter, qtAddAction, qtLambda, simplified from novelwriter.constants import nwLabels, trConst from novelwriter.core.status import CUSTOM_COL, NWStatus, StatusEntry from novelwriter.enum import nwStandardButton, nwStatusShape from novelwriter.extensions.configlayout import NColorLabel, NFixedPage, NScrollableForm from novelwriter.extensions.modified import NComboBox, NDialog, NIconToolButton from novelwriter.extensions.pagedsidebar import NPagedSideBar from novelwriter.extensions.switch import NSwitch from novelwriter.types import QtSizeMinimum, QtSizeMinimumExpanding, QtUserRole logger = logging.getLogger(__name__) class GuiProjectSettings(NDialog): """GUI: Project Settings DIalog.""" PAGE_SETTINGS = 0 PAGE_STATUS = 1 PAGE_IMPORT = 2 PAGE_REPLACE = 3 newProjectSettingsReady = pyqtSignal() def __init__(self, parent: QWidget, gotoPage: int = PAGE_SETTINGS) -> None: super().__init__(parent=parent) logger.debug("Create: GuiProjectSettings") self.setObjectName("GuiProjectSettings") self.setWindowTitle(self.tr("Project Settings")) options = SHARED.project.options self.setMinimumSize(500, 400) self.resize( options.getInt("GuiProjectSettings", "winWidth", 650), options.getInt("GuiProjectSettings", "winHeight", 500), ) # Title self.titleLabel = NColorLabel( self.tr("Project Settings"), self, color=SHARED.theme.helpText, scale=NColorLabel.HEADER_SCALE, indent=4, ) # SideBar self.sidebar = NPagedSideBar(self) self.sidebar.setLabelColor(SHARED.theme.helpText) self.sidebar.setAccessibleName(self.titleLabel.text()) self.sidebar.addButton(self.tr("Settings"), self.PAGE_SETTINGS) self.sidebar.addButton(self.tr("Status"), self.PAGE_STATUS) self.sidebar.addButton(self.tr("Importance"), self.PAGE_IMPORT) self.sidebar.addButton(self.tr("Auto-Replace"), self.PAGE_REPLACE) self.sidebar.buttonClicked.connect(self._sidebarClicked) # Buttons self.btnSave = SHARED.theme.getStandardButton(nwStandardButton.SAVE, self) self.btnSave.clicked.connect(self._doSave) self.btnCancel = SHARED.theme.getStandardButton(nwStandardButton.CANCEL, self) self.btnCancel.clicked.connect(self.reject) self.btnBox = QDialogButtonBox(self) self.btnBox.addButton(self.btnSave, QDialogButtonBox.ButtonRole.AcceptRole) self.btnBox.addButton(self.btnCancel, QDialogButtonBox.ButtonRole.RejectRole) # Content SHARED.project.countStatus() self.settingsPage = _SettingsPage(self) self.statusPage = _StatusPage(self, True) self.importPage = _StatusPage(self, False) self.replacePage = _ReplacePage(self) self.mainStack = QStackedWidget(self) self.mainStack.addWidget(self.settingsPage) self.mainStack.addWidget(self.statusPage) self.mainStack.addWidget(self.importPage) self.mainStack.addWidget(self.replacePage) # Assemble self.topBox = QHBoxLayout() self.topBox.addWidget(self.titleLabel) self.topBox.addStretch(1) self.mainBox = QHBoxLayout() self.mainBox.addWidget(self.sidebar) self.mainBox.addWidget(self.mainStack) self.mainBox.setContentsMargins(0, 0, 0, 0) self.outerBox = QVBoxLayout() self.outerBox.addLayout(self.topBox) self.outerBox.addLayout(self.mainBox) self.outerBox.addWidget(self.btnBox) self.outerBox.setSpacing(8) self.setLayout(self.outerBox) self.setSizeGripEnabled(True) # Jump to Specified Page self.sidebar.setSelected(gotoPage) self._sidebarClicked(gotoPage) logger.debug("Ready: GuiProjectSettings") def __del__(self) -> None: # pragma: no cover logger.debug("Delete: GuiProjectSettings") ## # Events ## def closeEvent(self, event: QCloseEvent) -> None: """Capture the user closing the window and save settings.""" self._saveSettings() event.accept() self.softDelete() ## # Private Slots ## @pyqtSlot(int) def _sidebarClicked(self, pageId: int) -> None: """Process a user request to switch page.""" if pageId == self.PAGE_SETTINGS: self.mainStack.setCurrentWidget(self.settingsPage) elif pageId == self.PAGE_STATUS: self.mainStack.setCurrentWidget(self.statusPage) elif pageId == self.PAGE_IMPORT: self.mainStack.setCurrentWidget(self.importPage) elif pageId == self.PAGE_REPLACE: self.mainStack.setCurrentWidget(self.replacePage) @pyqtSlot() def _doSave(self) -> None: """Save settings and close dialog.""" project = SHARED.project projName = self.settingsPage.projName.text() projAuthor = self.settingsPage.projAuthor.text() projLang = self.settingsPage.projLang.currentData() spellLang = self.settingsPage.spellLang.currentData() doBackup = not self.settingsPage.noBackup.isChecked() project.data.setName(projName) project.data.setAuthor(projAuthor) project.data.setDoBackup(doBackup) project.data.setSpellLang(spellLang) project.setProjectLang(projLang) if self.statusPage.changed: logger.debug("Updating status labels") project.updateStatus("s", self.statusPage.getNewList()) if self.importPage.changed: logger.debug("Updating importance labels") project.updateStatus("i", self.importPage.getNewList()) if self.replacePage.changed: logger.debug("Updating auto-replace settings") project.data.setAutoReplace(self.replacePage.getNewList()) self.newProjectSettingsReady.emit() QApplication.processEvents() self.close() ## # Internal Functions ## def _saveSettings(self) -> None: """Save GUI settings.""" statusColW = self.statusPage.columnWidth() importColW = self.importPage.columnWidth() replaceColW = self.replacePage.columnWidth() logger.debug("Saving State: GuiProjectSettings") options = SHARED.project.options options.setValue("GuiProjectSettings", "winWidth", self.width()) options.setValue("GuiProjectSettings", "winHeight", self.height()) options.setValue("GuiProjectSettings", "statusColW", statusColW) options.setValue("GuiProjectSettings", "importColW", importColW) options.setValue("GuiProjectSettings", "replaceColW", replaceColW) class _SettingsPage(NScrollableForm): def __init__(self, parent: QWidget) -> None: super().__init__(parent=parent) data = SHARED.project.data self.setHelpTextStyle(SHARED.theme.helpText) self.setRowIndent(0) # Project Name self.projName = QLineEdit(self) self.projName.setMaxLength(200) self.projName.setMinimumWidth(200) self.projName.setText(data.name) self.addRow( self.tr("Project name"), self.projName, self.tr("Changing this will affect the backup path."), stretch=(3, 2) ) # Project Author self.projAuthor = QLineEdit(self) self.projAuthor.setMaxLength(200) self.projAuthor.setMinimumWidth(200) self.projAuthor.setText(data.author) self.addRow( self.tr("Author(s)"), self.projAuthor, self.tr("Only used when building the manuscript."), stretch=(3, 2) ) # Project Language projLang = data.language or CONFIG.guiLocale self.projLang = NComboBox(self) self.projLang.setMinimumWidth(200) for tag, language in CONFIG.listLanguages(CONFIG.LANG_PROJ): self.projLang.addItem(language, tag) self.projLang.setCurrentData(projLang, projLang) self.addRow( self.tr("Project language"), self.projLang, self.tr("Only used when building the manuscript."), stretch=(3, 2) ) # Spell Check Language self.spellLang = NComboBox(self) self.spellLang.setMinimumWidth(200) self.spellLang.addItem(self.tr("Default"), "None") if CONFIG.hasEnchant: for tag, language in SHARED.spelling.listDictionaries(): self.spellLang.addItem(language, tag) self.addRow( self.tr("Spell check language"), self.spellLang, self.tr("Overrides main preferences."), stretch=(3, 2) ) if (idx := self.spellLang.findData(data.spellLang)) != -1: self.spellLang.setCurrentIndex(idx) # Backup on Close self.noBackup = NSwitch(self) self.noBackup.setChecked(not data.doBackup) self.addRow( self.tr("Disable backup on close"), self.noBackup, self.tr("Overrides main preferences.") ) self.finalise() class _StatusPage(NFixedPage): C_DATA = 0 C_LABEL = 0 C_USAGE = 1 D_KEY = QtUserRole D_ENTRY = QtUserRole + 1 def __init__(self, parent: QWidget, isStatus: bool) -> None: super().__init__(parent=parent) if isStatus: self._kind = self.tr("Status") self._store = SHARED.project.data.itemStatus pageLabel = self.tr("Novel Document Status Levels") colSetting = "statusColW" else: self._kind = self.tr("Importance") self._store = SHARED.project.data.itemImport pageLabel = self.tr("Project Note Importance Levels") colSetting = "importColW" wCol0 = SHARED.project.options.getInt("GuiProjectSettings", colSetting, 130) self._changed = False self._color = QColor(100, 100, 100) self._shape = nwStatusShape.SQUARE self._theme = CUSTOM_COL self._icons = {} self._iPx = SHARED.theme.baseIconHeight iSz = SHARED.theme.baseIconSize iColor = self.palette().text().color() # Labels self.trCountNone = self.tr("Not in use") self.trCountOne = self.tr("Used once") self.trCountMore = self.tr("Used by {0} items") self.trSelColor = self.tr("Select Colour") # Title self.pageTitle = NColorLabel( pageLabel, self, color=SHARED.theme.helpText, scale=NColorLabel.HEADER_SCALE ) # List Box self.listBox = QTreeWidget(self) self.listBox.setHeaderLabels([self.tr("Label"), self.tr("Usage")]) self.listBox.setColumnWidth(self.C_LABEL, wCol0) self.listBox.setIndentation(0) self.listBox.setAccessibleName(pageLabel) self.listBox.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.listBox.itemSelectionChanged.connect(self._onSelectionChanged) for key, entry in self._store.iterItems(): self._addItem(key, StatusEntry.duplicate(entry)) # List Controls self.addButton = NIconToolButton(self, iSz, "add", "green") self.addButton.setToolTip(self.tr("Add Label")) self.addButton.clicked.connect(self._onItemCreate) self.delButton = NIconToolButton(self, iSz, "remove", "red") self.delButton.setToolTip(self.tr("Delete Label")) self.delButton.clicked.connect(self._onItemDelete) self.upButton = NIconToolButton(self, iSz, "chevron_up", "blue") self.upButton.setToolTip(self.tr("Move Up")) self.upButton.clicked.connect(qtLambda(self._moveItem, -1)) self.downButton = NIconToolButton(self, iSz, "chevron_down", "blue") self.downButton.setToolTip(self.tr("Move Down")) self.downButton.clicked.connect(qtLambda(self._moveItem, 1)) self.importButton = NIconToolButton(self, iSz, "import", "green") self.importButton.setToolTip(self.tr("Import Labels")) self.importButton.clicked.connect(self._importLabels) self.exportButton = NIconToolButton(self, iSz, "export", "blue") self.exportButton.setToolTip(self.tr("Export Labels")) self.exportButton.clicked.connect(self._exportLabels) # Edit Form self.editName = QLineEdit(self) self.editName.setMaxLength(40) self.editName.setPlaceholderText(self.tr("Select item to edit")) self.editName.setEnabled(False) self.editName.textEdited.connect(self._onNameEdit) self.labelName = QLabel(self.tr("Label"), self) self.labelName.setBuddy(self.editName) # Icon Colours self.iconColor = NComboBox(self) self.iconColor.setMinimumWidth(200) self.iconColor.setEnabled(False) self.iconColor.addItem(self.tr("Custom"), CUSTOM_COL) for key, label in nwLabels.THEME_COLORS.items(): self.iconColor.addItem(trConst(label), key) self.iconColor.currentIndexChanged.connect(self._onThemeSelect) self.labelColor = QLabel(self.tr("Colour"), self) self.labelColor.setBuddy(self.iconColor) buttonStyle = ( "QToolButton {padding: 0 4px;} " "QToolButton::menu-indicator {image: none;}" ) self.colorButton = NIconToolButton(self, iSz) self.colorButton.setToolTip(self.tr("Colour")) self.colorButton.setSizePolicy(QtSizeMinimum, QtSizeMinimumExpanding) self.colorButton.setStyleSheet(buttonStyle) self.colorButton.setEnabled(False) self.colorButton.clicked.connect(self._onColorSelect) def buildMenu(menu: QMenu | None, items: dict[nwStatusShape, str]) -> None: if menu is not None: for shape, label in items.items(): icon = NWStatus.createIcon(self._iPx, iColor, shape) action = qtAddAction(menu, trConst(label)) action.setIcon(icon) action.triggered.connect(qtLambda(self._selectShape, shape)) menu.addAction(action) self._icons[shape] = icon self.shapeMenu = QMenu(self) buildMenu(self.shapeMenu, nwLabels.SHAPES_PLAIN) buildMenu(self.shapeMenu.addMenu(self.tr("Circles ...")), nwLabels.SHAPES_CIRCLE) buildMenu(self.shapeMenu.addMenu(self.tr("Bars ...")), nwLabels.SHAPES_BARS) buildMenu(self.shapeMenu.addMenu(self.tr("Blocks ...")), nwLabels.SHAPES_BLOCKS) self.shapeButton = NIconToolButton(self, iSz) self.shapeButton.setMenu(self.shapeMenu) self.shapeButton.setToolTip(self.tr("Shape")) self.shapeButton.setSizePolicy(QtSizeMinimum, QtSizeMinimumExpanding) self.shapeButton.setStyleSheet(buttonStyle) self.shapeButton.setEnabled(False) self.labelShape = QLabel(self.tr("Shape"), self) self.labelShape.setBuddy(self.iconColor) # Assemble self.listControls = QVBoxLayout() self.listControls.addWidget(self.addButton) self.listControls.addWidget(self.delButton) self.listControls.addWidget(self.upButton) self.listControls.addWidget(self.downButton) self.listControls.addStretch(1) self.listControls.addWidget(self.importButton) self.listControls.addWidget(self.exportButton) self.editBox = QGridLayout() self.editBox.addWidget(self.labelName, 0, 0) self.editBox.addWidget(self.editName, 0, 1, 1, 5) self.editBox.addWidget(self.labelColor, 1, 0) self.editBox.addWidget(self.iconColor, 1, 1) self.editBox.addWidget(self.colorButton, 1, 2) self.editBox.addWidget(self.labelShape, 1, 3) self.editBox.addWidget(self.shapeButton, 1, 4) self.editBox.setColumnStretch(5, 1) self.innerBox = QGridLayout() self.innerBox.addWidget(self.listBox, 0, 0) self.innerBox.addLayout(self.listControls, 0, 1) self.innerBox.addLayout(self.editBox, 1, 0) self.innerBox.setRowStretch(0, 1) self.innerBox.setColumnStretch(0, 1) self.outerBox = QVBoxLayout() self.outerBox.addWidget(self.pageTitle, 0) self.outerBox.addLayout(self.innerBox, 1) self.setCentralLayout(self.outerBox) self._setButtonIcons() @property def changed(self) -> bool: """The user changed these settings.""" return self._changed ## # Methods ## def getNewList(self) -> list[tuple[str | None, StatusEntry]]: """Return list of entries.""" if self._changed: update = [] for n in range(self.listBox.topLevelItemCount()): if item := self.listBox.topLevelItem(n): key = item.data(self.C_DATA, self.D_KEY) entry = item.data(self.C_DATA, self.D_ENTRY) update.append((key, entry)) return update return [] def columnWidth(self) -> int: """Return the size of the header column.""" return self.listBox.columnWidth(0) ## # Private Slots ## @pyqtSlot(str) def _onNameEdit(self, text: str) -> None: """Update the status label text.""" if item := self._getSelectedItem(): name = simplified(text) entry: StatusEntry = item.data(self.C_DATA, self.D_ENTRY) entry.name = name item.setText(self.C_LABEL, name) self._changed = True @pyqtSlot(int) def _onThemeSelect(self, index: int) -> None: """Update the colour handling on theme selection change.""" self._theme = str(self.iconColor.currentData()) self._setButtonIcons() self._updateIcon() @pyqtSlot() def _onColorSelect(self) -> None: """Open a dialog to select the status icon colour.""" if (color := QColorDialog.getColor(self._color, self, self.trSelColor)).isValid(): self._color = color self._theme = CUSTOM_COL self._setButtonIcons() self._updateIcon() @pyqtSlot() def _onItemCreate(self) -> None: """Create a new status item.""" color = QColor(100, 100, 100) shape = nwStatusShape.SQUARE icon = NWStatus.createIcon(self._iPx, color, shape) theme = str(self.iconColor.currentData()) self._addItem(None, StatusEntry(self.tr("New Item"), color, theme, shape, icon, 0)) self._changed = True @pyqtSlot() def _onItemDelete(self) -> None: """Delete a status item.""" if item := self._getSelectedItem(): iRow = self.listBox.indexOfTopLevelItem(item) entry: StatusEntry = item.data(self.C_DATA, self.D_ENTRY) if entry.count > 0: SHARED.error(self.tr("Cannot delete a status item that is in use.")) else: self.listBox.takeTopLevelItem(iRow) self._changed = True @pyqtSlot() def _onSelectionChanged(self) -> None: """Extract the info of a selected item and populate the settings boxes and button. If no item is selected, clear the form. """ if item := self._getSelectedItem(): entry: StatusEntry = item.data(self.C_DATA, self.D_ENTRY) self._color = entry.color self._shape = entry.shape self._theme = entry.theme self._setButtonIcons() self.editName.setText(entry.name) self.editName.selectAll() self.editName.setFocus() self.editName.setEnabled(True) self.iconColor.setEnabled(True) self.colorButton.setEnabled(True) self.shapeButton.setEnabled(True) else: self._color = QColor(100, 100, 100) self._shape = nwStatusShape.SQUARE self._theme = CUSTOM_COL self._setButtonIcons() self.editName.setText("") self.editName.setEnabled(False) self.iconColor.setEnabled(False) self.colorButton.setEnabled(False) self.shapeButton.setEnabled(False) @pyqtSlot() def _importLabels(self) -> None: """Import labels from file.""" if path := QFileDialog.getOpenFileName( self, self.tr("Import File"), str(CONFIG.homePath()), filter=formatFileFilter(["*.csv", "*"]), )[0]: try: with open(path, mode="r", encoding="utf-8") as fo: for row in csv.reader(fo): if entry := self._store.fromRaw(row): self._addItem(None, entry) self._changed = True except Exception as exc: SHARED.error("Could not read file.", exc=exc) return return @pyqtSlot() def _exportLabels(self) -> None: """Export labels to file.""" name = f"{SHARED.project.data.fileSafeName} - {self._kind}.csv" if path := QFileDialog.getSaveFileName( self, self.tr("Export File"), str(CONFIG.homePath() / name), )[0]: try: path = Path(path).with_suffix(".csv") with open(path, mode="w", encoding="utf-8") as fo: writer = csv.writer(fo) for n in range(self.listBox.topLevelItemCount()): if item := self.listBox.topLevelItem(n): entry: StatusEntry = item.data(self.C_DATA, self.D_ENTRY) writer.writerow([entry.shape.name, entry.color.name(), entry.name]) except Exception as exc: SHARED.error("Could not write file.", exc=exc) ## # Internal Functions ## def _selectShape(self, shape: nwStatusShape) -> None: """Set the current shape.""" self._shape = shape self._setButtonIcons() self._updateIcon() def _updateIcon(self) -> None: """Apply changes made to a status icon.""" if item := self._getSelectedItem(): icon = NWStatus.createIcon(self._iPx, self._pickColor(), self._shape) entry: StatusEntry = item.data(self.C_DATA, self.D_ENTRY) entry.color = self._color entry.shape = self._shape entry.theme = self._theme entry.icon = icon item.setIcon(self.C_LABEL, icon) self._changed = True def _addItem(self, key: str | None, entry: StatusEntry) -> None: """Add a status item to the list.""" item = QTreeWidgetItem() item.setText(self.C_LABEL, entry.name) item.setIcon(self.C_LABEL, entry.icon) item.setText(self.C_USAGE, self._usageString(entry.count)) item.setData(self.C_DATA, self.D_KEY, key) item.setData(self.C_DATA, self.D_ENTRY, entry) self.listBox.addTopLevelItem(item) def _moveItem(self, step: int) -> None: """Move and item up or down step.""" if item := self._getSelectedItem(): tIdx = self.listBox.indexOfTopLevelItem(item) nItm = self.listBox.topLevelItemCount() nIdx = tIdx + step if (0 <= nIdx < nItm) and (cItem := self.listBox.takeTopLevelItem(tIdx)): self.listBox.insertTopLevelItem(nIdx, cItem) self.listBox.clearSelection() cItem.setSelected(True) self._changed = True def _getSelectedItem(self) -> QTreeWidgetItem | None: """Get the currently selected item.""" if items := self.listBox.selectedItems(): return items[0] return None def _usageString(self, count: int) -> str: """Generate usage string.""" if count == 0: return self.trCountNone elif count == 1: return self.trCountOne else: return self.trCountMore.format(count) def _setButtonIcons(self) -> None: """Set the colour of the colour button.""" icon = NWStatus.createIcon(self._iPx, self._pickColor(), nwStatusShape.SQUARE) self.iconColor.setCurrentData(self._theme, CUSTOM_COL) self.colorButton.setIcon(icon) self.shapeButton.setIcon(self._icons[self._shape]) def _pickColor(self) -> QColor: """Get the correct colour value based on selections.""" if self._theme == CUSTOM_COL: return self._color return SHARED.theme.getBaseColor(self._theme) class _ReplacePage(NFixedPage): C_KEY = 0 C_REPL = 1 def __init__(self, parent: QWidget) -> None: super().__init__(parent=parent) self._changed = False iSz = SHARED.theme.baseIconSize wCol0 = SHARED.project.options.getInt("GuiProjectSettings", "replaceColW", 130) # Title self.pageTitle = NColorLabel( self.tr("Text Auto-Replace for Preview and Build"), self, color=SHARED.theme.helpText, scale=NColorLabel.HEADER_SCALE ) # List Box self.listBox = QTreeWidget(self) self.listBox.setHeaderLabels([self.tr("Keyword"), self.tr("Replace With")]) self.listBox.setColumnWidth(self.C_KEY, wCol0) self.listBox.setIndentation(0) self.listBox.setAccessibleName(self.pageTitle.text()) self.listBox.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.listBox.itemSelectionChanged.connect(self._onSelectionChanged) for aKey, aVal in SHARED.project.data.autoReplace.items(): newItem = QTreeWidgetItem([f"<{aKey}>", aVal]) self.listBox.addTopLevelItem(newItem) self.listBox.sortByColumn(self.C_KEY, Qt.SortOrder.AscendingOrder) self.listBox.setSortingEnabled(True) # List Controls self.addButton = NIconToolButton(self, iSz, "add", "green") self.addButton.clicked.connect(self._onEntryCreated) self.delButton = NIconToolButton(self, iSz, "remove", "red") self.delButton.clicked.connect(self._onEntryDeleted) # Edit Form self.editKey = QLineEdit(self) self.editKey.setPlaceholderText(self.tr("Select item to edit")) self.editKey.setEnabled(False) self.editKey.setMaxLength(40) self.editKey.textEdited.connect(self._onKeyEdit) self.editValue = QLineEdit(self) self.editValue.setEnabled(False) self.editValue.setMaxLength(250) self.editValue.textEdited.connect(self._onValueEdit) # Assemble self.listControls = QVBoxLayout() self.listControls.addWidget(self.addButton) self.listControls.addWidget(self.delButton) self.listControls.addStretch(1) self.editBox = QHBoxLayout() self.editBox.addWidget(self.editKey, 4) self.editBox.addWidget(self.editValue, 5) self.mainBox = QVBoxLayout() self.mainBox.addWidget(self.listBox, 1) self.mainBox.addLayout(self.editBox, 0) self.innerBox = QHBoxLayout() self.innerBox.addLayout(self.mainBox) self.innerBox.addLayout(self.listControls) self.outerBox = QVBoxLayout() self.outerBox.addWidget(self.pageTitle) self.outerBox.addLayout(self.innerBox) self.setCentralLayout(self.outerBox) @property def changed(self) -> bool: """The user changed these settings.""" return self._changed ## # Methods ## def getNewList(self) -> dict[str, str]: """Extract the list from the widget.""" new = {} for n in range(self.listBox.topLevelItemCount()): if item := self.listBox.topLevelItem(n): if key := self._stripKey(item.text(self.C_KEY)): new[key] = item.text(self.C_REPL) return new def columnWidth(self) -> int: """Return the size of the header column.""" return self.listBox.columnWidth(0) ## # Private Slots ## @pyqtSlot(str) def _onKeyEdit(self, text: str) -> None: """Update the key text.""" if (item := self._getSelectedItem()) and (key := self._stripKey(text)): item.setText(self.C_KEY, f"<{key}>") self._changed = True @pyqtSlot(str) def _onValueEdit(self, text: str) -> None: """Update the value text.""" if item := self._getSelectedItem(): item.setText(self.C_REPL, text) self._changed = True @pyqtSlot() def _onSelectionChanged(self) -> None: """Extract the details from the selected item and populate the edit form. """ if item := self._getSelectedItem(): self.editKey.setText(self._stripKey(item.text(self.C_KEY))) self.editValue.setText(item.text(self.C_REPL)) self.editKey.setEnabled(True) self.editValue.setEnabled(True) self.editKey.selectAll() self.editKey.setFocus() else: self.editKey.setText("") self.editValue.setText("") self.editKey.setEnabled(False) self.editValue.setEnabled(False) @pyqtSlot() def _onEntryCreated(self) -> None: """Add a new list entry.""" key = f"" self.listBox.addTopLevelItem(QTreeWidgetItem([key, ""])) @pyqtSlot() def _onEntryDeleted(self) -> None: """Delete the selected entry.""" if item := self._getSelectedItem(): self.listBox.takeTopLevelItem(self.listBox.indexOfTopLevelItem(item)) self._changed = True ## # Internal Functions ## def _getSelectedItem(self) -> QTreeWidgetItem | None: """Extract the currently selected item.""" if items := self.listBox.selectedItems(): return items[0] return None def _stripKey(self, key: str) -> str: """Clean up the replace key string.""" return "".join(c for c in key if c.isalnum())