diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c7a039..3c3bd6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # novelWriter Changelog +## Version 2.7.3 [2025-07-07] + +### Release Notes + +This is a patch release that fixes a bug in, and makes a few improvement to, Preferences. + +### Detailed Changelog + +**Bugfixes** + +* Fixes an issue where novelWriter would crash if a `%` was added to any of the free text settings + fields in Preferences. Issue #2455. PR #2456. + +**Improvements** + +* The "Dialogue line symbols" setting in Preferences now has an "Add" button with a dropdown menu + for all the symbols allowed in this field. Since virtually none of them are available on a + regular keyboard, this makes it easier to add them and more transparent which symbols are allowed + in the box. Issue #2453. PR #2457. +* The switch for the "Allow open-ended dialogue" setting in Preferences has been moved up one line + so it is clearer that it only applies to quoted dialogue, and not to the alternative dialogue + settings. Issue #2454. PR #2457. + +**Packaging** + +* Package license information has been updated to also list the licenses for the new icon themes. + Issue #2434. PRs #2435 and #2458. + +---- + ## Version 2.7.2 [2025-06-24] ### Release Notes diff --git a/novelwriter/common.py b/novelwriter/common.py index e9346a24..fc774778 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -699,11 +699,12 @@ class NWConfigParser(ConfigParser): """Common: Adapted Config Parser This is a subclass of the standard config parser that adds type safe - helper functions, and support for lists. + helper functions, and support for lists. It also turns off + interpolation, which would require % symbols to be escaped (#2455). """ def __init__(self) -> None: - super().__init__() + super().__init__(interpolation=None) return def rdStr(self, section: str, option: str, default: str) -> str: diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index 47ea748e..10395f28 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -441,9 +441,9 @@ class ProjectXMLReader: for xEntry in xItem: if xEntry.tag == "entry": key = xEntry.attrib.get("key", None) - red = checkInt(xEntry.attrib.get("red", 0), 0) # Deprecated in 1.5 R6 - green = checkInt(xEntry.attrib.get("green", 0), 0) # Deprecated in 1.5 R6 - blue = checkInt(xEntry.attrib.get("blue", 0), 0) # Deprecated in 1.5 R6 + red = checkInt(xEntry.attrib.get("red", 0), 0) # Removed in 1.5 R6 + green = checkInt(xEntry.attrib.get("green", 0), 0) # Removed in 1.5 R6 + blue = checkInt(xEntry.attrib.get("blue", 0), 0) # Removed in 1.5 R6 color = xEntry.attrib.get("color") # Added in 1.5 R6 count = checkInt(xEntry.attrib.get("count", 0), 0) shape = xEntry.attrib.get("shape", "") diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py index c3e58b76..3576e4a3 100644 --- a/novelwriter/core/status.py +++ b/novelwriter/core/status.py @@ -192,7 +192,8 @@ class NWStatus: def refreshIcons(self) -> None: """Refresh all icons.""" for entry in self._store.values(): - entry.color = SHARED.theme.parseColor(entry.theme) + if entry.theme != CUSTOM_COL: + entry.color = SHARED.theme.parseColor(entry.theme) entry.icon = NWStatus.createIcon(self._height, entry.color, entry.shape) return diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index e7155bfd..f13efb40 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -29,7 +29,7 @@ import logging from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt6.QtGui import QAction, QCloseEvent, QKeyEvent, QKeySequence from PyQt6.QtWidgets import ( - QCompleter, QDialogButtonBox, QFileDialog, QHBoxLayout, QLineEdit, + QCompleter, QDialogButtonBox, QFileDialog, QHBoxLayout, QLineEdit, QMenu, QPushButton, QVBoxLayout, QWidget ) @@ -608,6 +608,7 @@ class GuiPreferences(NDialog): self.sidebar.addButton(title, section) self.mainForm.addGroupLabel(title, section) + # Dialogue Quotes self.dialogStyle = NComboBox(self) self.dialogStyle.addItem(self.tr("None"), 0) self.dialogStyle.addItem(self.tr("Single Quotes"), 1) @@ -619,6 +620,15 @@ class GuiPreferences(NDialog): self.tr("Applies to the selected quote styles.") ) + # Open-Ended Dialogue + self.allowOpenDial = NSwitch(self) + self.allowOpenDial.setChecked(CONFIG.allowOpenDial) + self.mainForm.addRow( + self.tr("Allow open-ended dialogue"), self.allowOpenDial, + self.tr("Highlight dialogue line with no closing quote.") + ) + + # Alternative Dialogue self.altDialogOpen = QLineEdit(self) self.altDialogOpen.setMaxLength(4) self.altDialogOpen.setFixedWidth(boxFixed) @@ -636,23 +646,30 @@ class GuiPreferences(NDialog): self.tr("Custom highlighting of dialogue text.") ) - self.allowOpenDial = NSwitch(self) - self.allowOpenDial.setChecked(CONFIG.allowOpenDial) - self.mainForm.addRow( - self.tr("Allow open-ended dialogue"), self.allowOpenDial, - self.tr("Highlight dialogue line with no closing quote.") - ) + # Dialogue Line + self.mnLineSymbols = QMenu(self) + for symbol in nwQuotes.ALLOWED: + label = trConst(nwQuotes.SYMBOLS.get(symbol, nwQuotes.DASHES.get(symbol, "None"))) + self.mnLineSymbols.addAction( + f"[ {symbol } ] {label}", + lambda symbol=symbol: self._insertDialogLineSymbol(symbol) + ) self.dialogLine = QLineEdit(self) - self.dialogLine.setMaxLength(4) - self.dialogLine.setFixedWidth(boxFixed) + self.dialogLine.setMinimumWidth(100) self.dialogLine.setAlignment(QtAlignCenter) - self.dialogLine.setText(CONFIG.dialogLine) + self.dialogLine.setText(" ".join(CONFIG.dialogLine)) + + self.dialogLineButton = NIconToolButton(self, iSz, "add", "green") + self.dialogLineButton.setMenu(self.mnLineSymbols) + self.mainForm.addRow( self.tr("Dialogue line symbols"), self.dialogLine, - self.tr("Lines starting with any of these symbols are dialogue.") + self.tr("Lines starting with any of these symbols are dialogue."), + button=self.dialogLineButton ) + # Narrator Break self.narratorBreak = NComboBox(self) self.narratorDialog = NComboBox(self) for key, value in nwQuotes.DASHES.items(): @@ -672,6 +689,7 @@ class GuiPreferences(NDialog): self.tr("Alternates dialogue highlighting within any paragraph.") ) + # Emphasis self.highlightEmph = NSwitch(self) self.highlightEmph.setChecked(CONFIG.highlightEmph) self.mainForm.addRow( @@ -679,6 +697,7 @@ class GuiPreferences(NDialog): self.tr("Applies to the document editor only.") ) + # Additional Spaces self.showMultiSpaces = NSwitch(self) self.showMultiSpaces.setChecked(CONFIG.showMultiSpaces) self.mainForm.addRow( @@ -914,6 +933,14 @@ class GuiPreferences(NDialog): self.askBeforeBackup.setEnabled(state) return + @pyqtSlot(str) + def _insertDialogLineSymbol(self, symbol: str) -> None: + """Insert a symbol in the dialogue line box.""" + current = self.dialogLine.text() + values = processDialogSymbols(f"{current} {symbol}") + self.dialogLine.setText(" ".join(values)) + return + @pyqtSlot(bool) def _toggleAutoReplaceMain(self, state: bool) -> None: """Toggle switches controlled by the auto replace switch.""" diff --git a/tests/test_core/test_core_status.py b/tests/test_core/test_core_status.py index 1ea0c09e..7927678e 100644 --- a/tests/test_core/test_core_status.py +++ b/tests/test_core/test_core_status.py @@ -324,23 +324,55 @@ def testCoreStatus_Entries(mockGUI, mockRnd): @pytest.mark.core -def testCoreStatus_RefreshIcons(mockGUIwithTheme, mockRnd): - """Test refreshing the icons of the NWStatus class.""" +def testCoreStatus_RefreshIcons_Theme(mockGUIwithTheme, mockRnd): + """Test refreshing the icons with theme colours.""" nStatus = NWStatus(NWStatus.STATUS) nStatus.add(None, "New", "default", "SQUARE", 0) nStatus.add(None, "Note", "red", "CIRCLE", 0) nStatus.add(None, "Draft", "yellow", "SQUARE", 0) nStatus.add(None, "Finished", "green", "SQUARE", 0) - beforeIcons = [nStatus[statusKeys[i]].icon for i in range(4)] + iconsA = [nStatus[statusKeys[i]].icon for i in range(4)] + themeA = [nStatus[statusKeys[i]].theme for i in range(4)] # Refreshing the icons should generate new ones nStatus.refreshIcons() - afterIcons = [nStatus[statusKeys[i]].icon for i in range(4)] + iconsB = [nStatus[statusKeys[i]].icon for i in range(4)] + themeB = [nStatus[statusKeys[i]].theme for i in range(4)] - for before, after in zip(beforeIcons, afterIcons, strict=False): + for before, after in zip(iconsA, iconsB, strict=False): assert before is not after + assert themeA == themeB + + +@pytest.mark.core +def testCoreStatus_RefreshIcons_Custom(mockGUIwithTheme, mockRnd): + """Test refreshing the icons with custom colours.""" + nStatus = NWStatus(NWStatus.STATUS) + nStatus.add(None, "New", "#707070", "SQUARE", 0) + nStatus.add(None, "Note", "#ff0000", "CIRCLE", 0) + nStatus.add(None, "Draft", "#ffff00", "SQUARE", 0) + nStatus.add(None, "Finished", "#00ff00", "SQUARE", 0) + + iconsA = [nStatus[statusKeys[i]].icon for i in range(4)] + themeA = [nStatus[statusKeys[i]].theme for i in range(4)] + colorA = [nStatus[statusKeys[i]].color.getRgb() for i in range(4)] + + # Refreshing the icons should generate new ones + nStatus.refreshIcons() + iconsB = [nStatus[statusKeys[i]].icon for i in range(4)] + themeB = [nStatus[statusKeys[i]].theme for i in range(4)] + colorB = [nStatus[statusKeys[i]].color.getRgb() for i in range(4)] + + for before, after in zip(iconsA, iconsB, strict=False): + assert before is not after + + # But they should have the same colour value (#2452) + assert themeA == [CUSTOM_COL, CUSTOM_COL, CUSTOM_COL, CUSTOM_COL] + assert themeB == [CUSTOM_COL, CUSTOM_COL, CUSTOM_COL, CUSTOM_COL] + assert colorA == colorB + @pytest.mark.core def testCoreStatus_Pack(mockGUIwithTheme, mockRnd): diff --git a/tests/test_dialogs/test_dlg_preferences.py b/tests/test_dialogs/test_dlg_preferences.py index da87a629..bf1ee362 100644 --- a/tests/test_dialogs/test_dlg_preferences.py +++ b/tests/test_dialogs/test_dlg_preferences.py @@ -273,14 +273,17 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths): # Text Highlighting prefs.dialogStyle.setCurrentData(3, 0) prefs.allowOpenDial.setChecked(False) - prefs.dialogLine.setText("–") - prefs.narratorBreak.setCurrentData("–", "") - prefs.narratorDialog.setCurrentData("–", "") - prefs.altDialogOpen.setText("<") - prefs.altDialogClose.setText(">") + prefs.dialogLine.setText(nwUnicode.U_EMDASH) + prefs.narratorBreak.setCurrentData(nwUnicode.U_EMDASH, "") + prefs.narratorDialog.setCurrentData(nwUnicode.U_EMDASH, "") + prefs.altDialogOpen.setText("%") # Symbol also tests for #2455 + prefs.altDialogClose.setText("%") # Symbol also tests for #2455 prefs.highlightEmph.setChecked(False) prefs.showMultiSpaces.setChecked(False) + prefs._insertDialogLineSymbol(nwUnicode.U_ENDASH) + assert prefs.dialogLine.text() == f"{nwUnicode.U_ENDASH} {nwUnicode.U_EMDASH}" + assert CONFIG.dialogStyle == 2 assert CONFIG.allowOpenDial is True assert CONFIG.dialogLine == "" @@ -404,11 +407,11 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths): # Text Highlighting assert CONFIG.dialogStyle == 3 assert CONFIG.allowOpenDial is False - assert CONFIG.dialogLine == "–" - assert CONFIG.narratorBreak == "–" - assert CONFIG.narratorDialog == "–" - assert CONFIG.altDialogOpen == "<" - assert CONFIG.altDialogClose == ">" + assert CONFIG.dialogLine == f"{nwUnicode.U_ENDASH}{nwUnicode.U_EMDASH}" + assert CONFIG.narratorBreak == nwUnicode.U_EMDASH + assert CONFIG.narratorDialog == nwUnicode.U_EMDASH + assert CONFIG.altDialogOpen == "%" + assert CONFIG.altDialogClose == "%" assert CONFIG.highlightEmph is False assert CONFIG.showMultiSpaces is False