Merge remote-tracking branch 'origin/unicode_fix' into unicode_fix

This commit is contained in:
Veronica Berglyd Olsen
2025-07-09 12:50:04 +02:00
7 changed files with 126 additions and 32 deletions
+30
View File
@@ -1,5 +1,35 @@
# novelWriter Changelog # 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] ## Version 2.7.2 [2025-06-24]
### Release Notes ### Release Notes
+3 -2
View File
@@ -699,11 +699,12 @@ class NWConfigParser(ConfigParser):
"""Common: Adapted Config Parser """Common: Adapted Config Parser
This is a subclass of the standard config parser that adds type safe 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: def __init__(self) -> None:
super().__init__() super().__init__(interpolation=None)
return return
def rdStr(self, section: str, option: str, default: str) -> str: def rdStr(self, section: str, option: str, default: str) -> str:
+3 -3
View File
@@ -441,9 +441,9 @@ class ProjectXMLReader:
for xEntry in xItem: for xEntry in xItem:
if xEntry.tag == "entry": if xEntry.tag == "entry":
key = xEntry.attrib.get("key", None) key = xEntry.attrib.get("key", None)
red = checkInt(xEntry.attrib.get("red", 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) # Deprecated 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) # Deprecated 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 color = xEntry.attrib.get("color") # Added in 1.5 R6
count = checkInt(xEntry.attrib.get("count", 0), 0) count = checkInt(xEntry.attrib.get("count", 0), 0)
shape = xEntry.attrib.get("shape", "") shape = xEntry.attrib.get("shape", "")
+2 -1
View File
@@ -192,7 +192,8 @@ class NWStatus:
def refreshIcons(self) -> None: def refreshIcons(self) -> None:
"""Refresh all icons.""" """Refresh all icons."""
for entry in self._store.values(): 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) entry.icon = NWStatus.createIcon(self._height, entry.color, entry.shape)
return return
+38 -11
View File
@@ -29,7 +29,7 @@ import logging
from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QAction, QCloseEvent, QKeyEvent, QKeySequence from PyQt6.QtGui import QAction, QCloseEvent, QKeyEvent, QKeySequence
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QCompleter, QDialogButtonBox, QFileDialog, QHBoxLayout, QLineEdit, QCompleter, QDialogButtonBox, QFileDialog, QHBoxLayout, QLineEdit, QMenu,
QPushButton, QVBoxLayout, QWidget QPushButton, QVBoxLayout, QWidget
) )
@@ -608,6 +608,7 @@ class GuiPreferences(NDialog):
self.sidebar.addButton(title, section) self.sidebar.addButton(title, section)
self.mainForm.addGroupLabel(title, section) self.mainForm.addGroupLabel(title, section)
# Dialogue Quotes
self.dialogStyle = NComboBox(self) self.dialogStyle = NComboBox(self)
self.dialogStyle.addItem(self.tr("None"), 0) self.dialogStyle.addItem(self.tr("None"), 0)
self.dialogStyle.addItem(self.tr("Single Quotes"), 1) self.dialogStyle.addItem(self.tr("Single Quotes"), 1)
@@ -619,6 +620,15 @@ class GuiPreferences(NDialog):
self.tr("Applies to the selected quote styles.") 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 = QLineEdit(self)
self.altDialogOpen.setMaxLength(4) self.altDialogOpen.setMaxLength(4)
self.altDialogOpen.setFixedWidth(boxFixed) self.altDialogOpen.setFixedWidth(boxFixed)
@@ -636,23 +646,30 @@ class GuiPreferences(NDialog):
self.tr("Custom highlighting of dialogue text.") self.tr("Custom highlighting of dialogue text.")
) )
self.allowOpenDial = NSwitch(self) # Dialogue Line
self.allowOpenDial.setChecked(CONFIG.allowOpenDial) self.mnLineSymbols = QMenu(self)
self.mainForm.addRow( for symbol in nwQuotes.ALLOWED:
self.tr("Allow open-ended dialogue"), self.allowOpenDial, label = trConst(nwQuotes.SYMBOLS.get(symbol, nwQuotes.DASHES.get(symbol, "None")))
self.tr("Highlight dialogue line with no closing quote.") self.mnLineSymbols.addAction(
) f"[ {symbol } ] {label}",
lambda symbol=symbol: self._insertDialogLineSymbol(symbol)
)
self.dialogLine = QLineEdit(self) self.dialogLine = QLineEdit(self)
self.dialogLine.setMaxLength(4) self.dialogLine.setMinimumWidth(100)
self.dialogLine.setFixedWidth(boxFixed)
self.dialogLine.setAlignment(QtAlignCenter) 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.mainForm.addRow(
self.tr("Dialogue line symbols"), self.dialogLine, 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.narratorBreak = NComboBox(self)
self.narratorDialog = NComboBox(self) self.narratorDialog = NComboBox(self)
for key, value in nwQuotes.DASHES.items(): for key, value in nwQuotes.DASHES.items():
@@ -672,6 +689,7 @@ class GuiPreferences(NDialog):
self.tr("Alternates dialogue highlighting within any paragraph.") self.tr("Alternates dialogue highlighting within any paragraph.")
) )
# Emphasis
self.highlightEmph = NSwitch(self) self.highlightEmph = NSwitch(self)
self.highlightEmph.setChecked(CONFIG.highlightEmph) self.highlightEmph.setChecked(CONFIG.highlightEmph)
self.mainForm.addRow( self.mainForm.addRow(
@@ -679,6 +697,7 @@ class GuiPreferences(NDialog):
self.tr("Applies to the document editor only.") self.tr("Applies to the document editor only.")
) )
# Additional Spaces
self.showMultiSpaces = NSwitch(self) self.showMultiSpaces = NSwitch(self)
self.showMultiSpaces.setChecked(CONFIG.showMultiSpaces) self.showMultiSpaces.setChecked(CONFIG.showMultiSpaces)
self.mainForm.addRow( self.mainForm.addRow(
@@ -914,6 +933,14 @@ class GuiPreferences(NDialog):
self.askBeforeBackup.setEnabled(state) self.askBeforeBackup.setEnabled(state)
return 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) @pyqtSlot(bool)
def _toggleAutoReplaceMain(self, state: bool) -> None: def _toggleAutoReplaceMain(self, state: bool) -> None:
"""Toggle switches controlled by the auto replace switch.""" """Toggle switches controlled by the auto replace switch."""
+37 -5
View File
@@ -324,23 +324,55 @@ def testCoreStatus_Entries(mockGUI, mockRnd):
@pytest.mark.core @pytest.mark.core
def testCoreStatus_RefreshIcons(mockGUIwithTheme, mockRnd): def testCoreStatus_RefreshIcons_Theme(mockGUIwithTheme, mockRnd):
"""Test refreshing the icons of the NWStatus class.""" """Test refreshing the icons with theme colours."""
nStatus = NWStatus(NWStatus.STATUS) nStatus = NWStatus(NWStatus.STATUS)
nStatus.add(None, "New", "default", "SQUARE", 0) nStatus.add(None, "New", "default", "SQUARE", 0)
nStatus.add(None, "Note", "red", "CIRCLE", 0) nStatus.add(None, "Note", "red", "CIRCLE", 0)
nStatus.add(None, "Draft", "yellow", "SQUARE", 0) nStatus.add(None, "Draft", "yellow", "SQUARE", 0)
nStatus.add(None, "Finished", "green", "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 # Refreshing the icons should generate new ones
nStatus.refreshIcons() 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 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 @pytest.mark.core
def testCoreStatus_Pack(mockGUIwithTheme, mockRnd): def testCoreStatus_Pack(mockGUIwithTheme, mockRnd):
+13 -10
View File
@@ -273,14 +273,17 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths):
# Text Highlighting # Text Highlighting
prefs.dialogStyle.setCurrentData(3, 0) prefs.dialogStyle.setCurrentData(3, 0)
prefs.allowOpenDial.setChecked(False) prefs.allowOpenDial.setChecked(False)
prefs.dialogLine.setText("") prefs.dialogLine.setText(nwUnicode.U_EMDASH)
prefs.narratorBreak.setCurrentData("", "") prefs.narratorBreak.setCurrentData(nwUnicode.U_EMDASH, "")
prefs.narratorDialog.setCurrentData("", "") prefs.narratorDialog.setCurrentData(nwUnicode.U_EMDASH, "")
prefs.altDialogOpen.setText("<") prefs.altDialogOpen.setText("%") # Symbol also tests for #2455
prefs.altDialogClose.setText(">") prefs.altDialogClose.setText("%") # Symbol also tests for #2455
prefs.highlightEmph.setChecked(False) prefs.highlightEmph.setChecked(False)
prefs.showMultiSpaces.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.dialogStyle == 2
assert CONFIG.allowOpenDial is True assert CONFIG.allowOpenDial is True
assert CONFIG.dialogLine == "" assert CONFIG.dialogLine == ""
@@ -404,11 +407,11 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths):
# Text Highlighting # Text Highlighting
assert CONFIG.dialogStyle == 3 assert CONFIG.dialogStyle == 3
assert CONFIG.allowOpenDial is False assert CONFIG.allowOpenDial is False
assert CONFIG.dialogLine == "" assert CONFIG.dialogLine == f"{nwUnicode.U_ENDASH}{nwUnicode.U_EMDASH}"
assert CONFIG.narratorBreak == "" assert CONFIG.narratorBreak == nwUnicode.U_EMDASH
assert CONFIG.narratorDialog == "" assert CONFIG.narratorDialog == nwUnicode.U_EMDASH
assert CONFIG.altDialogOpen == "<" assert CONFIG.altDialogOpen == "%"
assert CONFIG.altDialogClose == ">" assert CONFIG.altDialogClose == "%"
assert CONFIG.highlightEmph is False assert CONFIG.highlightEmph is False
assert CONFIG.showMultiSpaces is False assert CONFIG.showMultiSpaces is False