diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index bb2ade31..eedcde10 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -358,8 +358,8 @@ class ProjectXMLReader: logger.debug("Parsing section (legacy format)") # Create maps to look up name -> key for status and importance - statusMap = {entry.get("name"): key for key, entry in data.itemStatus.items()} - importMap = {entry.get("name"): key for key, entry in data.itemImport.items()} + sMap: dict[str | None, str] = {e.name: k for k, e in data.itemStatus.iterItems()} + iMap: dict[str | None, str] = {e.name: k for k, e in data.itemImport.iterItems()} for xItem in xSection: if xItem.tag != "item": @@ -406,9 +406,9 @@ class ProjectXMLReader: # Status was split into separate status/import with a key in 1.4 if item.get("class", "") in ("NOVEL", "ARCHIVE"): - name["status"] = statusMap.get(tmpStatus, None) + name["status"] = sMap.get(tmpStatus, None) else: - name["import"] = importMap.get(tmpStatus, None) + name["import"] = iMap.get(tmpStatus, None) # A number of layouts were removed in 1.3 if item.get("layout", "") in ( diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py index a8c0c0ba..01c25067 100644 --- a/novelwriter/core/status.py +++ b/novelwriter/core/status.py @@ -27,7 +27,8 @@ from __future__ import annotations import logging import random -from collections.abc import ItemsView, Iterable, Iterator, KeysView, ValuesView +from collections.abc import Iterable +from dataclasses import dataclass from math import cos, pi, sin from typing import TYPE_CHECKING, Literal @@ -35,7 +36,7 @@ from PyQt5.QtCore import QPointF, Qt from PyQt5.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QColor, QPolygonF from novelwriter import CONFIG -from novelwriter.common import minmax, simplified +from novelwriter.common import simplified from novelwriter.enum import nwStatusShape from novelwriter.types import QtPaintAnitAlias, QtTransparent @@ -45,6 +46,18 @@ if TYPE_CHECKING: # pragma: no cover logger = logging.getLogger(__name__) +@dataclass +class StatusEntry: + + name: str + colour: QColor + shape: nwStatusShape + icon: QIcon + count: int = 0 + +# END Class StatusEntry + + class NWStatus: STATUS = 1 @@ -53,11 +66,13 @@ class NWStatus: def __init__(self, kind: Literal[1, 2]) -> None: self._type = kind - self._store = {} + self._store: dict[str, StatusEntry] = {} self._default = None self._iPX = CONFIG.pxInt(24) - self._defaultIcon = self._createIcon(100, 100, 100, nwStatusShape.SQUARE) + self._defaultIcon = self.createIcon( + self._iPX, QColor(100, 100, 100), nwStatusShape.SQUARE + ) if self._type == self.STATUS: self._prefix = "s" @@ -68,8 +83,18 @@ class NWStatus: return - def write(self, key: str | None, name: str, col: tuple, shape: nwStatusShape | str, - count: int | None = None) -> str: + def __len__(self) -> int: + return len(self._store) + + def __getitem__(self, key: str) -> StatusEntry: + return self._store[key] + + ## + # Methods + ## + + def write(self, key: str | None, name: str, col: tuple[int, int, int], + shape: nwStatusShape | str, count: int | None = None) -> str: """Add or update a status entry. If the key is invalid, a new key is generated. """ @@ -80,26 +105,25 @@ class NWStatus: if len(col) != 3: col = (100, 100, 100) - cR = minmax(col[0], 0, 255) - cG = minmax(col[1], 0, 255) - cB = minmax(col[2], 0, 255) name = simplified(name) + colour = QColor(*col) if not isinstance(shape, nwStatusShape): if shape in nwStatusShape.__members__: shape = nwStatusShape[shape] else: shape = nwStatusShape.SQUARE - if count is None: - count = self._store.get(key, {}).get("count", 0) + icon = self.createIcon(self._iPX, colour, shape) - self._store[key] = { - "name": name, - "icon": self._createIcon(cR, cG, cB, shape), - "cols": (cR, cG, cB), - "count": count, - "shape": shape, - } + if key and key in self._store: + entry = self._store[key] + entry.name = name + entry.colour = colour + entry.shape = shape + entry.icon = icon + entry.count = count or 0 + else: + self._store[key] = StatusEntry(name, colour, shape, icon, count or 0) if self._default is None: self._default = key @@ -110,7 +134,7 @@ class NWStatus: """Remove an entry in the list, except if the count > 0.""" if key not in self._store: return False - if self._store[key]["count"] > 0: + if self._store[key].count > 0: return False del self._store[key] @@ -135,33 +159,33 @@ class NWStatus: def name(self, key: str | None) -> str: """Return the name associated with a given key.""" if key and key in self._store: - return self._store[key]["name"] + return self._store[key].name elif self._default is not None: - return self._store[self._default]["name"] + return self._store[self._default].name return "" - def cols(self, key: str | None) -> tuple[int, int, int]: + def cols(self, key: str | None) -> QColor: """Return the colours associated with a given key.""" if key and key in self._store: - return self._store[key]["cols"] + return self._store[key].colour elif self._default is not None: - return self._store[self._default]["cols"] - return 100, 100, 100 + return self._store[self._default].colour + return QColor(100, 100, 100) def count(self, key: str | None) -> int: """Return the count associated with a given key.""" if key and key in self._store: - return self._store[key]["count"] + return self._store[key].count elif self._default is not None: - return self._store[self._default]["count"] + return self._store[self._default].count return 0 def icon(self, key: str | None) -> QIcon: """Return the icon associated with a given key.""" if key and key in self._store: - return self._store[key]["icon"] + return self._store[key].icon elif self._default is not None: - return self._store[self._default]["icon"] + return self._store[self._default].icon return self._defaultIcon def reorder(self, order: list[str]) -> bool: @@ -188,28 +212,49 @@ class NWStatus: def resetCounts(self) -> None: """Clear the counts of references to the status entries.""" for key in self._store: - self._store[key]["count"] = 0 + self._store[key].count = 0 return def increment(self, key: str | None) -> None: """Increment the counter for a given entry.""" if key and key in self._store: - self._store[key]["count"] += 1 + self._store[key].count += 1 return def pack(self) -> Iterable[tuple[str, dict]]: """Pack the status entries into a dictionary.""" - for key, data in self._store.items(): - yield (data["name"], { + for key, entry in self._store.items(): + yield (entry.name, { "key": key, - "count": str(data["count"]), - "red": str(data["cols"][0]), - "green": str(data["cols"][1]), - "blue": str(data["cols"][2]), - "shape": data["shape"].name, + "count": str(entry.count), + "red": str(entry.colour.red()), + "green": str(entry.colour.green()), + "blue": str(entry.colour.blue()), + "shape": entry.shape.name, }) return + def iterItems(self) -> Iterable[tuple[str, StatusEntry]]: + """Yield entries from the status icons.""" + yield from self._store.items() + + @staticmethod + def createIcon(height: int, colour: QColor, shape: nwStatusShape) -> QIcon: + """Generate an icon for a status label.""" + pixmap = QPixmap(48, 48) + pixmap.fill(QtTransparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QtPaintAnitAlias) + painter.fillPath(_SHAPES.getShape(shape), colour) + painter.end() + + return QIcon(pixmap.scaled( + height, height, + Qt.AspectRatioMode.IgnoreAspectRatio, + Qt.TransformationMode.SmoothTransformation + )) + ## # Internal Functions ## @@ -239,44 +284,6 @@ class NWStatus: return False return True - def _createIcon(self, red: int, green: int, blue: int, shape: nwStatusShape) -> QIcon: - """Generate an icon for a status label.""" - pixmap = QPixmap(48, 48) - pixmap.fill(QtTransparent) - - painter = QPainter(pixmap) - painter.setRenderHint(QtPaintAnitAlias) - painter.fillPath(_SHAPES.getShape(shape), QColor(red, green, blue)) - painter.end() - - return QIcon(pixmap.scaled( - self._iPX, self._iPX, - Qt.AspectRatioMode.IgnoreAspectRatio, - Qt.TransformationMode.SmoothTransformation - )) - - ## - # Iterator Bits - ## - - def __len__(self) -> int: - return len(self._store) - - def __getitem__(self, key: str) -> dict: - return self._store[key] - - def __iter__(self) -> Iterator[dict]: - return iter(self._store) - - def keys(self) -> KeysView[str]: - return self._store.keys() - - def items(self) -> ItemsView[str, dict]: - return self._store.items() - - def values(self) -> ValuesView[dict]: - return self._store.values() - # END Class NWStatus diff --git a/novelwriter/dialogs/projectsettings.py b/novelwriter/dialogs/projectsettings.py index 4e234f76..8d554a56 100644 --- a/novelwriter/dialogs/projectsettings.py +++ b/novelwriter/dialogs/projectsettings.py @@ -36,6 +36,8 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG, SHARED from novelwriter.common import simplified +from novelwriter.core.status import NWStatus +from novelwriter.enum import nwStatusShape from novelwriter.extensions.configlayout import NColourLabel, NFixedPage, NScrollableForm from novelwriter.extensions.modified import NComboBox, NIconToolButton from novelwriter.extensions.pagedsidebar import NPagedSideBar @@ -301,12 +303,14 @@ class _SettingsPage(NScrollableForm): class _StatusPage(NFixedPage): - COL_LABEL = 0 - COL_USAGE = 1 + C_DATA = 0 + C_LABEL = 0 + C_USAGE = 1 - KEY_ROLE = QtUserRole - COL_ROLE = QtUserRole + 1 - NUM_ROLE = QtUserRole + 2 + D_KEY = QtUserRole + D_COLOR = QtUserRole + 1 + D_SHAPE = QtUserRole + 2 + D_COUNT = QtUserRole + 3 def __init__(self, parent: QWidget, isStatus: bool) -> None: super().__init__(parent=parent) @@ -332,6 +336,11 @@ class _StatusPage(NFixedPage): iSz = SHARED.theme.baseIconSize bSz = SHARED.theme.buttonIconSize + # Labels + self.trCountNone = self.tr("Not in use") + self.trCountOne = self.tr("Used once") + self.trCountMore = self.tr("Used by {0} items") + # Title self.pageTitle = NColourLabel( pageLabel, SHARED.theme.helpText, parent=self, @@ -342,11 +351,11 @@ class _StatusPage(NFixedPage): self.listBox = QTreeWidget(self) self.listBox.setHeaderLabels([self.tr("Label"), self.tr("Usage")]) self.listBox.itemSelectionChanged.connect(self._selectedItem) - self.listBox.setColumnWidth(self.COL_LABEL, wCol0) + self.listBox.setColumnWidth(self.C_LABEL, wCol0) self.listBox.setIndentation(0) - for key, entry in status.items(): - self._addItem(key, entry["name"], entry["cols"], entry["count"]) + for key, entry in status.iterItems(): + self._addItem(key, entry.name, entry.colour, entry.shape, entry.icon, entry.count) # List Controls self.addButton = NIconToolButton(self, iSz, "add") @@ -424,9 +433,10 @@ class _StatusPage(NFixedPage): item = self.listBox.topLevelItem(n) if item is not None: newList.append({ - "key": item.data(self.COL_LABEL, self.KEY_ROLE), - "name": item.text(self.COL_LABEL), - "cols": item.data(self.COL_LABEL, self.COL_ROLE), + "key": item.data(self.C_DATA, self.D_KEY), + "name": item.text(self.C_DATA), + "cols": item.data(self.C_DATA, self.D_COLOR), + "shape": item.data(self.C_DATA, self.D_SHAPE), }) return newList, self._colDeleted return [], [] @@ -457,7 +467,7 @@ class _StatusPage(NFixedPage): @pyqtSlot() def _newItem(self) -> None: """Create a new status item.""" - self._addItem(None, self.tr("New Item"), (100, 100, 100), 0) + # self._addItem(None, self.tr("New Item"), (100, 100, 100), 0) self._changed = True return @@ -467,11 +477,11 @@ class _StatusPage(NFixedPage): selItem = self._getSelectedItem() if isinstance(selItem, QTreeWidgetItem): iRow = self.listBox.indexOfTopLevelItem(selItem) - if selItem.data(self.COL_LABEL, self.NUM_ROLE) > 0: + if selItem.data(self.C_LABEL, self.D_COUNT) > 0: SHARED.error(self.tr("Cannot delete a status item that is in use.")) else: self.listBox.takeTopLevelItem(iRow) - self._colDeleted.append(selItem.data(self.COL_LABEL, self.KEY_ROLE)) + self._colDeleted.append(selItem.data(self.C_DATA, self.D_KEY)) self._changed = True return @@ -480,9 +490,9 @@ class _StatusPage(NFixedPage): """Save changes made to a status item.""" selItem = self._getSelectedItem() if isinstance(selItem, QTreeWidgetItem): - selItem.setText(self.COL_LABEL, simplified(self.editName.text())) - selItem.setIcon(self.COL_LABEL, self.colButton.icon()) - selItem.setData(self.COL_LABEL, self.COL_ROLE, ( + selItem.setText(self.C_LABEL, simplified(self.editName.text())) + selItem.setIcon(self.C_LABEL, self.colButton.icon()) + selItem.setData(self.C_DATA, self.D_COLOR, ( self._selColour.red(), self._selColour.green(), self._selColour.blue() )) self._changed = True @@ -495,11 +505,11 @@ class _StatusPage(NFixedPage): """ selItem = self._getSelectedItem() if isinstance(selItem, QTreeWidgetItem): - cols = selItem.data(self.COL_LABEL, self.COL_ROLE) - name = selItem.text(self.COL_LABEL) + cols = selItem.data(self.C_DATA, self.D_COLOR) + name = selItem.text(self.C_LABEL) pixmap = QPixmap(self.iPx, self.iPx) - pixmap.fill(QColor(*cols)) - self._selColour = QColor(*cols) + pixmap.fill(cols) + self._selColour = cols self.editName.setText(name) self.colButton.setIcon(QIcon(pixmap)) self.editName.selectAll() @@ -522,19 +532,20 @@ class _StatusPage(NFixedPage): # Internal Functions ## - def _addItem(self, key: str | None, name: str, - colour: tuple[int, int, int], count: int) -> None: + def _addItem(self, key: str | None, name: str, colour: QColor, + shape: nwStatusShape, icon: QIcon | None, count: int) -> None: """Add a status item to the list.""" - pixmap = QPixmap(self.iPx, self.iPx) - pixmap.fill(QColor(*colour)) + if icon is None: + icon = NWStatus.createIcon(SHARED.theme.baseIconHeight, colour, shape) item = QTreeWidgetItem() - item.setText(self.COL_LABEL, name) - item.setIcon(self.COL_LABEL, QIcon(pixmap)) - item.setData(self.COL_LABEL, self.KEY_ROLE, key) - item.setData(self.COL_LABEL, self.COL_ROLE, colour) - item.setData(self.COL_LABEL, self.NUM_ROLE, count) - item.setText(self.COL_USAGE, self._usageString(count)) + item.setText(self.C_LABEL, name) + item.setIcon(self.C_LABEL, icon) + item.setText(self.C_USAGE, self._usageString(count)) + item.setData(self.C_DATA, self.D_KEY, key) + item.setData(self.C_DATA, self.D_COLOR, colour) + item.setData(self.C_DATA, self.D_SHAPE, shape) + item.setData(self.C_DATA, self.D_COUNT, count) self.listBox.addTopLevelItem(item) @@ -571,11 +582,11 @@ class _StatusPage(NFixedPage): def _usageString(self, count: int) -> str: """Generate usage string.""" if count == 0: - return self.tr("Not in use") + return self.trCountNone elif count == 1: - return self.tr("Used once") + return self.trCountOne else: - return self.tr("Used by {0} items").format(count) + return self.trCountMore.format(count) # END Class _StatusPage diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 5ba158af..7743c3f9 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -1855,11 +1855,11 @@ class _TreeContextMenu(QMenu): if self._item.isNovelLike(): menu = self.addMenu(self.tr("Set Status to ...")) current = self._item.itemStatus - for n, (key, entry) in enumerate(SHARED.project.data.itemStatus.items()): - name = entry["name"] + for n, (key, entry) in enumerate(SHARED.project.data.itemStatus.iterItems()): + name = entry.name if not multi and current == key: name += f" ({nwUnicode.U_CHECK})" - action = menu.addAction(entry["icon"], name) + action = menu.addAction(entry.icon, name) if multi: action.triggered.connect(lambda n, key=key: self._iterSetItemStatus(key)) else: @@ -1872,11 +1872,11 @@ class _TreeContextMenu(QMenu): else: menu = self.addMenu(self.tr("Set Importance to ...")) current = self._item.itemImport - for n, (key, entry) in enumerate(SHARED.project.data.itemImport.items()): - name = entry["name"] + for n, (key, entry) in enumerate(SHARED.project.data.itemImport.iterItems()): + name = entry.name if not multi and current == key: name += f" ({nwUnicode.U_CHECK})" - action = menu.addAction(entry["icon"], name) + action = menu.addAction(entry.icon, name) if multi: action.triggered.connect(lambda n, key=key: self._iterSetItemImport(key)) else: