Merge release 2.7.5

This commit is contained in:
Veronica Berglyd Olsen
2025-09-14 19:37:15 +02:00
323 changed files with 10078 additions and 4645 deletions
+184 -34
View File
@@ -17,7 +17,7 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
from unittest.mock import MagicMock
@@ -28,7 +28,7 @@ from PyQt6.QtCore import QEvent, QMimeData, QPointF, Qt, QThreadPool, QUrl
from PyQt6.QtGui import (
QAction, QClipboard, QDesktopServices, QDragEnterEvent, QDragMoveEvent,
QDropEvent, QFont, QInputMethodEvent, QMouseEvent, QTextBlock, QTextCursor,
QTextOption
QTextDocument, QTextOption
)
from PyQt6.QtWidgets import QApplication, QMenu, QPlainTextEdit
@@ -37,7 +37,7 @@ from novelwriter.common import decodeMimeHandles
from novelwriter.constants import nwKeyWords, nwUnicode
from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.enum import nwDocAction, nwDocInsert, nwItemClass, nwItemLayout
from novelwriter.gui.doceditor import GuiDocEditor, _TagAction
from novelwriter.gui.doceditor import GuiDocEditor, TextAutoReplace, _TagAction
from novelwriter.gui.dochighlight import TextBlockData
from novelwriter.text.counting import standardCounter
from novelwriter.types import (
@@ -515,32 +515,36 @@ def testGuiEditor_SpellChecking(qtbot, monkeypatch, nwGUI, projPath, ipsumText,
# Run SpellCheck
# ==============
SHARED.project.data.setSpellCheck(True)
LORAX = "Lorax\U0001F03A"
cursor = docEditor.textCursor()
cursor.setPosition(16)
data = cursor.block().userData()
assert cursor.block().text().startswith("Lorem")
assert isinstance(data, TextBlockData)
data._spellErrors = [(0, 5)]
data._spellErrors = [(0, 5, "Lorem")]
# No known position
assert docEditor._qDocument.spellErrorAtPos(-1) == ("", -1, -1, [])
assert docEditor._qDocument.spellErrorAtPos(-1) == ("", -1, [])
# With Suggestion
with monkeypatch.context() as mp:
mp.setattr(SHARED.spelling, "suggestWords", lambda *a: ["Lorax"])
mp.setattr(SHARED.spelling, "suggestWords", lambda *a: [LORAX])
ctxMenu = getMenuForPos(docEditor, 16)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert "Spelling Suggestion(s)" in actions
assert f"{nwUnicode.U_ENDASH} Lorax" in actions
assert f"{nwUnicode.U_ENDASH} {LORAX}" in actions
ctxMenu.actions()[7].trigger()
QApplication.processEvents()
assert docEditor.getText() == text.replace("Lorem", "Lorax", 1)
assert docEditor.getText() == text.replace("Lorem", LORAX, 1)
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# Update Entry
data._spellErrors = [(0, 7, LORAX)]
# Without Suggestion
with monkeypatch.context() as mp:
mp.setattr(SHARED.spelling, "suggestWords", lambda *a: [])
@@ -549,7 +553,7 @@ def testGuiEditor_SpellChecking(qtbot, monkeypatch, nwGUI, projPath, ipsumText,
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert f"{nwUnicode.U_ENDASH} No Suggestions" in actions
assert docEditor.getText() == text.replace("Lorem", "Lorax", 1)
assert docEditor.getText() == text.replace("Lorem", LORAX, 1)
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
@@ -563,11 +567,11 @@ def testGuiEditor_SpellChecking(qtbot, monkeypatch, nwGUI, projPath, ipsumText,
assert "Ignore Word" in actions
assert "Add Word to Dictionary" in actions
assert "Lorax" not in SHARED.spelling._userDict
assert LORAX not in SHARED.spelling._userDict
ctxMenu.actions()[7].trigger() # Ignore
assert "Lorax" not in SHARED.spelling._userDict
assert LORAX not in SHARED.spelling._userDict
ctxMenu.actions()[8].trigger() # Add
assert "Lorax" in SHARED.spelling._userDict
assert LORAX in SHARED.spelling._userDict
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
@@ -675,9 +679,16 @@ def testGuiEditor_Actions(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
# Mark
docEditor.setCursorPosition(50)
assert docEditor.docAction(nwDocAction.MD_MARK) is True
assert docEditor.getText() == text.replace("consectetur", "==consectetur==")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
# Redo
assert docEditor.docAction(nwDocAction.REDO) is True
assert docEditor.getText() == text.replace("consectetur", "~~consectetur~~")
assert docEditor.getText() == text.replace("consectetur", "==consectetur==")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
@@ -2081,7 +2092,7 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum):
origText = docEditor.getText()
# Select the Word "est"
docEditor.setCursorPosition(645)
docEditor.setCursorPosition(663)
docEditor._makeSelection(QTextCursor.SelectionType.WordUnderCursor)
cursor = docEditor.textCursor()
assert cursor.selectedText() == "est"
@@ -2094,11 +2105,11 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum):
# Find next by enter key
monkeypatch.setattr(docSearch.searchBox, "hasFocus", lambda: True)
qtbot.keyClick(docSearch.searchBox, Qt.Key.Key_Return, delay=KEY_DELAY)
assert abs(docEditor.getCursorPosition() - 1299) < 3
assert abs(docEditor.getCursorPosition() - 1317) < 3
# Find next by button
qtbot.mouseClick(docSearch.searchButton, QtMouseLeft, delay=KEY_DELAY)
assert abs(docEditor.getCursorPosition() - 1513) < 3
assert abs(docEditor.getCursorPosition() - 1531) < 3
# Activate loop search
docSearch.toggleLoop.activate(QAction.ActionEvent.Trigger)
@@ -2107,7 +2118,7 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum):
# Find next by menu Search > Find Next
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 647) < 3
assert abs(docEditor.getCursorPosition() - 665) < 3
# Close search
docSearch.cancelSearch.activate(QAction.ActionEvent.Trigger)
@@ -2146,13 +2157,13 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum):
# Set valid RegEx
docSearch.setSearchText(r"\bSus")
qtbot.mouseClick(docSearch.searchButton, QtMouseLeft, delay=KEY_DELAY)
assert abs(docEditor.getCursorPosition() - 223) < 3
assert abs(docEditor.getCursorPosition() - 241) < 3
# Find next and then prev
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 324) < 3
assert abs(docEditor.getCursorPosition() - 342) < 3
nwGUI.mainMenu.aFindPrev.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 223) < 3
assert abs(docEditor.getCursorPosition() - 241) < 3
# Make RegEx case sensitive
docSearch.toggleCase.activate(QAction.ActionEvent.Trigger)
@@ -2161,11 +2172,11 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum):
# Find next/prev (one result)
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 626) < 3
assert abs(docEditor.getCursorPosition() - 644) < 3
nwGUI.mainMenu.aFindPrev.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 626) < 3
assert abs(docEditor.getCursorPosition() - 644) < 3
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 626) < 3
assert abs(docEditor.getCursorPosition() - 644) < 3
# Trigger replace
nwGUI.mainMenu.aReplace.activate(QAction.ActionEvent.Trigger)
@@ -2182,22 +2193,22 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum):
assert CONFIG.searchMatchCap is True
# Replace "Sus" with "Foo" via menu
docEditor.setCursorPosition(605)
docEditor.setCursorPosition(623)
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
nwGUI.mainMenu.aReplaceNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[623:634] == "Foopendisse"
assert docEditor.getText()[641:652] == "Foopendisse"
# Find next/prev to loop file
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 223) < 3
assert abs(docEditor.getCursorPosition() - 241) < 3
nwGUI.mainMenu.aFindPrev.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 1805) < 3
assert abs(docEditor.getCursorPosition() - 1823) < 3
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 223) < 3
assert abs(docEditor.getCursorPosition() - 241) < 3
# Replace "sus" with "foo" via replace button
qtbot.mouseClick(docSearch.replaceButton, QtMouseLeft, delay=KEY_DELAY)
assert docEditor.getText()[220:228] == "foocipit"
assert docEditor.getText()[238:246] == "foocipit"
# Revert last two replaces
assert docEditor.docAction(nwDocAction.UNDO)
@@ -2211,7 +2222,7 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum):
# Close search and select "est" again
docSearch.cancelSearch.activate(QAction.ActionEvent.Trigger)
docEditor.setCursorPosition(645)
docEditor.setCursorPosition(663)
docEditor._makeSelection(QTextCursor.SelectionType.WordUnderCursor)
cursor = docEditor.textCursor()
assert cursor.selectedText() == "est"
@@ -2228,9 +2239,9 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum):
# Only one match
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 647) < 3
assert abs(docEditor.getCursorPosition() - 665) < 3
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 647) < 3
assert abs(docEditor.getCursorPosition() - 665) < 3
# Enable next doc search
docSearch.toggleProject.activate(QAction.ActionEvent.Trigger)
@@ -2241,9 +2252,9 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum):
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.docHandle == "2426c6f0ca922" # Next document
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 620) < 3
assert abs(docEditor.getCursorPosition() - 651) < 3
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert abs(docEditor.getCursorPosition() - 1127) < 3
assert abs(docEditor.getCursorPosition() - 1157) < 3
# Next doc, no match
assert CONFIG.searchNextFile is True
@@ -2345,3 +2356,142 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum):
assert docEditor.textCursor().selectedText() == ""
# qtbot.stop()
@pytest.mark.gui
def testGuiEditor_TextAutoReplaceSymbols():
"""Test the editor auto-replace functionality."""
CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO
CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO
CONFIG.fmtDQuoteOpen = nwUnicode.U_LDQUO
CONFIG.fmtDQuoteClose = nwUnicode.U_RDQUO
CONFIG.doReplaceSQuote = True
CONFIG.doReplaceDQuote = True
CONFIG.doReplaceDash = True
CONFIG.doReplaceDots = True
ar = TextAutoReplace()
def prep(text: str) -> tuple[str, int]:
return text, len(text)
# Double Quote Open
assert ar._determine(*prep('"')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep('Stuff "')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep('>"')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep('>>"')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep('_"')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep(' _"')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep('\u00a0_"')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep('**"')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep(' **"')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep('\u00a0**"')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep('=="')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep(' =="')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep('\u00a0=="')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep('~~"')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep(' ~~"')) == (1, nwUnicode.U_LDQUO)
assert ar._determine(*prep('\u00a0~~"')) == (1, nwUnicode.U_LDQUO)
# Double Quote Close
assert ar._determine(*prep('Stuff"')) == (1, nwUnicode.U_RDQUO)
# Single Quote Open
assert ar._determine(*prep("'")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep("Stuff '")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep(">'")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep(">>'")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep("_'")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep(" _'")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep("\u00a0_'")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep("**'")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep(" **'")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep("\u00a0**'")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep("=='")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep(" =='")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep("\u00a0=='")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep("~~'")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep(" ~~'")) == (1, nwUnicode.U_LSQUO)
assert ar._determine(*prep("\u00a0~~'")) == (1, nwUnicode.U_LSQUO)
# Single Quote Close
assert ar._determine(*prep("Stuff'")) == (1, nwUnicode.U_RSQUO)
# Dashes
assert ar._determine(*prep("-")) == (0, "-")
assert ar._determine(*prep("--")) == (2, nwUnicode.U_ENDASH)
assert ar._determine(*prep("---")) == (3, nwUnicode.U_EMDASH)
assert ar._determine(*prep("----")) == (4, nwUnicode.U_HBAR)
assert ar._determine(*prep("\u2013-")) == (2, nwUnicode.U_EMDASH)
assert ar._determine(*prep("\u2014-")) == (2, nwUnicode.U_HBAR)
# Ellipsis
assert ar._determine(*prep(".")) == (0, ".")
assert ar._determine(*prep("..")) == (0, ".")
assert ar._determine(*prep("...")) == (3, nwUnicode.U_HELLIP)
# Block Typed Line Separator (#1150)
assert ar._determine(*prep("Text\u2028")) == (1, nwUnicode.U_PSEP)
@pytest.mark.gui
def testGuiEditor_TextAutoReplaceProcess():
"""Test the editor auto-replace functionality."""
CONFIG.fmtDQuoteOpen = nwUnicode.U_LAQUO
CONFIG.fmtDQuoteClose = nwUnicode.U_RAQUO
CONFIG.doReplaceDQuote = True
CONFIG.doReplaceDots = True
ar = TextAutoReplace()
doc = QTextDocument()
def prep(text: str) -> tuple[str, QTextCursor]:
doc.setPlainText(text)
cursor = QTextCursor(doc)
cursor.setPosition(len(text))
return text, cursor
# Nothing to Process
assert ar.process(*prep("")) is False
# Standard Auto-Replace
assert ar.process(*prep("Text ...")) is True
assert doc.toRawText() == "Text \u2026"
# Pad Before, Normal
CONFIG.fmtPadBefore = ":\u00bb"
CONFIG.fmtPadThin = False
ar.initSettings()
assert ar.process(*prep("Text:")) is True
assert doc.toRawText() == "Text\u00a0:"
assert ar.process(*prep("Text :")) is True # See #1061
assert doc.toRawText() == "Text\u00a0:"
assert ar.process(*prep('Text"')) is True
assert doc.toRawText() == "Text\u00a0»"
assert ar.process(*prep("@Synopsis:")) is False
assert doc.toRawText() == "@Synopsis:"
# Pad Before, Thin
CONFIG.fmtPadBefore = ":\u00bb"
CONFIG.fmtPadThin = True
ar.initSettings()
assert ar.process(*prep("Text:")) is True
assert doc.toRawText() == "Text\u202f:"
assert ar.process(*prep("Text :")) is True # See #1061
assert doc.toRawText() == "Text\u202f:"
# Pad After, Normal
CONFIG.fmtPadAfter = "\u00ab"
CONFIG.fmtPadThin = False
ar.initSettings()
assert ar.process(*prep('Text "')) is True
assert doc.toRawText() == "Text «\u00a0"
# Pad After, Thin
CONFIG.fmtPadAfter = "\u00ab"
CONFIG.fmtPadThin = True
ar.initSettings()
assert ar.process(*prep('Text "')) is True
assert doc.toRawText() == "Text «\u202f"
+632
View File
@@ -0,0 +1,632 @@
"""
novelWriter GUI Syntax Highlighter Tester
===========================================
This file is a part of novelWriter
Copyright (C) 2025 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 <https://www.gnu.org/licenses/>.
""" # noqa
from __future__ import annotations
import pytest
from PyQt6.QtGui import QTextCharFormat, QTextCursor, QTextDocument
from novelwriter import CONFIG, SHARED
from novelwriter.core.item import NWItem
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType, nwTheme
from novelwriter.gui.dochighlight import BLOCK_META, BLOCK_TITLE, GuiDocHighlighter, TextBlockData
from novelwriter.types import QtKeepAnchor
R_HANDLE = "3456789abcdef"
T_HANDLE = "0123456789abc"
@pytest.fixture()
def syntax(nwGUI):
"""Create a syntax object for use with testing."""
CONFIG.lightTheme = "default_light"
CONFIG.themeMode = nwTheme.LIGHT
CONFIG.dialogStyle = 3
CONFIG.fmtDQuoteOpen = "\u201c"
CONFIG.fmtDQuoteClose = "\u201d"
CONFIG.altDialogOpen = "::"
CONFIG.altDialogClose = "::"
CONFIG.showMultiSpaces = True
theme = SHARED.theme
theme.loadTheme(force=True)
assert theme._guiPalette.base().color().getRgb() == (0xfc, 0xfc, 0xfc, 0xff)
assert theme.syntaxTheme.text.getRgb() == (0x30, 0x30, 0x30, 0xff)
doc = QTextDocument()
syntax = GuiDocHighlighter(doc)
# Add a Mock Item
tRoot = NWItem(SHARED.project, R_HANDLE)
tRoot.setClass(nwItemClass.NOVEL)
tRoot.setType(nwItemType.ROOT)
tItem = NWItem(SHARED.project, T_HANDLE)
tItem.setParent(R_HANDLE)
tItem.setLayout(nwItemLayout.NOTE)
tItem.setClass(nwItemClass.NOVEL)
tItem.setType(nwItemType.FILE)
SHARED.project.tree.add(tRoot)
SHARED.project.tree.add(tItem)
yield syntax
def getFragments(
syntax: GuiDocHighlighter
) -> tuple[list[tuple[int, str]], list[QTextCharFormat]]:
"""Extract all syntax highlighter fragments from a document."""
pieces = []
formats = []
doc = syntax.document()
cursor = QTextCursor(doc)
assert doc is not None
for b in range(doc.blockCount()):
block = doc.findBlockByNumber(b)
first = block.position()
syntax.rehighlightBlock(block)
if layout := block.layout():
for fmt in layout.formats():
cursor.setPosition(first + fmt.start)
cursor.setPosition(first + fmt.start + fmt.length, QtKeepAnchor)
pieces.append((b, fmt.start, fmt.length, cursor.selectedText()))
formats.append(fmt.format)
return pieces, formats
def maxOrd(text: str) -> int:
"""Get the max character value of a string."""
return max(ord(c) for c in text)
@pytest.mark.gui
def testGuiDocHighlighter_Basic(syntax):
"""Test the basic functionality of the syntax highlighter."""
# Alternate Spell Check
assert syntax._spellCheck is False
syntax.setSpellCheck(True)
assert syntax._spellCheck is True
# Check Handle
assert syntax._tHandle is None
syntax.setHandle(T_HANDLE)
assert syntax._tHandle == T_HANDLE
assert syntax._isNovel is False
assert syntax._isInactive is False
tItem = SHARED.project.tree[T_HANDLE]
assert tItem is not None
tItem.setLayout(nwItemLayout.DOCUMENT)
tItem.setClass(nwItemClass.ARCHIVE)
syntax.setHandle(T_HANDLE)
assert syntax._tHandle == T_HANDLE
assert syntax._isNovel is True
assert syntax._isInactive is True
@pytest.mark.gui
def testGuiDocHighlighter_Keywords(syntax):
"""Test highlighting of keywords."""
theme = SHARED.theme
doc = syntax.document()
assert doc is not None
# Settings
syntax._tHandle = T_HANDLE
colKey = theme.syntaxTheme.key.getRgb()
colTag = theme.syntaxTheme.tag.getRgb()
colOpt = theme.syntaxTheme.opt.getRgb()
colErr = theme.syntaxTheme.error.getRgb()
# Ascii
doc.setPlainText(
"@tag: Bob | Robert\n"
"@char: Someone\n"
)
syntax.rehighlightByType(BLOCK_META)
assert maxOrd(doc.toPlainText()) <= 0x7f
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 4, "@tag"), (0, 6, 3, "Bob"), (0, 12, 6, "Robert"),
(1, 0, 5, "@char"), (1, 7, 7, "Someone"),
]
assert formats[0].foreground().color().getRgb() == colKey
assert formats[1].foreground().color().getRgb() == colTag
assert formats[2].foreground().color().getRgb() == colOpt
assert formats[3].foreground().color().getRgb() == colKey
assert formats[4].underlineColor().getRgb() == colErr
# # Unicode <= 0xFFFF
doc.setPlainText(
"@tag: Zoë | Zoë Smith\n"
"@char: Олексій\n"
)
syntax.rehighlightByType(BLOCK_META)
assert 0x7f < maxOrd(doc.toPlainText()) <= 0xffff
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 4, "@tag"), (0, 6, 3, "Zoë"), (0, 12, 9, "Zoë Smith"),
(1, 0, 5, "@char"), (1, 7, 7, "Олексій"),
]
assert formats[0].foreground().color().getRgb() == colKey
assert formats[1].foreground().color().getRgb() == colTag
assert formats[2].foreground().color().getRgb() == colOpt
assert formats[3].foreground().color().getRgb() == colKey
assert formats[4].underlineColor().getRgb() == colErr
# # Unicode > 0xFFFF
doc.setPlainText(
"@tag: 😄 | Smiley 😄\n"
"@char: 😎😎😎\n"
)
syntax.rehighlightByType(BLOCK_META)
assert 0xffff < maxOrd(doc.toPlainText()) <= 0xffffffff
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 4, "@tag"), (0, 6, 2, "😄"), (0, 11, 9, "Smiley 😄"),
(1, 0, 5, "@char"), (1, 7, 6, "😎😎😎"),
]
assert formats[0].foreground().color().getRgb() == colKey
assert formats[1].foreground().color().getRgb() == colTag
assert formats[2].foreground().color().getRgb() == colOpt
assert formats[3].foreground().color().getRgb() == colKey
assert formats[4].underlineColor().getRgb() == colErr
@pytest.mark.gui
def testGuiDocHighlighter_Titles(syntax):
"""Test highlighting of titles."""
theme = SHARED.theme
doc = syntax.document()
assert doc is not None
# Settings
syntax._tHandle = T_HANDLE
colHeadMark = theme.syntaxTheme.headH.getRgb()
colHeadText = theme.syntaxTheme.head.getRgb()
# Ascii
doc.setPlainText(
"# Heading 1\n\n"
"## Heading 2\n\n"
"### Heading 3\n\n"
"#### Heading 4\n\n"
"#! Heading A1\n\n"
"##! Heading A2\n\n"
"###! Heading A3\n\n"
)
syntax.rehighlightByType(BLOCK_TITLE)
assert maxOrd(doc.toPlainText()) <= 0x7f
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 1, "#"), (0, 1, 10, " Heading 1"),
(2, 0, 2, "##"), (2, 2, 10, " Heading 2"),
(4, 0, 3, "###"), (4, 3, 10, " Heading 3"),
(6, 0, 4, "####"), (6, 4, 10, " Heading 4"),
(8, 0, 2, "#!"), (8, 2, 11, " Heading A1"),
(10, 0, 3, "##!"), (10, 3, 11, " Heading A2"),
(12, 0, 4, "###!"), (12, 4, 11, " Heading A3"),
]
for i in range(0, len(formats), 2):
assert formats[i].foreground().color().getRgb() == colHeadMark
assert formats[i+1].foreground().color().getRgb() == colHeadText
# Unicode <= 0xFFFF
doc.setPlainText(
"# Ȟǣđ 1\n\n"
"## Ȟǣđ 2\n\n"
"### Ȟǣđ 3\n\n"
"#### Ȟǣđ 4\n\n"
"#! Ȟǣđ A1\n\n"
"##! Ȟǣđ A2\n\n"
"###! Ȟǣđ A3\n\n"
)
syntax.rehighlightByType(BLOCK_TITLE)
assert 0x7f < maxOrd(doc.toPlainText()) <= 0xffff
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 1, "#"), (0, 1, 6, " Ȟǣđ 1"),
(2, 0, 2, "##"), (2, 2, 6, " Ȟǣđ 2"),
(4, 0, 3, "###"), (4, 3, 6, " Ȟǣđ 3"),
(6, 0, 4, "####"), (6, 4, 6, " Ȟǣđ 4"),
(8, 0, 2, "#!"), (8, 2, 7, " Ȟǣđ A1"),
(10, 0, 3, "##!"), (10, 3, 7, " Ȟǣđ A2"),
(12, 0, 4, "###!"), (12, 4, 7, " Ȟǣđ A3"),
]
for i in range(0, len(formats), 2):
assert formats[i].foreground().color().getRgb() == colHeadMark
assert formats[i+1].foreground().color().getRgb() == colHeadText
# Unicode > 0xFFFF
doc.setPlainText(
"# 😇😎 1\n\n"
"## 😇😎 2\n\n"
"### 😇😎 3\n\n"
"#### 😇😎 4\n\n"
"#! 😇😎 A1\n\n"
"##! 😇😎 A2\n\n"
"###! 😇😎 A3\n\n"
)
syntax.rehighlightByType(BLOCK_TITLE)
assert 0xffff < maxOrd(doc.toPlainText()) <= 0xffffffff
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 1, "#"), (0, 1, 7, " 😇😎 1"),
(2, 0, 2, "##"), (2, 2, 7, " 😇😎 2"),
(4, 0, 3, "###"), (4, 3, 7, " 😇😎 3"),
(6, 0, 4, "####"), (6, 4, 7, " 😇😎 4"),
(8, 0, 2, "#!"), (8, 2, 8, " 😇😎 A1"),
(10, 0, 3, "##!"), (10, 3, 8, " 😇😎 A2"),
(12, 0, 4, "###!"), (12, 4, 8, " 😇😎 A3"),
]
for i in range(0, len(formats), 2):
assert formats[i].foreground().color().getRgb() == colHeadMark
assert formats[i+1].foreground().color().getRgb() == colHeadText
@pytest.mark.gui
def testGuiDocHighlighter_Comments(syntax):
"""Test highlighting of comments."""
theme = SHARED.theme
doc = syntax.document()
assert doc is not None
# Settings
syntax._tHandle = T_HANDLE
colHidden = theme.syntaxTheme.hidden.getRgb()
colMod = theme.syntaxTheme.mod.getRgb()
colValue = theme.syntaxTheme.val.getRgb()
colNote = theme.syntaxTheme.note.getRgb()
# Ascii
doc.setPlainText(
"% Plain\n"
"%~ Ignored\n"
"%Synopsis: Synopsis\n"
"%Note.Stuff: Note\n"
)
syntax.rehighlight()
assert maxOrd(doc.toPlainText()) <= 0x7f
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 7, "% Plain"),
(1, 0, 10, "%~ Ignored"),
(2, 0, 10, "%Synopsis:"), (2, 10, 9, " Synopsis"),
(3, 0, 6, "%Note."), (3, 6, 6, "Stuff:"), (3, 12, 5, " Note"),
]
assert formats[0].foreground().color().getRgb() == colHidden
assert formats[1].foreground().color().getRgb() == colHidden
assert formats[1].fontStrikeOut() is True
assert formats[2].foreground().color().getRgb() == colMod
assert formats[3].foreground().color().getRgb() == colNote
assert formats[4].foreground().color().getRgb() == colMod
assert formats[5].foreground().color().getRgb() == colValue
assert formats[6].foreground().color().getRgb() == colNote
# Unicode <= 0xFFFF
doc.setPlainText(
"% Рівнина\n"
"%~ Ігноровано\n"
"%Synopsis: Синопсис\n"
"%Note.Stuff: Примітка\n"
)
syntax.rehighlight()
assert 0x7f < maxOrd(doc.toPlainText()) <= 0xffff
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 9, "% Рівнина"),
(1, 0, 13, "%~ Ігноровано"),
(2, 0, 10, "%Synopsis:"), (2, 10, 9, " Синопсис"),
(3, 0, 6, "%Note."), (3, 6, 6, "Stuff:"), (3, 12, 9, " Примітка"),
]
assert formats[0].foreground().color().getRgb() == colHidden
assert formats[1].foreground().color().getRgb() == colHidden
assert formats[1].fontStrikeOut() is True
assert formats[2].foreground().color().getRgb() == colMod
assert formats[3].foreground().color().getRgb() == colNote
assert formats[4].foreground().color().getRgb() == colMod
assert formats[5].foreground().color().getRgb() == colValue
assert formats[6].foreground().color().getRgb() == colNote
# Unicode > 0xFFFF
doc.setPlainText(
"% 😎😎\n"
"%~ 🙈🙈🙈\n"
"%Synopsis: 😍😍😍😍\n"
"%Note.Stuff: 😡😡😡😡😡\n"
)
syntax.rehighlight()
assert 0xffff < maxOrd(doc.toPlainText()) <= 0xffffffff
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 6, "% 😎😎"),
(1, 0, 9, "%~ 🙈🙈🙈"),
(2, 0, 10, "%Synopsis:"), (2, 10, 9, " 😍😍😍😍"),
(3, 0, 6, "%Note."), (3, 6, 6, "Stuff:"), (3, 12, 11, " 😡😡😡😡😡"),
]
assert formats[0].foreground().color().getRgb() == colHidden
assert formats[1].foreground().color().getRgb() == colHidden
assert formats[1].fontStrikeOut() is True
assert formats[2].foreground().color().getRgb() == colMod
assert formats[3].foreground().color().getRgb() == colNote
assert formats[4].foreground().color().getRgb() == colMod
assert formats[5].foreground().color().getRgb() == colValue
assert formats[6].foreground().color().getRgb() == colNote
@pytest.mark.gui
def testGuiDocHighlighter_Special(syntax):
"""Test highlighting of special commands."""
theme = SHARED.theme
doc = syntax.document()
assert doc is not None
# Settings
syntax._tHandle = T_HANDLE
colErr = theme.syntaxTheme.error.getRgb()
colCode = theme.syntaxTheme.code.getRgb()
colValue = theme.syntaxTheme.val.getRgb()
# Ascii
doc.setPlainText(
"[NewPage]\n"
"[New Page]\n"
"[VSpace]\n"
"[VSpace:123]\n"
"[VSpace:Meh]\n"
)
syntax.rehighlight()
assert maxOrd(doc.toPlainText()) <= 0x7f
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 9, "[NewPage]"),
(1, 0, 10, "[New Page]"),
(2, 0, 8, "[VSpace]"),
(3, 0, 8, "[VSpace:"), (3, 8, 3, "123"), (3, 11, 1, "]"),
(4, 0, 8, "[VSpace:"), (4, 8, 3, "Meh"), (4, 11, 1, "]"),
]
assert formats[0].foreground().color().getRgb() == colCode
assert formats[1].foreground().color().getRgb() == colCode
assert formats[2].foreground().color().getRgb() == colCode
assert formats[3].foreground().color().getRgb() == colCode
assert formats[4].foreground().color().getRgb() == colValue
assert formats[5].foreground().color().getRgb() == colCode
assert formats[6].foreground().color().getRgb() == colCode
assert formats[7].underlineColor().getRgb() == colErr
assert formats[8].foreground().color().getRgb() == colCode
# Unicode <= 0xFFFF
doc.setPlainText(
"[NewPage]\n"
"[New Page]\n"
"[VSpace]\n"
"[VSpace:123]\n"
"[VSpace:⅘]\n"
)
syntax.rehighlight()
assert 0x7f < maxOrd(doc.toPlainText()) <= 0xffff
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 9, "[NewPage]"),
(1, 0, 10, "[New Page]"),
(2, 0, 8, "[VSpace]"),
(3, 0, 8, "[VSpace:"), (3, 8, 3, "123"), (3, 11, 1, "]"),
(4, 0, 8, "[VSpace:"), (4, 8, 1, ""), (4, 9, 1, "]"),
]
assert formats[0].foreground().color().getRgb() == colCode
assert formats[1].foreground().color().getRgb() == colCode
assert formats[2].foreground().color().getRgb() == colCode
assert formats[3].foreground().color().getRgb() == colCode
assert formats[4].foreground().color().getRgb() == colValue
assert formats[5].foreground().color().getRgb() == colCode
assert formats[6].foreground().color().getRgb() == colCode
assert formats[7].underlineColor().getRgb() == colErr
assert formats[8].foreground().color().getRgb() == colCode
# Unicode > 0xFFFF
doc.setPlainText(
"[NewPage]\n"
"[New Page]\n"
"[VSpace]\n"
"[VSpace:123]\n"
"[VSpace:🙈🙈]\n"
)
syntax.rehighlight()
assert 0xffff < maxOrd(doc.toPlainText()) <= 0xffffffff
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 9, "[NewPage]"),
(1, 0, 10, "[New Page]"),
(2, 0, 8, "[VSpace]"),
(3, 0, 8, "[VSpace:"), (3, 8, 3, "123"), (3, 11, 1, "]"),
(4, 0, 8, "[VSpace:"), (4, 8, 4, "🙈🙈"), (4, 12, 1, "]"),
]
assert formats[0].foreground().color().getRgb() == colCode
assert formats[1].foreground().color().getRgb() == colCode
assert formats[2].foreground().color().getRgb() == colCode
assert formats[3].foreground().color().getRgb() == colCode
assert formats[4].foreground().color().getRgb() == colValue
assert formats[5].foreground().color().getRgb() == colCode
assert formats[6].foreground().color().getRgb() == colCode
assert formats[7].underlineColor().getRgb() == colErr
assert formats[8].foreground().color().getRgb() == colCode
@pytest.mark.gui
def testGuiDocHighlighter_Text(monkeypatch, syntax):
"""Test highlighting of text."""
theme = SHARED.theme
doc = syntax.document()
assert doc is not None
# Settings
syntax._tHandle = T_HANDLE
syntax._isNovel = True
syntax.setSpellCheck(True)
monkeypatch.setattr(SHARED.spelling, "checkWord", lambda *a: False)
colHidden = theme.syntaxTheme.hidden.getRgb()
colEmph = theme.syntaxTheme.emph.getRgb()
colLink = theme.syntaxTheme.link.getRgb()
colSpell = theme.syntaxTheme.spell.getRgb()
colCode = theme.syntaxTheme.code.getRgb()
colDialogue = theme.syntaxTheme.dialN.getRgb()
colAltDialogue = theme.syntaxTheme.dialA.getRgb()
# Ascii
doc.setPlainText(
"Text **bold** text _italic_ text ~~strike~~ text [b]bold[/b], http://example.com\n\n"
)
syntax.rehighlight()
assert maxOrd(doc.toPlainText()) <= 0x7f
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 4, "Text"),
(0, 5, 2, "**"), (0, 7, 4, "bold"), (0, 11, 2, "**"),
(0, 14, 4, "text"),
(0, 19, 1, "_"), (0, 20, 6, "italic"), (0, 26, 1, "_"),
(0, 28, 4, "text"),
(0, 33, 2, "~~"), (0, 35, 6, "strike"), (0, 41, 2, "~~"),
(0, 44, 4, "text"),
(0, 49, 3, "[b]"), (0, 52, 4, "bold"), (0, 56, 4, "[/b]"),
(0, 62, 18, "http://example.com"),
]
assert formats[0].underlineColor().getRgb() == colSpell # Text
assert formats[1].foreground().color().getRgb() == colHidden # **
assert formats[2].foreground().color().getRgb() == colEmph # bold
assert formats[2].underlineColor().getRgb() == colSpell
assert formats[3].foreground().color().getRgb() == colHidden # **
assert formats[4].underlineColor().getRgb() == colSpell # text
assert formats[5].foreground().color().getRgb() == colHidden # _
assert formats[6].foreground().color().getRgb() == colEmph # italic
assert formats[6].underlineColor().getRgb() == colSpell
assert formats[7].foreground().color().getRgb() == colHidden # _
assert formats[8].underlineColor().getRgb() == colSpell # text
assert formats[9].foreground().color().getRgb() == colHidden # ~~
assert formats[10].foreground().color().getRgb() == colHidden # strike
assert formats[10].fontStrikeOut() is True
assert formats[10].underlineColor().getRgb() == colSpell
assert formats[11].foreground().color().getRgb() == colHidden # ~~
assert formats[12].underlineColor().getRgb() == colSpell # text
assert formats[13].foreground().color().getRgb() == colCode # [b]
assert formats[14].underlineColor().getRgb() == colSpell # bold
assert formats[15].foreground().color().getRgb() == colCode # [/b]
assert formats[16].foreground().color().getRgb() == colLink # http://example.com
# Spell Check
data = doc.findBlockByNumber(0).userData()
assert isinstance(data, TextBlockData)
assert data.metaData == [(62, 80, "http://example.com", "url")]
assert data.spellErrors == [
(0, 4, "Text"), (7, 11, "bold"),
(14, 18, "text"), (20, 26, "italic"),
(28, 32, "text"), (35, 41, "strike"),
(44, 48, "text"), (52, 56, "bold"),
]
# Unicode <= 0xFFFF
doc.setPlainText(
"\u201cDialogue,\u201d and then ::dialogue::, http://example.com\n\n"
)
syntax.rehighlight()
assert 0x7f < maxOrd(doc.toPlainText()) <= 0xffff
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 1, "\u201c"), (0, 1, 8, "Dialogue"), (0, 9, 2, ",\u201d"),
(0, 12, 3, "and"), (0, 16, 4, "then"),
(0, 21, 2, "::"), (0, 23, 8, "dialogue"), (0, 31, 2, "::"),
(0, 35, 18, "http://example.com"),
]
assert formats[0].foreground().color().getRgb() == colDialogue # Quote
assert formats[1].foreground().color().getRgb() == colDialogue # Dialogue
assert formats[1].underlineColor().getRgb() == colSpell
assert formats[2].foreground().color().getRgb() == colDialogue # Quote
assert formats[3].underlineColor().getRgb() == colSpell # and
assert formats[4].underlineColor().getRgb() == colSpell # then
assert formats[5].foreground().color().getRgb() == colAltDialogue # ::
assert formats[6].foreground().color().getRgb() == colAltDialogue # dialogue
assert formats[6].underlineColor().getRgb() == colSpell
assert formats[7].foreground().color().getRgb() == colAltDialogue # ::
assert formats[8].foreground().color().getRgb() == colLink # http://example.com
# Spell Check
data = doc.findBlockByNumber(0).userData()
assert isinstance(data, TextBlockData)
assert data.metaData == [(35, 53, "http://example.com", "url")]
assert data.spellErrors == [
(1, 9, "Dialogue"), (12, 15, "and"),
(16, 20, "then"), (23, 31, "dialogue"),
]
# Unicode > 0xFFFF
doc.setPlainText(
"\u201c😁 Grinning 😁,\u201d and then ::🙊 shush 🙊::, http://example.com\n\n"
)
syntax.rehighlight()
assert 0xffff < maxOrd(doc.toPlainText()) <= 0xffffffff
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 0, 4, "\u201c😁 "), (0, 4, 8, "Grinning"), (0, 12, 5, " 😁,\u201d"),
(0, 18, 3, "and"), (0, 22, 4, "then"),
(0, 27, 5, "::🙊 "), (0, 32, 5, "shush"), (0, 37, 5, " 🙊::"),
(0, 44, 18, "http://example.com"),
]
assert formats[0].foreground().color().getRgb() == colDialogue # Quote😁
assert formats[1].foreground().color().getRgb() == colDialogue # Grinning
assert formats[1].underlineColor().getRgb() == colSpell
assert formats[2].foreground().color().getRgb() == colDialogue # 😁Quote
assert formats[3].underlineColor().getRgb() == colSpell # and
assert formats[4].underlineColor().getRgb() == colSpell # then
assert formats[5].foreground().color().getRgb() == colAltDialogue # ::🙊
assert formats[6].foreground().color().getRgb() == colAltDialogue # shush
assert formats[6].underlineColor().getRgb() == colSpell
assert formats[7].foreground().color().getRgb() == colAltDialogue # 🙊::
assert formats[8].foreground().color().getRgb() == colLink # http://example.com
# Spell Check
data = doc.findBlockByNumber(0).userData()
assert isinstance(data, TextBlockData)
assert data.metaData == [(40, 58, "http://example.com", "url")]
assert data.spellErrors == [
(4, 12, "Grinning"), (18, 21, "and"),
(22, 26, "then"), (32, 37, "shush"),
]
+9 -3
View File
@@ -17,7 +17,7 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
from unittest.mock import MagicMock
@@ -223,17 +223,23 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
CONFIG.showFullPath = True
# Document footer show/hide synopsis
assert nwGUI.viewDocument("f96ec11c6a3da") is True
nwGUI.viewDocument("f96ec11c6a3da")
assert len(docViewer.toPlainText()) == 4314
docViewer.docFooter._doToggleSynopsis(False)
assert len(docViewer.toPlainText()) == 4098
# Document footer show/hide comments
assert nwGUI.viewDocument("846352075de7d") is True
nwGUI.viewDocument("846352075de7d")
assert len(docViewer.toPlainText()) == 683
docViewer.docFooter._doToggleComments(False)
assert len(docViewer.toPlainText()) == 634
# Document footer show/hide notes
nwGUI.viewDocument("88d59a277361b")
assert len(docViewer.toPlainText()) == 900
docViewer.docFooter._doToggleNotes(False)
assert len(docViewer.toPlainText()) == 871
# Crash the HTML rendering
with monkeypatch.context() as mp:
mp.setattr(ToQTextDocument, "doConvert", causeException)
+2 -2
View File
@@ -17,7 +17,7 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import pytest
@@ -224,7 +224,7 @@ def testGuiViewerPanel_Tags(qtbot, monkeypatch, caplog, nwGUI, projPath, mockRnd
# Update Labels
assert charTab.topLevelItem(0).text(charTab.C_IMPORT) == "New"
SHARED.project.data.itemImport.add(C.iNew, "Stuff", (100, 100, 100), "SQUARE", 0)
SHARED.project.data.itemImport.add(C.iNew, "Stuff", "#646464", "SQUARE", 0)
viewPanel.updateStatusLabels("i")
assert charTab.topLevelItem(0).text(charTab.C_IMPORT) == "Stuff"
+25 -10
View File
@@ -17,13 +17,14 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import shutil
from pathlib import Path
from shutil import copyfile
from unittest.mock import Mock
import pytest
@@ -32,9 +33,10 @@ from PyQt6.QtGui import QPalette
from PyQt6.QtWidgets import QInputDialog, QMessageBox
from novelwriter import CONFIG, SHARED
from novelwriter.config import DEF_GUI_DARK, DEF_GUI_LIGHT
from novelwriter.constants import nwFiles
from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.enum import nwDocAction, nwDocMode, nwFocus, nwItemType, nwView
from novelwriter.enum import nwDocAction, nwDocMode, nwFocus, nwItemType, nwTheme, nwView
from novelwriter.gui.doceditor import GuiDocEditor
from novelwriter.gui.noveltree import GuiNovelView
from novelwriter.gui.outline import GuiOutlineView
@@ -179,21 +181,35 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
@pytest.mark.gui
def testGuiMain_UpdateTheme(qtbot, nwGUI):
"""Test updating the theme in the GUI."""
mainTheme = SHARED.theme
CONFIG.guiTheme = "default_dark"
CONFIG.guiSyntax = "default_dark"
mainTheme.loadTheme()
mainTheme.loadSyntax()
theme = SHARED.theme
CONFIG.themeMode = nwTheme.DARK
CONFIG.darkTheme = DEF_GUI_DARK
CONFIG.lightTheme = DEF_GUI_LIGHT
theme.loadTheme()
assert theme.isDarkTheme is True
nwGUI._processConfigChanges(False, True, False, False)
nwGUI._processConfigChanges(True, True, True, True)
# Check editor syntax
syntax = SHARED.theme.syntaxTheme
assert nwGUI.docEditor.palette().color(QPalette.ColorRole.Window) == syntax.back
assert nwGUI.docEditor.docHeader.palette().color(QPalette.ColorRole.Window) == syntax.back
assert nwGUI.docViewer.palette().color(QPalette.ColorRole.Window) == syntax.back
assert nwGUI.docViewer.docHeader.palette().color(QPalette.ColorRole.Window) == syntax.back
# Update by check
CONFIG.themeMode = nwTheme.LIGHT
nwGUI.checkThemeUpdate()
assert theme.isDarkTheme is False
# Through change event
event = Mock()
event.type.return_value = 210
CONFIG.themeMode = nwTheme.DARK
nwGUI.changeEvent(event)
assert theme.isDarkTheme is True
# qtbot.stop()
@@ -251,8 +267,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
# Change some settings
CONFIG.hideHScroll = True
CONFIG.hideVScroll = True
CONFIG.autoScrollPos = 80
CONFIG.autoScroll = True
CONFIG.autoScroll = False
# Add a Character File
nwGUI._changeView(nwView.PROJECT)
+1 -1
View File
@@ -17,7 +17,7 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import sys
+41 -40
View File
@@ -17,7 +17,7 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
from unittest.mock import MagicMock
@@ -68,154 +68,155 @@ def testGuiMainMenu_EditFormat(qtbot, monkeypatch, nwGUI, prjLipsum):
assert docEditor.docAction(nwDocAction.COPY) is False
assert nwGUI.openProject(prjLipsum) is True
x = 72
# Split By Chapter
assert nwGUI.openDocument("4c4f28287af27") is True
docEditor.setCursorPosition(57)
cleanText = docEditor.getText()[54:101]
docEditor.setCursorPosition(x+3)
cleanText = docEditor.getText()[x:x+47]
# Bold
mainMenu.aFmtBold.activate(QAction.ActionEvent.Trigger)
fmtStr = "**Pellentesque** nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:105] == fmtStr
assert docEditor.getText()[x:x+51] == fmtStr
mainMenu.aFmtBold.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:101] == cleanText
assert docEditor.getText()[x:x+47] == cleanText
# Italic
mainMenu.aFmtItalic.activate(QAction.ActionEvent.Trigger)
fmtStr = "_Pellentesque_ nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:103] == fmtStr
assert docEditor.getText()[x:x+49] == fmtStr
mainMenu.aFmtItalic.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:101] == cleanText
assert docEditor.getText()[x:x+47] == cleanText
# Strikethrough
mainMenu.aFmtStrike.activate(QAction.ActionEvent.Trigger)
fmtStr = "~~Pellentesque~~ nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:105] == fmtStr
assert docEditor.getText()[x:x+51] == fmtStr
mainMenu.aFmtStrike.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:101] == cleanText
assert docEditor.getText()[x:x+47] == cleanText
# Should get us back to plain
mainMenu.aFmtBold.activate(QAction.ActionEvent.Trigger)
mainMenu.aFmtItalic.activate(QAction.ActionEvent.Trigger)
mainMenu.aFmtItalic.activate(QAction.ActionEvent.Trigger)
mainMenu.aFmtBold.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:101] == cleanText
assert docEditor.getText()[x:x+47] == cleanText
# Double Quotes
mainMenu.aFmtDQuote.activate(QAction.ActionEvent.Trigger)
fmtStr = "“Pellentesque” nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:103] == fmtStr
assert docEditor.getText()[x:x+49] == fmtStr
mainMenu.aEditUndo.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:101] == cleanText
assert docEditor.getText()[x:x+47] == cleanText
# Single Quotes
mainMenu.aFmtSQuote.activate(QAction.ActionEvent.Trigger)
fmtStr = "Pellentesque nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:103] == fmtStr
assert docEditor.getText()[x:x+49] == fmtStr
mainMenu.aEditUndo.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:101] == cleanText
assert docEditor.getText()[x:x+47] == cleanText
# Block Formats
# =============
# cSpell:ignore Pellentesque erat nulla posuere commodo
docEditor.setCursorPosition(57)
docEditor.setCursorPosition(x+3)
# Header 1
mainMenu.aFmtHead1.activate(QAction.ActionEvent.Trigger)
fmtStr = "# Pellentesque nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:103] == fmtStr
assert docEditor.getText()[x:x+49] == fmtStr
# Header 2
mainMenu.aFmtHead2.activate(QAction.ActionEvent.Trigger)
fmtStr = "## Pellentesque nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:104] == fmtStr
assert docEditor.getText()[x:x+50] == fmtStr
# Header 3
mainMenu.aFmtHead3.activate(QAction.ActionEvent.Trigger)
fmtStr = "### Pellentesque nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:105] == fmtStr
assert docEditor.getText()[x:x+51] == fmtStr
# Header 4
mainMenu.aFmtHead4.activate(QAction.ActionEvent.Trigger)
fmtStr = "#### Pellentesque nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:106] == fmtStr
assert docEditor.getText()[x:x+52] == fmtStr
# Title Format
mainMenu.aFmtTitle.activate(QAction.ActionEvent.Trigger)
fmtStr = "#! Pellentesque nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:104] == fmtStr
assert docEditor.getText()[x:x+50] == fmtStr
# Unnumbered Chapter
mainMenu.aFmtUnNum.activate(QAction.ActionEvent.Trigger)
fmtStr = "##! Pellentesque nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:105] == fmtStr
assert docEditor.getText()[x:x+51] == fmtStr
# Hard Scene
mainMenu.aFmtHardSc.activate(QAction.ActionEvent.Trigger)
fmtStr = "###! Pellentesque nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:106] == fmtStr
assert docEditor.getText()[x:x+52] == fmtStr
# Clear Format
mainMenu.aFmtNoFormat.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:101] == cleanText
assert docEditor.getText()[x:x+47] == cleanText
# Comment On
mainMenu.aFmtComment.activate(QAction.ActionEvent.Trigger)
fmtStr = "% Pellentesque nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:103] == fmtStr
assert docEditor.getText()[x:x+49] == fmtStr
# Comment Off
mainMenu.aFmtComment.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:101] == cleanText
assert docEditor.getText()[x:x+47] == cleanText
# Check comment with no space before text
docEditor.setCursorPosition(54)
docEditor.setCursorPosition(x)
docEditor.insertText("%")
fmtStr = "%Pellentesque nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:102] == fmtStr
assert docEditor.getText()[x:x+48] == fmtStr
mainMenu.aFmtNoFormat.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:101] == cleanText
assert docEditor.getText()[x:x+47] == cleanText
# Undo/Redo
mainMenu.aEditUndo.activate(QAction.ActionEvent.Trigger)
fmtStr = "%Pellentesque nec erat ut nulla posuere commodo."
assert docEditor.getText()[54:102] == fmtStr
assert docEditor.getText()[x:x+48] == fmtStr
mainMenu.aEditRedo.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:101] == cleanText
assert docEditor.getText()[x:x+47] == cleanText
# Cut, Copy and Paste
docEditor.setCursorPosition(54)
docEditor.setCursorPosition(x)
docEditor._makeSelection(QTextCursor.SelectionType.WordUnderCursor)
mainMenu.aEditCut.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:104] == (
assert docEditor.getText()[x:x+50] == (
" nec erat ut nulla posuere commodo. Curabitur nisi"
)
mainMenu.aEditPaste.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:104] == (
assert docEditor.getText()[x:x+50] == (
"Pellentesque nec erat ut nulla posuere commodo. Cu"
)
docEditor.setCursorPosition(54)
docEditor.setCursorPosition(x)
docEditor._makeSelection(QTextCursor.SelectionType.WordUnderCursor)
mainMenu.aEditCopy.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:104] == (
assert docEditor.getText()[x:x+50] == (
"Pellentesque nec erat ut nulla posuere commodo. Cu"
)
docEditor.setCursorPosition(54)
docEditor.setCursorPosition(x)
mainMenu.aEditPaste.activate(QAction.ActionEvent.Trigger)
assert docEditor.getText()[54:104] == (
assert docEditor.getText()[x:x+50] == (
"PellentesquePellentesque nec erat ut nulla posuere"
)
mainMenu.aEditUndo.activate(QAction.ActionEvent.Trigger)
# Select Paragraph/All
docEditor.setCursorPosition(57)
docEditor.setCursorPosition(x+3)
mainMenu.aSelectPar.activate(QAction.ActionEvent.Trigger)
cursor = docEditor.textCursor()
assert cursor.selectedText() == (
@@ -228,10 +229,10 @@ def testGuiMainMenu_EditFormat(qtbot, monkeypatch, nwGUI, prjLipsum):
"nunc lacus, imperdiet nec posuere ac, interdum non lectus."
)
docEditor.setCursorPosition(57)
docEditor.setCursorPosition(x+3)
mainMenu.aSelectAll.activate(QAction.ActionEvent.Trigger)
cursor = docEditor.textCursor()
assert len(cursor.selectedText()) == 1910
assert len(cursor.selectedText()) == 1928
# Clear the Text
docEditor.clear()
+1 -1
View File
@@ -17,7 +17,7 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
from pathlib import Path
+1 -1
View File
@@ -17,7 +17,7 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import time
+2 -2
View File
@@ -17,7 +17,7 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
from unittest.mock import MagicMock
@@ -1454,7 +1454,7 @@ def testGuiProjTree_Templates(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
nwNewCharacter = project.tree[hNewCharacter]
assert nwNewCharacter is not None
assert nwNewCharacter.itemName == "Note"
assert project.storage.getDocument(hNewCharacter).readDocument() == "# Jane\n\n@tag: Jane\n\n"
assert project.storage.getDocument(hNewCharacter).readDocument() == "# Note\n\n@tag: Jane\n\n"
# Clearing the menu and rebuilding it should work
projBar.mTemplates.clearMenu()
+1 -1
View File
@@ -17,7 +17,7 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
from time import time
+53
View File
@@ -0,0 +1,53 @@
"""
novelWriter Side Bar Class Tester
===================================
This file is a part of novelWriter
Copyright (C) 2025 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 <https://www.gnu.org/licenses/>.
""" # noqa
from __future__ import annotations
import pytest
from novelwriter import CONFIG
from novelwriter.constants import nwLabels
from novelwriter.enum import nwTheme
@pytest.mark.gui
def testGuiSideBar_CycleColourTheme(nwGUI):
"""Test theme cycle feature on the side bar."""
CONFIG.themeMode = nwTheme.AUTO
sidebar = nwGUI.sideBar
sidebar.mainGui.checkThemeUpdate = lambda *a: None
# Run 3 Cycles
for _ in range(3):
# Cycle Light
sidebar._cycleColurTheme()
assert CONFIG.themeMode == nwTheme.LIGHT
assert sidebar.tbTheme.toolTip() == nwLabels.THEME_MODE_LABEL[nwTheme.LIGHT]
# Cycle Dark
sidebar._cycleColurTheme()
assert CONFIG.themeMode == nwTheme.DARK
assert sidebar.tbTheme.toolTip() == nwLabels.THEME_MODE_LABEL[nwTheme.DARK]
# Cycle Auto
sidebar._cycleColurTheme()
assert CONFIG.themeMode == nwTheme.AUTO
assert sidebar.tbTheme.toolTip() == nwLabels.THEME_MODE_LABEL[nwTheme.AUTO]
+2 -2
View File
@@ -17,7 +17,7 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import time
@@ -31,7 +31,7 @@ from tests.tools import C, buildTestProject
@pytest.mark.gui
def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test the the various features of the status bar."""
"""Test the various features of the status bar."""
buildTestProject(nwGUI, projPath)
cHandle = SHARED.project.newFile("A Note", C.hCharRoot)
newDoc = SHARED.project.storage.getDocument(cHandle)
+466 -297
View File
@@ -17,347 +17,405 @@ 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 <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import sys
import json
from configparser import ConfigParser
from pathlib import Path
from unittest.mock import MagicMock, Mock
import pytest
from PyQt6.QtGui import QColor, QIcon, QPalette, QPixmap
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor, QFont, QFontDatabase, QIcon, QPalette, QPixmap, QStyleHints
from novelwriter import CONFIG, SHARED
from novelwriter.common import NWConfigParser
from novelwriter.config import DEF_GUI
from novelwriter import CONFIG
from novelwriter.config import DEF_GUI_DARK, DEF_GUI_LIGHT, DEF_ICONS
from novelwriter.constants import nwLabels
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.gui.theme import _listConf
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType, nwTheme
from novelwriter.gui.theme import (
STYLES_BIG_TOOLBUTTON, STYLES_FLAT_TABS, STYLES_MIN_TOOLBUTTON, GuiTheme,
ThemeMeta, _listContent
)
from tests.mocked import causeOSError
from tests.tools import writeFile
@pytest.mark.gui
def testGuiTheme_Main(qtbot, nwGUI, tstPaths):
"""Test the theme class init."""
mainTheme = SHARED.theme
def testGuiTheme_ParseColor():
"""Test the colour parsing."""
theme = GuiTheme()
# Methods
# =======
# Pre-Populate
theme._qColors["red"] = QColor(255, 0, 0)
theme._qColors["green"] = QColor(0, 255, 0)
theme._qColors["blue"] = QColor(0, 0, 255)
theme._qColors["grey"] = QColor(127, 127, 127)
mSize = mainTheme.getTextWidth("m")
assert mSize > 0
assert mainTheme.getTextWidth("m", mainTheme.guiFont) == mSize
# By Name
assert theme.parseColor("red").getRgb() == (255, 0, 0, 255)
assert theme.parseColor("green").getRgb() == (0, 255, 0, 255)
assert theme.parseColor("blue").getRgb() == (0, 0, 255, 255)
assert theme.parseColor("bob").getRgb() == (0, 0, 0, 255)
# Scan for Themes
# ===============
# CSS Format
assert theme.parseColor("#ff0000").getRgb() == (255, 0, 0, 255)
assert theme.parseColor("#ff00007f").getRgb() == (255, 0, 0, 127)
assert theme.parseColor("#ff00").getRgb() == (0, 0, 0, 255) # Too short -> ignored
assert theme.parseColor("#ff00007f15").getRgb() == (0, 0, 0, 255) # Too long -> ignored
result = {}
_listConf({}, Path("not_a_path"), ".conf")
assert result == {}
# Name + Alpha
assert theme.parseColor("red:255").getRgb() == (255, 0, 0, 255)
assert theme.parseColor("red:127").getRgb() == (255, 0, 0, 127)
assert theme.parseColor("red:512").getRgb() == (255, 0, 0, 255) # Value truncated
themeOne = tstPaths.cnfDir / "themes" / "themeone.conf"
themeTwo = tstPaths.cnfDir / "themes" / "themetwo.conf"
writeFile(themeOne, "# Stuff")
writeFile(themeTwo, "# Stuff")
# Name + Lighter
assert theme.parseColor("grey:L100").getRgb() == (127, 127, 127, 255)
assert theme.parseColor("grey:L150").getRgb() == (190, 190, 190, 255)
assert theme.parseColor("grey:L50").getRgb() == (63, 63, 63, 255)
_listConf(result, tstPaths.cnfDir / "themes", ".conf")
assert result["themeone"] == themeOne
assert result["themetwo"] == themeTwo
# Name + Darker
assert theme.parseColor("grey:D100").getRgb() == (127, 127, 127, 255)
assert theme.parseColor("grey:D150").getRgb() == (85, 85, 85, 255)
assert theme.parseColor("grey:D50").getRgb() == (254, 254, 254, 255)
# Parse Colours
# =============
parser = NWConfigParser()
parser["Palette"] = {
"colour1": "100, 150, 200", # Valid
"colour2": "100, 150, 200, 250", # With alpha
"colour3": "100, 150, 200, 250, 300", # Too many values
"colour4": "250, 250", # Missing blue
"colour5": "-10, 127, 300", # Invalid red and blue
"colour6": "bob, 127, 255", # Invalid red
}
# Test the parser for several valid and invalid values
assert mainTheme._parseColor(parser, "Palette", "colour1").getRgb() == (100, 150, 200, 255)
assert mainTheme._parseColor(parser, "Palette", "colour2").getRgb() == (100, 150, 200, 250)
assert mainTheme._parseColor(parser, "Palette", "colour3").getRgb() == (100, 150, 200, 250)
assert mainTheme._parseColor(parser, "Palette", "colour4").getRgb() == (250, 250, 0, 255)
assert mainTheme._parseColor(parser, "Palette", "colour5").getRgb() == (0, 0, 0, 0)
assert mainTheme._parseColor(parser, "Palette", "colour6").getRgb() == (0, 127, 255, 255)
# The palette should load with the parsed values
mainTheme._setPalette(parser, "Palette", "colour1", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (100, 150, 200, 255)
mainTheme._setPalette(parser, "Palette", "colour2", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (100, 150, 200, 250)
mainTheme._setPalette(parser, "Palette", "colour3", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (100, 150, 200, 250)
mainTheme._setPalette(parser, "Palette", "colour4", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (250, 250, 0, 255)
mainTheme._setPalette(parser, "Palette", "colour5", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (0, 0, 0, 0)
mainTheme._setPalette(parser, "Palette", "colour6", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (0, 127, 255, 255)
# Non-existing value should return default colour
mainTheme._setPalette(parser, "Palette", "stuff", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (0, 0, 0, 255)
# qtbot.stop()
# Values
assert theme.parseColor("255, 0, 0").getRgb() == (255, 0, 0, 255)
assert theme.parseColor("255, 0, 0, 255").getRgb() == (255, 0, 0, 255)
assert theme.parseColor("255, 0, 0, 127").getRgb() == (255, 0, 0, 127)
assert theme.parseColor("255, 0, 0, 127, 42").getRgb() == (255, 0, 0, 127) # Truncated
@pytest.mark.gui
def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths):
"""Test the theme part of the class."""
mainTheme = SHARED.theme
def testGuiTheme_ScanThemes(monkeypatch):
"""Test the themes scanning."""
theme = GuiTheme()
# List Themes
# Load built-in themes
files = []
_listContent(files, CONFIG.assetPath("themes"), ".conf")
assert len(files) > 0
# Block reading theme files
with monkeypatch.context() as mp:
mp.setattr(ConfigParser, "read", causeOSError)
theme._scanThemes(files)
assert theme.colourThemes == {}
# Read all themes correctly
theme._scanThemes(files)
assert len(theme.colourThemes) > 0
dark = theme.colourThemes[DEF_GUI_DARK]
light = theme.colourThemes[DEF_GUI_LIGHT]
assert dark.name == "Default Dark Theme"
assert dark.dark is True
assert light.name == "Default Light Theme"
assert light.dark is False
@pytest.mark.gui
def testGuiTheme_LoadThemes(monkeypatch):
"""Test loading themes."""
theme = GuiTheme()
theme.iconCache = MagicMock()
CONFIG.lightTheme = DEF_GUI_LIGHT
CONFIG.darkTheme = DEF_GUI_DARK
# Load built-in themes
files = []
_listContent(files, CONFIG.assetPath("themes"), ".conf")
theme._scanThemes(files)
assert DEF_GUI_LIGHT in theme._allThemes
assert DEF_GUI_DARK in theme._allThemes
assert theme._currentTheme == ""
# Load light theme
CONFIG.themeMode = nwTheme.LIGHT
theme.loadTheme()
assert theme._currentTheme == DEF_GUI_LIGHT
# Load dark theme
CONFIG.themeMode = nwTheme.DARK
theme.loadTheme()
assert theme._currentTheme == DEF_GUI_DARK
# Let auto switch back to light, then dark
with monkeypatch.context() as mp:
mp.setattr(CONFIG, "verQtValue", 0x060500)
mp.setattr(QStyleHints, "colorScheme", lambda *a: Qt.ColorScheme.Light)
CONFIG.themeMode = nwTheme.AUTO
theme.loadTheme()
assert theme._currentTheme == DEF_GUI_LIGHT
with monkeypatch.context() as mp:
mp.setattr(CONFIG, "verQtValue", 0x060500)
mp.setattr(QStyleHints, "colorScheme", lambda *a: Qt.ColorScheme.Dark)
CONFIG.themeMode = nwTheme.AUTO
theme.loadTheme()
assert theme._currentTheme == DEF_GUI_DARK
# Error Cases
# ===========
# Block the reading of the files
# Invalid light theme
CONFIG.lightTheme = "not_a_theme"
CONFIG.themeMode = nwTheme.LIGHT
theme.loadTheme()
assert theme._currentTheme == DEF_GUI_LIGHT
# Invalid dark theme
CONFIG.darkTheme = "not_a_theme"
CONFIG.themeMode = nwTheme.DARK
theme.loadTheme()
assert theme._currentTheme == DEF_GUI_DARK
# Clear meta and check exit early cases
theme._meta = ThemeMeta()
assert theme._meta.name == ""
# Reload dark should not load anything
CONFIG.darkTheme = DEF_GUI_DARK
CONFIG.themeMode = nwTheme.DARK
theme.loadTheme()
assert theme._meta.name == ""
# Force reload, but fail parsing
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert mainTheme.listThemes() == []
mp.setattr(ConfigParser, "read", causeOSError)
CONFIG.darkTheme = DEF_GUI_DARK
CONFIG.themeMode = nwTheme.DARK
theme.loadTheme(force=True)
assert theme._meta.name == ""
# Load the theme info, default themes first
themesList = mainTheme.listThemes()
assert themesList[0] == ("default_dark", "Default Dark Theme")
assert themesList[1] == ("default_light", "Default Light Theme")
assert themesList[2] == ("cyberpunk_night", "Cyberpunk Night")
assert themesList[3] == ("dracula", "Dracula")
# Invalid theme, and defaults are missing
del theme._allThemes[DEF_GUI_DARK]
del theme._allThemes[DEF_GUI_LIGHT]
# A second call should returned the cached list
assert mainTheme.listThemes() == mainTheme._themeList
CONFIG.lightTheme = "not_a_theme"
CONFIG.themeMode = nwTheme.LIGHT
theme.loadTheme(force=True)
assert theme._meta.name == ""
# Check handling of broken theme settings
CONFIG.guiTheme = "not_a_theme"
availThemes = mainTheme._availThemes
mainTheme._availThemes = {}
assert mainTheme.loadTheme() is False
mainTheme._availThemes = availThemes
CONFIG.darkTheme = "not_a_theme"
CONFIG.themeMode = nwTheme.DARK
theme.loadTheme(force=True)
assert theme._meta.name == ""
# Check handling of unreadable file
CONFIG.guiTheme = DEF_GUI
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert mainTheme.loadTheme() is False
# Load Default Theme
# ==================
@pytest.mark.gui
def testGuiTheme_SpecialColors(tstPaths):
"""Test handling special cases for colours."""
theme = GuiTheme()
theme.iconCache = MagicMock()
if sys.platform != "win32":
# Set a mock colour for the window background
mainTheme._guiPalette.color(QPalette.ColorRole.Window).setRgb(0, 0, 0, 0)
# Load the default theme
CONFIG.guiTheme = DEF_GUI
assert mainTheme.loadTheme() is True
# This should load a standard palette
wCol = QPalette().color(QPalette.ColorRole.Window).getRgb()
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == wCol
# Mock Dark Theme
# ===============
mockTheme: Path = tstPaths.cnfDir / "themes" / "test.conf"
mockTheme.write_text((
testTheme: Path = tstPaths.cnfDir / "themes" / "test.conf"
testTheme.write_text((
"[Main]\n"
"name = Test\n"
"mode = light\n"
"\n"
"[Base]\n"
"default = #cccccc\n"
"faded = #949494\n"
"red = #ff0000\n"
"orange = #ff7f00\n"
"yellow = #ffff00\n"
"green = #00ff00\n"
"cyan = #00ffff\n"
"blue = #0000ff\n"
"purple = #ff00ff\n"
"\n"
"[Project]\n"
"root = blue\n"
"folder = yellow\n"
"file = default\n"
"title = green\n"
"chapter = red\n"
"scene = blue\n"
"note = yellow\n"
"\n"
"[Palette]\n"
"window = 0, 0, 0\n"
"text = 255, 255, 255\n"
"window = #000000\n"
"text = #ffffff\n"
), encoding="utf-8")
mainTheme._availThemes["test"] = mockTheme
theme._scanThemes([testTheme])
assert len(theme.colourThemes) == 1
CONFIG.themeMode = nwTheme.LIGHT
CONFIG.lightTheme = "test"
CONFIG.guiTheme = "test"
assert mainTheme.loadTheme() is True
assert mainTheme._guiPalette.window().color().getRgb() == (0, 0, 0, 255)
assert mainTheme._guiPalette.text().color().getRgb() == (255, 255, 255, 255)
assert mainTheme._guiPalette.light().color().getRgb() == (57, 57, 57, 255)
assert mainTheme.isDarkTheme is True
# Load theme
theme.loadTheme()
# Load Default Light Theme
# ========================
# Since window is black, a lighter version should be generated
assert theme._guiPalette.light().color().getRgb() == (57, 57, 57, 255)
CONFIG.guiTheme = "default_light"
assert mainTheme.loadTheme() is True
# Reload with project override to red
CONFIG.iconColTree = "red"
CONFIG.iconColDocs = True
theme.loadTheme(force=True)
# Check a few values
assert mainTheme._guiPalette.color(
QPalette.ColorRole.Window
).getRgb() == (239, 239, 239, 255)
assert mainTheme._guiPalette.color(
QPalette.ColorRole.WindowText
).getRgb() == (0, 0, 0, 255)
assert mainTheme._guiPalette.color(
QPalette.ColorRole.Base
).getRgb() == (255, 255, 255, 255)
assert mainTheme._guiPalette.color(
QPalette.ColorRole.AlternateBase
).getRgb() == (224, 224, 224, 255)
assert theme.getBaseColor("root").getRgb() == (255, 0, 0, 255)
assert theme.getBaseColor("folder").getRgb() == (255, 0, 0, 255)
assert theme.getBaseColor("file").getRgb() == (204, 204, 204, 255)
assert theme.getBaseColor("title").getRgb() == (0, 255, 0, 255)
assert theme.getBaseColor("chapter").getRgb() == (255, 0, 0, 255)
assert theme.getBaseColor("scene").getRgb() == (0, 0, 255, 255)
assert theme.getBaseColor("note").getRgb() == (255, 255, 0, 255)
# Load Default Dark Theme
# =======================
CONFIG.guiTheme = "default_dark"
assert mainTheme.loadTheme() is True
# Check a few values
assert mainTheme._guiPalette.color(
QPalette.ColorRole.Window).getRgb() == (54, 54, 54, 255)
assert mainTheme._guiPalette.color(
QPalette.ColorRole.WindowText).getRgb() == (204, 204, 204, 255)
assert mainTheme._guiPalette.color(
QPalette.ColorRole.Base).getRgb() == (62, 62, 62, 255)
assert mainTheme._guiPalette.color(
QPalette.ColorRole.AlternateBase).getRgb() == (78, 78, 78, 255)
# qtbot.stop()
@pytest.mark.gui
def testGuiTheme_Syntax(qtbot, monkeypatch, nwGUI):
"""Test the syntax part of the class."""
mainTheme = SHARED.theme
# List Themes
# ===========
# Block the reading of the files
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert mainTheme.listThemes() == []
# Load the syntax info
syntaxList = mainTheme.listSyntax()
assert syntaxList[0] == ("default_dark", "Default Dark")
assert syntaxList[1] == ("default_light", "Default Light")
# A second call should returned the cached list
assert mainTheme.listSyntax() == mainTheme._syntaxList
# Check handling of broken theme settings
availSyntax = mainTheme._availSyntax
mainTheme._availSyntax = {}
CONFIG.guiSyntax = "not_a_syntax"
assert mainTheme.loadSyntax() is False
mainTheme._availSyntax = availSyntax
# Check handling of unreadable file
CONFIG.guiSyntax = "default_light"
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert mainTheme.loadSyntax() is False
# Load Default Light Syntax
# =========================
# Load the default syntax
CONFIG.guiSyntax = "default_light"
assert mainTheme.loadSyntax() is True
# Check some values
assert mainTheme.syntaxMeta.name == "Default Light"
assert mainTheme.syntaxTheme.back == QColor(255, 255, 255)
assert mainTheme.syntaxTheme.text == QColor(0, 0, 0)
assert mainTheme.syntaxTheme.link == QColor(0, 0, 200)
# Load Default Dark Theme
# =======================
# Load the default syntax
CONFIG.guiSyntax = "default_dark"
assert mainTheme.loadSyntax() is True
# Check some values
assert mainTheme.syntaxMeta.name == "Default Dark"
assert mainTheme.syntaxTheme.back == QColor(42, 42, 42)
assert mainTheme.syntaxTheme.text == QColor(204, 204, 204)
assert mainTheme.syntaxTheme.link == QColor(102, 153, 204)
# qtbot.stop()
@pytest.mark.gui
def testGuiTheme_IconThemes(qtbot, caplog, monkeypatch, nwGUI, tstPaths):
"""Test the icon cache class."""
iconCache = SHARED.theme.iconCache
# Load Theme
# ==========
# Invalid theme name
availThemes = iconCache._availThemes
iconCache._availThemes = {}
assert iconCache.loadTheme("not_a_theme") is False
iconCache._availThemes = availThemes
# Check handling of unreadable file
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert iconCache.loadTheme("material_rounded_normal") is False
# Load working theme file
assert iconCache.loadTheme("material_rounded_normal") is True
assert iconCache.themeMeta.name == "Material Symbols - Rounded"
# Load with project colour override
purple = iconCache._svgColors["purple"]
assert iconCache._svgColors["root"] != purple
assert iconCache._svgColors["folder"] != purple
assert iconCache._svgColors["file"] != purple
assert iconCache._svgColors["title"] != purple
assert iconCache._svgColors["chapter"] != purple
assert iconCache._svgColors["scene"] != purple
assert iconCache._svgColors["note"] != purple
assert theme.getRawBaseColor("root") == b"#ff0000"
assert theme.getRawBaseColor("folder") == b"#ff0000"
assert theme.getRawBaseColor("file") == b"#cccccc"
assert theme.getRawBaseColor("title") == b"#00ff00"
assert theme.getRawBaseColor("chapter") == b"#ff0000"
assert theme.getRawBaseColor("scene") == b"#0000ff"
assert theme.getRawBaseColor("note") == b"#ffff00"
# Reload with project override to purple, also for docs
CONFIG.iconColTree = "purple"
assert iconCache.loadTheme("material_rounded_normal") is True
assert iconCache._svgColors["root"] == purple
assert iconCache._svgColors["folder"] == purple
assert iconCache._svgColors["file"] == purple
assert iconCache._svgColors["title"] == purple
assert iconCache._svgColors["chapter"] == purple
assert iconCache._svgColors["scene"] == purple
assert iconCache._svgColors["note"] == purple
CONFIG.iconColDocs = False
theme.loadTheme(force=True)
# Change some colours
iconCache.setIconColor("root", QColor(255, 255, 255))
assert iconCache._svgColors["root"] != purple
assert iconCache._svgColors["root"] == b"#ffffff"
assert theme.getBaseColor("root").getRgb() == (255, 0, 255, 255)
assert theme.getBaseColor("folder").getRgb() == (255, 0, 255, 255)
assert theme.getBaseColor("file").getRgb() == (255, 0, 255, 255)
assert theme.getBaseColor("title").getRgb() == (255, 0, 255, 255)
assert theme.getBaseColor("chapter").getRgb() == (255, 0, 255, 255)
assert theme.getBaseColor("scene").getRgb() == (255, 0, 255, 255)
assert theme.getBaseColor("note").getRgb() == (255, 0, 255, 255)
# List Themes
# ===========
# Load error returns empty list
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
themes = iconCache.listThemes()
assert themes == []
# Successful read
themes = iconCache.listThemes()
assert len(themes) > 1
assert "material_rounded_normal" in dict(themes)
# Load error doesn't matter on second read since list is cached
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert iconCache.listThemes() == themes
# qtbot.stop()
assert theme.getRawBaseColor("root") == b"#ff00ff"
assert theme.getRawBaseColor("folder") == b"#ff00ff"
assert theme.getRawBaseColor("file") == b"#ff00ff"
assert theme.getRawBaseColor("title") == b"#ff00ff"
assert theme.getRawBaseColor("chapter") == b"#ff00ff"
assert theme.getRawBaseColor("scene") == b"#ff00ff"
assert theme.getRawBaseColor("note") == b"#ff00ff"
@pytest.mark.gui
def testGuiTheme_LoadIcons(qtbot, nwGUI):
def testGuiTheme_Methods(monkeypatch):
"""Test other themes methods."""
theme = GuiTheme()
theme.iconCache = MagicMock()
CONFIG.darkTheme = DEF_GUI_DARK
CONFIG.themeMode = nwTheme.DARK
# Init theme
assert theme.colourThemes == {}
theme.initThemes()
assert theme._meta.name == "Default Dark Theme"
# Text width
theme.guiFont = QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont)
assert theme.getTextWidth("MMMMM") > theme.getTextWidth("MMM")
font = QFont(theme.guiFont)
font.setPointSizeF(0.5*font.pointSizeF())
assert theme.getTextWidth("MMMMM", font) < theme.getTextWidth("MMMMM")
# Detect desktop mode Qt 6.5+
with monkeypatch.context() as mp:
mp.setattr(CONFIG, "verQtValue", 0x060500)
mp.setattr(QStyleHints, "colorScheme", lambda *a: Qt.ColorScheme.Light)
assert theme.isDesktopDarkMode() is False
mp.setattr(QStyleHints, "colorScheme", lambda *a: Qt.ColorScheme.Dark)
assert theme.isDesktopDarkMode() is True
# Detect desktop mode Qt 6.4
mockWhite = Mock()
mockWhite.color.return_value = QColor(255, 255, 255)
mockBlack = Mock()
mockBlack.color.return_value = QColor(0, 0, 0)
with monkeypatch.context() as mp:
mp.setattr(CONFIG, "verQtValue", 0x060400)
mp.setattr(QPalette, "window", lambda *a: mockWhite)
mp.setattr(QPalette, "windowText", lambda *a: mockBlack)
assert theme.isDesktopDarkMode() is False
mp.setattr(QPalette, "window", lambda *a: mockBlack)
mp.setattr(QPalette, "windowText", lambda *a: mockWhite)
assert theme.isDesktopDarkMode() is True
# Stylesheets
assert theme.getStyleSheet(STYLES_FLAT_TABS) != ""
assert theme.getStyleSheet(STYLES_MIN_TOOLBUTTON) != ""
assert theme.getStyleSheet(STYLES_BIG_TOOLBUTTON) != ""
assert theme.getStyleSheet("stuff") == ""
@pytest.mark.gui
def testGuiTheme_ScanIcons(monkeypatch):
"""Test the icon theme scanning."""
theme = GuiTheme()
# Load built-in themes
files = []
_listContent(files, CONFIG.assetPath("icons"), ".icons")
assert len(files) > 0
# Block reading theme files
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
theme.iconCache._scanThemes(files)
assert theme.iconCache.iconThemes == {}
# Read all themes correctly
theme.iconCache._scanThemes(files)
assert len(theme.iconCache.iconThemes) > 0
@pytest.mark.gui
def testGuiTheme_IconThemes(monkeypatch):
"""Test loading icon theme."""
CONFIG.lightTheme = DEF_GUI_LIGHT
CONFIG.themeMode = nwTheme.LIGHT
CONFIG.iconTheme = DEF_ICONS
theme = GuiTheme()
# Init should load default theme
theme.initThemes()
assert theme.iconCache._meta.name == "Material Symbols - Rounded"
assert DEF_ICONS in theme.iconCache.iconThemes
# Load default theme directly
theme.iconCache._meta = ThemeMeta()
theme.iconCache.loadTheme("DEF_ICONS")
assert theme.iconCache._meta.name == "Material Symbols - Rounded"
# Failed loading should load nothing
theme.iconCache._meta = ThemeMeta()
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
theme.iconCache.loadTheme("DEF_ICONS")
assert theme.iconCache._meta.name == ""
# Reload with non-existent theme should reload default
theme.iconCache._meta = ThemeMeta()
theme.iconCache.loadTheme("not_a_theme")
assert theme.iconCache._meta.name == "Material Symbols - Rounded"
# If default theme is missing, load nothing
del theme.iconCache._allThemes[DEF_ICONS]
theme.iconCache._meta = ThemeMeta()
theme.iconCache.loadTheme("not_a_theme")
assert theme.iconCache._meta.name == ""
@pytest.mark.gui
def testGuiTheme_LoadIcons():
"""Test the icon cache class."""
iconCache = SHARED.theme.iconCache
assert iconCache.loadTheme("material_rounded_normal") is True
theme = GuiTheme()
theme.initThemes()
iconCache = theme.iconCache
# Load Icons
# ==========
@@ -373,11 +431,13 @@ def testGuiTheme_LoadIcons(qtbot, nwGUI):
assert qIcon.isNull() is False
# Load it as a pixmap with a size
# If this part of the test fails, you may need to set the
# environment variable: QT_SCALE_FACTOR=1
qPix = iconCache.getPixmap("add", (50, 50))
assert isinstance(qPix, QPixmap)
assert qPix.isNull() is False
assert qPix.width() == 50
assert qPix.height() == 50
assert qPix.width() == 50, "If this fails, make sure QT_SCALE_FACTOR=1"
assert qPix.height() == 50, "If this fails, make sure QT_SCALE_FACTOR=1"
# Load app icon
qIcon = iconCache.getIcon("novelwriter")
@@ -455,14 +515,13 @@ def testGuiTheme_LoadIcons(qtbot, nwGUI):
nwItemType.NO_TYPE, nwItemClass.NOVEL, nwItemLayout.DOCUMENT, hLevel="H0"
) == iconCache._noIcon
# qtbot.stop()
@pytest.mark.gui
def testGuiTheme_LoadDecorations(qtbot, monkeypatch, nwGUI):
def testGuiTheme_LoadDecorations(monkeypatch):
"""Test the icon cache class."""
iconCache = SHARED.theme.iconCache
assert iconCache.loadTheme("material_rounded_normal") is True
theme = GuiTheme()
theme.initThemes()
iconCache = theme.iconCache
# Load Decorations
# ================
@@ -520,4 +579,114 @@ def testGuiTheme_LoadDecorations(qtbot, monkeypatch, nwGUI):
assert iconCache.getHeaderDecorationNarrow(5) == iconCache._headerDecNarrow[5]
assert iconCache.getHeaderDecorationNarrow(6) == iconCache._headerDecNarrow[5]
# qtbot.stop()
THEMES = []
_listContent(THEMES, CONFIG.assetPath("themes"), ".conf")
@pytest.mark.gui
@pytest.mark.parametrize("theme", [a.stem for a in THEMES])
def testGuiTheme_CheckTheme(theme):
"""Test loading all themes."""
themes = GuiTheme()
themes.iconCache = MagicMock()
themes._scanThemes(THEMES)
assert theme in themes.colourThemes
current = themes.colourThemes[theme]
if current.dark:
CONFIG.darkTheme = theme
CONFIG.themeMode = nwTheme.DARK
else:
CONFIG.lightTheme = theme
CONFIG.themeMode = nwTheme.LIGHT
# Check loading
themes.loadTheme()
assert themes._meta.name == current.name
assert themes.isDarkTheme == current.dark
# Check completeness
parser = ConfigParser()
parser.read(current.path, encoding="utf-8")
sections = ["Main", "Base", "Project", "Palette", "GUI", "Syntax"]
assert sorted(parser.sections()) == sorted(sections)
structure = {
"Main": [
"name", "mode", "author", # The rest are not required
],
"Base": [
"base", "default", "faded", "red", "orange", "yellow", "green",
"cyan", "blue", "purple",
],
"Project": [
"root", "folder", "file", "title", "chapter", "scene", "note",
"active", "inactive", "disabled",
],
"Palette": [
"window", "windowtext", "base", "alternatebase", "text",
"tooltipbase", "tooltiptext", "button", "buttontext", "brighttext",
"highlight", "highlightedtext", "link", "linkvisited", "accent",
],
"GUI": [
"helptext", "fadedtext", "errortext",
],
"Syntax": [
"background", "text", "line", "link", "headertext", "headertag",
"emphasis", "dialog", "altdialog", "hidden", "note", "shortcode",
"keyword", "tag", "value", "optional", "spellcheckline",
"errorline", "replacetag", "modifier", "texthighlight",
],
}
optional = ["credit", "url"]
missing = []
for section, options in structure.items():
missing.extend(opt for opt in options if opt not in parser[section])
assert missing == [], "Missing options in theme file"
# Check deprecated
deprecated = []
for section in sections:
deprecated.extend(
opt for opt in parser[section]
if opt not in structure[section] and opt not in optional
)
assert deprecated == [], "Deprecated options in theme file"
ICONS = []
_listContent(ICONS, CONFIG.assetPath("icons"), ".icons")
@pytest.mark.gui
@pytest.mark.parametrize("icons", [a.stem for a in ICONS])
def testGuiTheme_CheckIcons(icons, tstPaths):
"""Test loading all icons."""
keysFile: Path = tstPaths.filesDir / "all_icons.json"
iconKeys = json.loads(keysFile.read_text(encoding="utf-8"))
assert isinstance(iconKeys, list)
CONFIG.lightTheme = DEF_GUI_LIGHT
CONFIG.themeMode = nwTheme.LIGHT
themes = GuiTheme()
themes.initThemes()
iconCache = themes.iconCache
assert icons in iconCache.iconThemes
current = iconCache.iconThemes[icons]
CONFIG.iconTheme = icons
# Check loading
themes.loadTheme(force=True)
assert iconCache._meta.name == current.name
# Check completeness
missing = [key for key in iconKeys if key not in iconCache._svgData]
assert missing == [], "Missing keys in icons file"
# Check deprecated
deprecated = [key for key in iconCache._svgData if key not in iconKeys]
assert deprecated == [], "Deprecated keys in icons file"