diff --git a/.github/workflows/test_mac.yml b/.github/workflows/test_mac.yml index de4aebc5a..64c3dfa88 100644 --- a/.github/workflows/test_mac.yml +++ b/.github/workflows/test_mac.yml @@ -12,7 +12,7 @@ on: jobs: testMac: - runs-on: macos-latest + runs-on: macos-13 steps: - name: Python Setup uses: actions/setup-python@v5 diff --git a/novelwriter/assets/i18n/project_en_GB.json b/novelwriter/assets/i18n/project_en_GB.json index f8e30410d..39d52e6e0 100644 --- a/novelwriter/assets/i18n/project_en_GB.json +++ b/novelwriter/assets/i18n/project_en_GB.json @@ -1,6 +1,7 @@ { "Synopsis": "Synopsis", "Short Description": "Short Description", + "Footnotes": "Footnotes", "Comment": "Comment", "Notes": "Notes", "Tag": "Tag", diff --git a/novelwriter/common.py b/novelwriter/common.py index ff36bae27..8573c1bd1 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -24,30 +24,32 @@ from __future__ import annotations import json -import uuid import logging import unicodedata +import uuid import xml.etree.ElementTree as ET -from typing import TYPE_CHECKING, Any, Literal -from pathlib import Path -from datetime import datetime from configparser import ConfigParser +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, TypeVar from urllib.parse import urljoin from urllib.request import pathname2url -from PyQt5.QtGui import QColor, QDesktopServices from PyQt5.QtCore import QCoreApplication, QUrl +from PyQt5.QtGui import QColor, QDesktopServices -from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout -from novelwriter.error import logException from novelwriter.constants import nwConst, nwLabels, nwUnicode, trConst +from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType +from novelwriter.error import logException if TYPE_CHECKING: # pragma: no cover from typing import TypeGuard # Requires Python 3.10 logger = logging.getLogger(__name__) +_Type = TypeVar("_Type") + ## # Checker Functions @@ -172,6 +174,11 @@ def isItemLayout(value: Any) -> TypeGuard[str]: return isinstance(value, str) and value in nwItemLayout.__members__ +def isListInstance(data: Any, check: type[_Type]) -> TypeGuard[list[_Type]]: + """Check that all items of a list is of a given type.""" + return isinstance(data, list) and all(isinstance(item, check) for item in data) + + def hexToInt(value: Any, default: int = 0) -> int: """Convert a hex string to an integer.""" if isinstance(value, str): @@ -272,6 +279,13 @@ def simplified(text: str) -> str: return " ".join(str(text).strip().split()) +def elide(text: str, length: int) -> str: + """Elide a piece of text to a maximum length.""" + if len(text) > (cut := max(4, length)): + return f"{text[:cut-4].rstrip()} ..." + return text + + def yesNo(value: int | bool | None) -> Literal["yes", "no"]: """Convert a boolean evaluated variable to a yes or no.""" return "yes" if value else "no" diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 48487d777..dac0604a5 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -23,9 +23,11 @@ """ from __future__ import annotations -from PyQt5.QtCore import QCoreApplication, QT_TRANSLATE_NOOP +from PyQt5.QtCore import QT_TRANSLATE_NOOP, QCoreApplication -from novelwriter.enum import nwBuildFmt, nwItemClass, nwItemLayout, nwOutline, nwStatusShape +from novelwriter.enum import ( + nwBuildFmt, nwComment, nwItemClass, nwItemLayout, nwOutline, nwStatusShape +) def trConst(text: str) -> str: @@ -67,7 +69,7 @@ class nwRegEx: FMT_EB = r"(? Iterable[tup else: yield i, False + makeObj.appendFootnotes() + if not (self._build.getBool("html.preserveTabs") or self._preview): makeObj.replaceTabs() @@ -231,6 +233,8 @@ def iterBuildMarkdown(self, path: Path, extendedMd: bool) -> Iterable[tuple[int, else: yield i, False + makeObj.appendFootnotes() + self._error = None self._cache = makeObj diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 7ca7afe56..a94493573 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -29,17 +29,20 @@ import json import logging +import random -from time import time -from typing import TYPE_CHECKING -from pathlib import Path from collections.abc import ItemsView, Iterable +from pathlib import Path +from time import time +from typing import TYPE_CHECKING, Literal from novelwriter import SHARED -from novelwriter.enum import nwComment, nwItemClass, nwItemType, nwItemLayout +from novelwriter.common import ( + checkInt, isHandle, isItemClass, isListInstance, isTitleTag, jsonEncode +) +from novelwriter.constants import nwFiles, nwHeaders, nwKeyWords +from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwItemType from novelwriter.error import logException -from novelwriter.common import checkInt, isHandle, isItemClass, isTitleTag, jsonEncode -from novelwriter.constants import nwFiles, nwKeyWords, nwHeaders from novelwriter.text.counting import standardCounter if TYPE_CHECKING: # pragma: no cover @@ -48,7 +51,12 @@ logger = logging.getLogger(__name__) -TT_NONE = "T0000" +T_NoteTypes = Literal["footnotes", "comments"] + +TT_NONE = "T0000" # Default title key +MAX_RETRY = 1000 # Key generator recursion limit +KEY_SOURCE = "0123456789bcdfghjklmnpqrstvwxz" +NOTE_TYPES: list[T_NoteTypes] = ["footnotes", "comments"] class NWIndex: @@ -301,9 +309,9 @@ def scanText(self, tHandle: str, text: str, blockSignal: bool = False) -> bool: def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict[str, bool]) -> None: """Scan an active document for meta data.""" - nTitle = 0 # Line Number of the previous title - cTitle = TT_NONE # Tag of the current title - pTitle = TT_NONE # Tag of the previous title + nTitle = 0 # Line Number of the previous title + cTitle = TT_NONE # Tag of the current title + pTitle = TT_NONE # Tag of the previous title canSetHead = True # First heading has not yet been set lines = text.splitlines() @@ -335,10 +343,11 @@ def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict[str, b self._indexKeyword(tHandle, line, cTitle, nwItem.itemClass, tags) elif line.startswith("%"): - if cTitle != TT_NONE: - cStyle, cText, _ = processComment(line) - if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT): - self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText) + cStyle, cKey, cText, _, _ = processComment(line) + if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT): + self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText) + elif cStyle == nwComment.FOOTNOTE: + self._itemIndex.addNoteKey(tHandle, "footnotes", cKey) # Count words for remaining text after last heading if pTitle != TT_NONE: @@ -506,6 +515,14 @@ def parseValue(self, text: str) -> tuple[str, str]: name, _, display = text.partition("|") return name.rstrip(), display.lstrip() + def newCommentKey(self, tHandle: str, style: nwComment) -> str: + """Generate a new key for a comment style.""" + if style == nwComment.FOOTNOTE: + return self._itemIndex.genNewNoteKey(tHandle, "footnotes") + elif style == nwComment.COMMENT: + return self._itemIndex.genNewNoteKey(tHandle, "comments") + return "err" + ## # Extract Data ## @@ -790,7 +807,7 @@ def unpackData(self, data: dict) -> None: for key, entry in data.items(): if not isinstance(key, str): - raise ValueError("tagsIndex keys must be a string") + raise ValueError("tagsIndex key must be a string") if not isinstance(entry, dict): raise ValueError("tagsIndex entry is not a dict") @@ -950,6 +967,25 @@ def addHeadingRef(self, tHandle: str, sTitle: str, tagKeys: list[str], refType: self._items[tHandle].addHeadingRef(sTitle, tagKeys, refType) return + def addNoteKey(self, tHandle: str, style: T_NoteTypes, key: str) -> None: + """Set notes key for a given item.""" + if tHandle in self._items: + self._items[tHandle].addNoteKey(style, key) + return + + def genNewNoteKey(self, tHandle: str, style: T_NoteTypes) -> str: + """Set notes key for a given item.""" + if style in NOTE_TYPES and (item := self._items.get(tHandle)): + keys = set() + for entry in self._items.values(): + keys.update(entry.noteKeys(style)) + for _ in range(MAX_RETRY): + key = style[:1] + "".join(random.choices(KEY_SOURCE, k=4)) + if key not in keys: + item.addNoteKey(style, key) + return key + return "err" + ## # Pack/Unpack ## @@ -991,12 +1027,13 @@ class IndexItem: must be reset each time the item is re-indexed. """ - __slots__ = ("_handle", "_item", "_headings", "_count") + __slots__ = ("_handle", "_item", "_headings", "_count", "_notes") def __init__(self, tHandle: str, nwItem: NWItem) -> None: self._handle = tHandle self._item = nwItem self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(TT_NONE)} + self._notes: dict[str, set[str]] = {} self._count = 0 return @@ -1064,6 +1101,13 @@ def addHeadingRef(self, sTitle: str, tagKeys: list[str], refType: str) -> None: self._headings[sTitle].addReference(tagKey, refType) return + def addNoteKey(self, style: T_NoteTypes, key: str) -> None: + """Add a note key to the index.""" + if style not in self._notes: + self._notes[style] = set() + self._notes[style].add(key) + return + ## # Data Methods ## @@ -1085,6 +1129,10 @@ def nextHeading(self) -> str: self._count += 1 return f"T{self._count:04d}" + def noteKeys(self, style: T_NoteTypes) -> set[str]: + """Return a set of all note keys.""" + return self._notes.get(style, set()) + ## # Pack/Unpack ## @@ -1103,6 +1151,8 @@ def packData(self) -> dict: data["headings"] = heads if refs: data["references"] = refs + if self._notes: + data["notes"] = {style: list(keys) for style, keys in self._notes.items()} return data @@ -1116,6 +1166,14 @@ def unpackData(self, data: dict) -> None: tHeading.unpackData(hData) tHeading.unpackReferences(references.get(sTitle, {})) self.addHeading(tHeading) + + for style, keys in data.get("notes", {}).items(): + if style not in NOTE_TYPES: + raise ValueError("The notes style is invalid") + if not isListInstance(keys, str): + raise ValueError("The notes keys must be a list of strings") + self._notes[style] = set(keys) + return # END Class IndexItem @@ -1302,18 +1360,43 @@ def unpackReferences(self, data: dict) -> None: # Text Processing Functions # =============================================================================================== # -CLASSIFIERS = { - "short": nwComment.SHORT, +MODIFIERS = { "synopsis": nwComment.SYNOPSIS, + "short": nwComment.SHORT, + "note": nwComment.NOTE, + "footnote": nwComment.FOOTNOTE, +} +KEY_REQ = { + "synopsis": 0, # Key not allowed + "short": 0, # Key not allowed + "note": 1, # Key optional + "footnote": 2, # Key required } -def processComment(text: str) -> tuple[nwComment, str, int]: - """Extract comment style and text. Should only be called on text - starting with a %. +def _checkModKey(modifier: str, key: str) -> bool: + """Check if a modifier and key set are ok.""" + if modifier in MODIFIERS: + if key == "": + return KEY_REQ[modifier] < 2 + elif key.replace("_", "").isalnum(): + return KEY_REQ[modifier] > 0 + return False + + +def processComment(text: str) -> tuple[nwComment, str, str, int, int]: + """Extract comment style, key and text. Should only be called on + text starting with a %. """ - check = text[1:].lstrip() - classifier, _, content = check.partition(":") - if content and (clean := classifier.strip().lower()) in CLASSIFIERS: - return CLASSIFIERS[clean], content.strip(), text.find(":") + 1 - return nwComment.PLAIN, check, 0 + if text[:2] == "%~": + return nwComment.IGNORE, "", text[2:].lstrip(), 0, 0 + + check = text[1:].strip() + start, _, content = check.partition(":") + modifier, _, key = start.rstrip().partition(".") + if content and (clean := modifier.lower()) and _checkModKey(clean, key): + col = text.find(":") + 1 + dot = text.find(".", 0, col) + 1 + return MODIFIERS[clean], key, content.lstrip(), dot, col + + return nwComment.PLAIN, "", check, 0, 0 diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py index 6b85ccec7..50f7e1d2f 100644 --- a/novelwriter/core/status.py +++ b/novelwriter/core/status.py @@ -29,7 +29,7 @@ import random from collections.abc import Iterable -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from PyQt5.QtCore import QPointF, Qt from PyQt5.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QColor, QPolygonF @@ -75,7 +75,7 @@ class NWStatus: __slots__ = ("_store", "_default", "_prefix", "_height") - def __init__(self, prefix: str) -> None: + def __init__(self, prefix: Literal["s", "i"]) -> None: self._store: dict[str, StatusEntry] = {} self._default = None self._prefix = prefix[:1] diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py index 72539c701..960f8ace6 100644 --- a/novelwriter/core/tohtml.py +++ b/novelwriter/core/tohtml.py @@ -26,17 +26,53 @@ import json import logging -from time import time from pathlib import Path +from time import time from novelwriter import CONFIG from novelwriter.common import formatTimeStamp -from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwHtmlUnicode +from novelwriter.constants import nwHeadFmt, nwHtmlUnicode, nwKeyWords, nwLabels from novelwriter.core.project import NWProject -from novelwriter.core.tokenizer import Tokenizer, stripEscape +from novelwriter.core.tokenizer import T_Formats, Tokenizer, stripEscape logger = logging.getLogger(__name__) +HTML4_TAGS = { + Tokenizer.FMT_B_B: "", + Tokenizer.FMT_B_E: "", + Tokenizer.FMT_I_B: "", + Tokenizer.FMT_I_E: "", + Tokenizer.FMT_D_B: "", + Tokenizer.FMT_D_E: "", + Tokenizer.FMT_U_B: "", + Tokenizer.FMT_U_E: "", + Tokenizer.FMT_M_B: "", + Tokenizer.FMT_M_E: "", + Tokenizer.FMT_SUP_B: "", + Tokenizer.FMT_SUP_E: "", + Tokenizer.FMT_SUB_B: "", + Tokenizer.FMT_SUB_E: "", + Tokenizer.FMT_STRIP: "", +} + +HTML5_TAGS = { + Tokenizer.FMT_B_B: "", + Tokenizer.FMT_B_E: "", + Tokenizer.FMT_I_B: "", + Tokenizer.FMT_I_E: "", + Tokenizer.FMT_D_B: "", + Tokenizer.FMT_D_E: "", + Tokenizer.FMT_U_B: "", + Tokenizer.FMT_U_E: "", + Tokenizer.FMT_M_B: "", + Tokenizer.FMT_M_E: "", + Tokenizer.FMT_SUP_B: "", + Tokenizer.FMT_SUP_E: "", + Tokenizer.FMT_SUB_B: "", + Tokenizer.FMT_SUB_E: "", + Tokenizer.FMT_STRIP: "", +} + class ToHtml(Tokenizer): """Core: HTML Document Writer @@ -58,6 +94,7 @@ def __init__(self, project: NWProject) -> None: # Internals self._trMap = {} + self._usedNotes: dict[str, int] = {} self.setReplaceUnicode(False) return @@ -117,38 +154,9 @@ def doPreProcessing(self) -> None: def doConvert(self) -> None: """Convert the list of text tokens into an HTML document.""" - if self._genMode == self.M_PREVIEW: - htmlTags = { # HTML4 + CSS2 (for Qt) - self.FMT_B_B: "", - self.FMT_B_E: "", - self.FMT_I_B: "", - self.FMT_I_E: "", - self.FMT_D_B: "", - self.FMT_D_E: "", - self.FMT_U_B: "", - self.FMT_U_E: "", - self.FMT_M_B: "", - self.FMT_M_E: "", - } - else: - htmlTags = { # HTML5 (for export) - self.FMT_B_B: "", - self.FMT_B_E: "", - self.FMT_I_B: "", - self.FMT_I_E: "", - self.FMT_D_B: "", - self.FMT_D_E: "", - self.FMT_U_B: "", - self.FMT_U_E: "", - self.FMT_M_B: "", - self.FMT_M_E: "", - } - - htmlTags[self.FMT_SUP_B] = "" - htmlTags[self.FMT_SUP_E] = "" - htmlTags[self.FMT_SUB_B] = "" - htmlTags[self.FMT_SUB_E] = "" + self._result = "" + hTags = HTML4_TAGS if self._genMode == self.M_PREVIEW else HTML5_TAGS if self._isNovel and self._genMode != self.M_PREVIEW: # For story files, we bump the titles one level up h1Cl = " class='title'" @@ -163,12 +171,9 @@ def doConvert(self) -> None: h3 = "h3" h4 = "h4" - self._result = "" - para = [] - pStyle = None lines = [] - + pStyle = None tHandle = self._handle for tType, nHead, tText, tFormat, tStyle in self._tokens: @@ -181,11 +186,11 @@ def doConvert(self) -> None: for c in tText: if c == "<": cText.append("<") - tFormat = [[p + 3 if p > i else p, f] for p, f in tFormat] + tFormat = [(p + 3 if p > i else p, f, k) for p, f, k in tFormat] i += 4 elif c == ">": cText.append(">") - tFormat = [[p + 3 if p > i else p, f] for p, f in tFormat] + tFormat = [(p + 3 if p > i else p, f, k) for p, f, k in tFormat] i += 4 else: cText.append(c) @@ -275,21 +280,18 @@ def doConvert(self) -> None: lines.append(f"

 

\n") elif tType == self.T_TEXT: - tTemp = tText if pStyle is None: pStyle = hStyle - for pos, fmt in reversed(tFormat): - tTemp = f"{tTemp[:pos]}{htmlTags[fmt]}{tTemp[pos:]}" - para.append(stripEscape(tTemp.rstrip())) + para.append(self._formatText(tText, tFormat, hTags).rstrip()) elif tType == self.T_SYNOPSIS and self._doSynopsis: - lines.append(self._formatSynopsis(tText, True)) + lines.append(self._formatSynopsis(self._formatText(tText, tFormat, hTags), True)) elif tType == self.T_SHORT and self._doSynopsis: - lines.append(self._formatSynopsis(tText, False)) + lines.append(self._formatSynopsis(self._formatText(tText, tFormat, hTags), False)) elif tType == self.T_COMMENT and self._doComments: - lines.append(self._formatComments(tText)) + lines.append(self._formatComments(self._formatText(tText, tFormat, hTags))) elif tType == self.T_KEYWORD and self._doKeywords: tag, text = self._formatKeywords(tText) @@ -302,6 +304,27 @@ def doConvert(self) -> None: return + def appendFootnotes(self) -> None: + """Append the footnotes in the buffer.""" + if self._usedNotes: + tags = HTML4_TAGS if self._genMode == self.M_PREVIEW else HTML5_TAGS + footnotes = self._localLookup("Footnotes") + + lines = [] + lines.append(f"

{footnotes}

\n") + lines.append("
    \n") + for key, index in self._usedNotes.items(): + if content := self._footnotes.get(key): + text = self._formatText(*content, tags) + lines.append(f"
  1. {text}

  2. \n") + lines.append("
\n") + + result = "".join(lines) + self._result += result + self._fullHTML.append(result) + + return + def saveHtml5(self, path: str | Path) -> None: """Save the data to an HTML file.""" with open(path, mode="w", encoding="utf-8") as fObj: @@ -453,6 +476,23 @@ def getStyleSheet(self) -> list[str]: # Internal Functions ## + def _formatText(self, text: str, tFmt: T_Formats, tags: dict[int, str]) -> str: + """Apply formatting tags to text.""" + temp = text + for pos, fmt, data in reversed(tFmt): + html = "" + if fmt == self.FMT_FNOTE: + if data in self._footnotes: + index = len(self._usedNotes) + 1 + self._usedNotes[data] = index + html = f"{index}" + else: + html = "ERR" + else: + html = tags.get(fmt, "ERR") + temp = f"{temp[:pos]}{html}{temp[pos:]}" + return stripEscape(temp) + def _formatSynopsis(self, text: str, synopsis: bool) -> str: """Apply HTML formatting to synopsis.""" if synopsis: diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 5c26d8ea7..efeaacdef 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -24,18 +24,18 @@ """ from __future__ import annotations -import re import json import logging +import re from abc import ABC, abstractmethod -from time import time -from pathlib import Path from functools import partial +from pathlib import Path +from time import time from PyQt5.QtCore import QCoreApplication, QRegularExpression -from novelwriter.common import formatTimeStamp, numberToRoman, checkInt +from novelwriter.common import checkInt, formatTimeStamp, numberToRoman from novelwriter.constants import ( nwHeadFmt, nwKeyWords, nwLabels, nwRegEx, nwShortcode, nwUnicode, trConst ) @@ -48,6 +48,9 @@ ESCAPES = {r"\*": "*", r"\~": "~", r"\_": "_", r"\[": "[", r"\]": "]", r"\ ": ""} RX_ESC = re.compile("|".join([re.escape(k) for k in ESCAPES.keys()]), flags=re.DOTALL) +T_Formats = list[tuple[int, int, str]] +T_Comment = tuple[str, T_Formats] + def stripEscape(text: str) -> str: """Strip escaped Markdown characters from paragraph text.""" @@ -80,6 +83,8 @@ class Tokenizer(ABC): FMT_SUP_E = 12 # End superscript FMT_SUB_B = 13 # Begin subscript FMT_SUB_E = 14 # End subscript + FMT_FNOTE = 15 # Footnote marker + FMT_STRIP = 16 # Strip the format code # Block Type T_EMPTY = 1 # Empty line (new paragraph) @@ -117,17 +122,19 @@ def __init__(self, project: NWProject) -> None: self._project = project # Data Variables - self._text = "" # The raw text to be tokenized - self._handle = None # The item handle currently being processed - self._result = "" # The result of the last document + self._text = "" # The raw text to be tokenized + self._handle = None # The item handle currently being processed + self._result = "" # The result of the last document + self._keepMD = False # Whether to keep the markdown text - self._keepMarkdown = False # Whether to keep the markdown text - self._allMarkdown = [] # The result novelWriter markdown of all documents + # Tokens and Meta Data (Per Document) + self._tokens: list[tuple[int, int, str, T_Formats, int]] = [] + self._footnotes: dict[str, T_Comment] = {} - # Processed Tokens and Meta Data - self._tokens: list[tuple[int, int, str, list[tuple[int, int]], int]] = [] + # Tokens and Meta Data (Per Instance) self._counts: dict[str, int] = {} self._outline: dict[str, str] = {} + self._markdown: list[str] = [] # User Settings self._textFont = "Serif" # Output text font @@ -135,6 +142,7 @@ def __init__(self, project: NWProject) -> None: self._textFixed = False # Fixed width text self._lineHeight = 1.15 # Line height in units of em self._blockIndent = 4.00 # Block indent in units of em + self._textIndent = 1.40 # First line indent in units of em self._doJustify = False # Justify text self._doBodyText = True # Include body text self._doSynopsis = False # Also process synopsis comments @@ -150,6 +158,7 @@ def __init__(self, project: NWProject) -> None: self._marginHead4 = (0.584, 0.500) self._marginText = (0.000, 0.584) self._marginMeta = (0.000, 0.584) + self._marginFoot = (1.417, 0.467) # Title Formats self._fmtTitle = nwHeadFmt.TITLE # Formatting for titles @@ -205,6 +214,9 @@ def __init__(self, project: NWProject) -> None: nwShortcode.SUP_O: self.FMT_SUP_B, nwShortcode.SUP_C: self.FMT_SUP_E, nwShortcode.SUB_O: self.FMT_SUB_B, nwShortcode.SUB_C: self.FMT_SUB_E, } + self._shortCodeVals = { + nwShortcode.FOOTNOTE_B: self.FMT_FNOTE, + } return @@ -220,7 +232,7 @@ def result(self) -> str: @property def allMarkdown(self) -> list[str]: """The combined novelWriter Markdown text.""" - return self._allMarkdown + return self._markdown @property def textStats(self) -> dict[str, int]: @@ -387,7 +399,7 @@ def setIgnoredKeywords(self, keywords: str) -> None: def setKeepMarkdown(self, state: bool) -> None: """Keep original markdown during build.""" - self._keepMarkdown = state + self._keepMD = state return ## @@ -417,8 +429,8 @@ def addRootHeading(self, tHandle: str) -> None: self._tokens.append(( self.T_TITLE, 1, title, [], textAlign )) - if self._keepMarkdown: - self._allMarkdown.append(f"#! {title}\n\n") + if self._keepMD: + self._markdown.append(f"#! {title}\n\n") return @@ -473,6 +485,7 @@ def tokenizeText(self) -> None: nHead = 0 breakNext = False tmpMarkdown = [] + tHandle = self._handle or "" for aLine in self._text.splitlines(): sLine = aLine.strip().lower() @@ -481,7 +494,7 @@ def tokenizeText(self) -> None: self._tokens.append(( self.T_EMPTY, nHead, "", [], self.A_NONE )) - if self._keepMarkdown: + if self._keepMD: tmpMarkdown.append("\n") continue @@ -533,24 +546,32 @@ def tokenizeText(self) -> None: if aLine.startswith("%~"): continue - cStyle, cText, _ = processComment(aLine) + cStyle, cKey, cText, _, _ = processComment(aLine) if cStyle == nwComment.SYNOPSIS: + tLine, tFmt = self._extractFormats(cText) self._tokens.append(( - self.T_SYNOPSIS, nHead, cText, [], sAlign + self.T_SYNOPSIS, nHead, tLine, tFmt, sAlign )) - if self._doSynopsis and self._keepMarkdown: + if self._doSynopsis and self._keepMD: tmpMarkdown.append(f"{aLine}\n") elif cStyle == nwComment.SHORT: + tLine, tFmt = self._extractFormats(cText) self._tokens.append(( - self.T_SHORT, nHead, cText, [], sAlign + self.T_SHORT, nHead, tLine, tFmt, sAlign )) - if self._doSynopsis and self._keepMarkdown: + if self._doSynopsis and self._keepMD: + tmpMarkdown.append(f"{aLine}\n") + elif cStyle == nwComment.FOOTNOTE: + tLine, tFmt = self._extractFormats(cText, skip=self.FMT_FNOTE) + self._footnotes[f"{tHandle}:{cKey}"] = (tLine, tFmt) + if self._keepMD: tmpMarkdown.append(f"{aLine}\n") else: + tLine, tFmt = self._extractFormats(cText) self._tokens.append(( - self.T_COMMENT, nHead, cText, [], sAlign + self.T_COMMENT, nHead, tLine, tFmt, sAlign )) - if self._doComments and self._keepMarkdown: + if self._doComments and self._keepMD: tmpMarkdown.append(f"{aLine}\n") elif aLine.startswith("@"): @@ -564,7 +585,7 @@ def tokenizeText(self) -> None: self._tokens.append(( self.T_KEYWORD, nHead, aLine[1:].strip(), [], sAlign )) - if self._doKeywords and self._keepMarkdown: + if self._doKeywords and self._keepMD: tmpMarkdown.append(f"{aLine}\n") elif aLine.startswith(("# ", "#! ")): @@ -600,7 +621,7 @@ def tokenizeText(self) -> None: self._tokens.append(( tType, nHead, tText, [], tStyle )) - if self._keepMarkdown: + if self._keepMD: tmpMarkdown.append(f"{aLine}\n") elif aLine.startswith(("## ", "##! ")): @@ -635,7 +656,7 @@ def tokenizeText(self) -> None: self._tokens.append(( tType, nHead, tText, [], tStyle )) - if self._keepMarkdown: + if self._keepMD: tmpMarkdown.append(f"{aLine}\n") elif aLine.startswith(("### ", "###! ")): @@ -676,7 +697,7 @@ def tokenizeText(self) -> None: self._tokens.append(( tType, nHead, tText, [], tStyle )) - if self._keepMarkdown: + if self._keepMD: tmpMarkdown.append(f"{aLine}\n") elif aLine.startswith("#### "): @@ -706,7 +727,7 @@ def tokenizeText(self) -> None: self._tokens.append(( tType, nHead, tText, [], tStyle )) - if self._keepMarkdown: + if self._keepMD: tmpMarkdown.append(f"{aLine}\n") else: @@ -750,11 +771,11 @@ def tokenizeText(self) -> None: sAlign |= self.A_IND_R # Process formats - tLine, fmtPos = self._extractFormats(aLine) + tLine, tFmt = self._extractFormats(aLine) self._tokens.append(( - self.T_TEXT, nHead, tLine, fmtPos, sAlign + self.T_TEXT, nHead, tLine, tFmt, sAlign )) - if self._keepMarkdown: + if self._keepMD: tmpMarkdown.append(f"{aLine}\n") # If we have content, turn off the first page flag @@ -773,9 +794,9 @@ def tokenizeText(self) -> None: self._tokens.append(( self.T_EMPTY, nHead, "", [], self.A_NONE )) - if self._keepMarkdown: + if self._keepMD: tmpMarkdown.append("\n") - self._allMarkdown.append("".join(tmpMarkdown)) + self._markdown.append("".join(tmpMarkdown)) # Second Pass # =========== @@ -797,7 +818,9 @@ def tokenizeText(self) -> None: aStyle |= self.A_Z_TOPMRG if nToken[0] == self.T_KEYWORD: aStyle |= self.A_Z_BTMMRG - self._tokens[n] = (token[0], token[1], token[2], token[3], aStyle) + self._tokens[n] = ( + token[0], token[1], token[2], token[3], aStyle + ) return @@ -935,7 +958,7 @@ def countStats(self) -> None: def saveRawMarkdown(self, path: str | Path) -> None: """Save the raw text to a plain text file.""" with open(path, mode="w", encoding="utf-8") as outFile: - for nwdPage in self._allMarkdown: + for nwdPage in self._markdown: outFile.write(nwdPage) return @@ -950,7 +973,7 @@ def saveRawMarkdownJSON(self, path: str | Path) -> None: "buildTimeStr": formatTimeStamp(timeStamp), }, "text": { - "nwd": [page.rstrip("\n").split("\n") for page in self._allMarkdown], + "nwd": [page.rstrip("\n").split("\n") for page in self._markdown], } } with open(path, mode="w", encoding="utf-8") as fObj: @@ -961,9 +984,9 @@ def saveRawMarkdownJSON(self, path: str | Path) -> None: # Internal Functions ## - def _extractFormats(self, text: str) -> tuple[str, list[tuple[int, int]]]: + def _extractFormats(self, text: str, skip: int = 0) -> tuple[str, T_Formats]: """Extract format markers from a text paragraph.""" - temp = [] + temp: list[tuple[int, int, int, str]] = [] # Match Markdown for regEx, fmts in self._rxMarkdown: @@ -971,7 +994,7 @@ def _extractFormats(self, text: str) -> tuple[str, list[tuple[int, int]]]: while rxItt.hasNext(): rxMatch = rxItt.next() temp.extend( - [rxMatch.capturedStart(n), rxMatch.capturedLength(n), fmt] + (rxMatch.capturedStart(n), rxMatch.capturedLength(n), fmt, "") for n, fmt in enumerate(fmts) if fmt > 0 ) @@ -979,20 +1002,34 @@ def _extractFormats(self, text: str) -> tuple[str, list[tuple[int, int]]]: rxItt = self._rxShortCodes.globalMatch(text, 0) while rxItt.hasNext(): rxMatch = rxItt.next() - temp.append([ + temp.append(( rxMatch.capturedStart(1), rxMatch.capturedLength(1), - self._shortCodeFmt.get(rxMatch.captured(1).lower(), 0) - ]) + self._shortCodeFmt.get(rxMatch.captured(1).lower(), 0), + "", + )) + + # Match Shortcode w/Values + rxItt = self._rxShortCodeVals.globalMatch(text, 0) + tHandle = self._handle or "" + while rxItt.hasNext(): + rxMatch = rxItt.next() + kind = self._shortCodeVals.get(rxMatch.captured(1).lower(), 0) + temp.append(( + rxMatch.capturedStart(0), + rxMatch.capturedLength(0), + self.FMT_STRIP if kind == skip else kind, + f"{tHandle}:{rxMatch.captured(2)}", + )) - # Post-process text and format markers + # Post-process text and format result = text formats = [] - for pos, n, fmt in reversed(sorted(temp, key=lambda x: x[0])): + for pos, n, fmt, key in reversed(sorted(temp, key=lambda x: x[0])): if fmt > 0: result = result[:pos] + result[pos+n:] - formats = [(p-n, f) for p, f in formats] - formats.insert(0, (pos, fmt)) + formats = [(p-n, f, k) for p, f, k in formats] + formats.insert(0, (pos, fmt, key)) return result, formats diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py index 82fdb78ab..e794083a5 100644 --- a/novelwriter/core/tomd.py +++ b/novelwriter/core/tomd.py @@ -29,11 +29,50 @@ from novelwriter.constants import nwHeadFmt, nwLabels, nwUnicode from novelwriter.core.project import NWProject -from novelwriter.core.tokenizer import Tokenizer +from novelwriter.core.tokenizer import T_Formats, Tokenizer logger = logging.getLogger(__name__) +# Standard Markdown +STD_MD = { + Tokenizer.FMT_B_B: "**", + Tokenizer.FMT_B_E: "**", + Tokenizer.FMT_I_B: "_", + Tokenizer.FMT_I_E: "_", + Tokenizer.FMT_D_B: "", + Tokenizer.FMT_D_E: "", + Tokenizer.FMT_U_B: "", + Tokenizer.FMT_U_E: "", + Tokenizer.FMT_M_B: "", + Tokenizer.FMT_M_E: "", + Tokenizer.FMT_SUP_B: "", + Tokenizer.FMT_SUP_E: "", + Tokenizer.FMT_SUB_B: "", + Tokenizer.FMT_SUB_E: "", + Tokenizer.FMT_STRIP: "", +} + +# Extended Markdown +EXT_MD = { + Tokenizer.FMT_B_B: "**", + Tokenizer.FMT_B_E: "**", + Tokenizer.FMT_I_B: "_", + Tokenizer.FMT_I_E: "_", + Tokenizer.FMT_D_B: "~~", + Tokenizer.FMT_D_E: "~~", + Tokenizer.FMT_U_B: "", + Tokenizer.FMT_U_E: "", + Tokenizer.FMT_M_B: "==", + Tokenizer.FMT_M_E: "==", + Tokenizer.FMT_SUP_B: "^", + Tokenizer.FMT_SUP_E: "^", + Tokenizer.FMT_SUB_B: "~", + Tokenizer.FMT_SUB_E: "~", + Tokenizer.FMT_STRIP: "", +} + + class ToMarkdown(Tokenizer): """Core: Markdown Document Writer @@ -50,6 +89,7 @@ def __init__(self, project: NWProject) -> None: self._genMode = self.M_STD self._fullMD: list[str] = [] self._preserveBreaks = True + self._usedNotes: dict[str, int] = {} return ## @@ -90,47 +130,15 @@ def getFullResultSize(self) -> int: def doConvert(self) -> None: """Convert the list of text tokens into a Markdown document.""" + self._result = "" + if self._genMode == self.M_STD: - # Standard Markdown - mdTags = { - self.FMT_B_B: "**", - self.FMT_B_E: "**", - self.FMT_I_B: "_", - self.FMT_I_E: "_", - self.FMT_D_B: "", - self.FMT_D_E: "", - self.FMT_U_B: "", - self.FMT_U_E: "", - self.FMT_M_B: "", - self.FMT_M_E: "", - self.FMT_SUP_B: "", - self.FMT_SUP_E: "", - self.FMT_SUB_B: "", - self.FMT_SUB_E: "", - } + mTags = STD_MD cSkip = "" else: - # Extended Markdown - mdTags = { - self.FMT_B_B: "**", - self.FMT_B_E: "**", - self.FMT_I_B: "_", - self.FMT_I_E: "_", - self.FMT_D_B: "~~", - self.FMT_D_E: "~~", - self.FMT_U_B: "", - self.FMT_U_E: "", - self.FMT_M_B: "==", - self.FMT_M_E: "==", - self.FMT_SUP_B: "^", - self.FMT_SUP_E: "^", - self.FMT_SUB_B: "~", - self.FMT_SUB_E: "~", - } + mTags = EXT_MD cSkip = nwUnicode.U_MMSP - self._result = "" - para = [] lines = [] lineSep = " \n" if self._preserveBreaks else " " @@ -170,22 +178,19 @@ def doConvert(self) -> None: lines.append(f"{cSkip}\n\n") elif tType == self.T_TEXT: - tTemp = tText - for pos, fmt in reversed(tFormat): - tTemp = f"{tTemp[:pos]}{mdTags[fmt]}{tTemp[pos:]}" - para.append(tTemp.rstrip()) + para.append(self._formatText(tText, tFormat, mTags).rstrip()) elif tType == self.T_SYNOPSIS and self._doSynopsis: label = self._localLookup("Synopsis") - lines.append(f"**{label}:** {tText}\n\n") + lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n") elif tType == self.T_SHORT and self._doSynopsis: label = self._localLookup("Short Description") - lines.append(f"**{label}:** {tText}\n\n") + lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n") elif tType == self.T_COMMENT and self._doComments: label = self._localLookup("Comment") - lines.append(f"**{label}:** {tText}\n\n") + lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n") elif tType == self.T_KEYWORD and self._doKeywords: lines.append(self._formatKeywords(tText, tStyle)) @@ -195,6 +200,27 @@ def doConvert(self) -> None: return + def appendFootnotes(self) -> None: + """Append the footnotes in the buffer.""" + if self._usedNotes: + tags = STD_MD if self._genMode == self.M_STD else EXT_MD + footnotes = self._localLookup("Footnotes") + + lines = [] + lines.append(f"### {footnotes}\n\n") + for key, index in self._usedNotes.items(): + if content := self._footnotes.get(key): + marker = f"{index}. " + text = self._formatText(*content, tags) + lines.append(f"{marker}{text}\n") + lines.append("\n") + + result = "".join(lines) + self._result += result + self._fullMD.append(result) + + return + def saveMarkdown(self, path: str | Path) -> None: """Save the data to a plain text file.""" with open(path, mode="w", encoding="utf-8") as outFile: @@ -206,14 +232,31 @@ def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " ") -> None: """Replace tabs with spaces.""" spaces = spaceChar*nSpaces self._fullMD = [p.replace("\t", spaces) for p in self._fullMD] - if self._keepMarkdown: - self._allMarkdown = [p.replace("\t", spaces) for p in self._allMarkdown] + if self._keepMD: + self._markdown = [p.replace("\t", spaces) for p in self._markdown] return ## # Internal Functions ## + def _formatText(self, text: str, tFmt: T_Formats, tags: dict[int, str]) -> str: + """Apply formatting tags to text.""" + temp = text + for pos, fmt, data in reversed(tFmt): + md = "" + if fmt == self.FMT_FNOTE: + if data in self._footnotes: + index = len(self._usedNotes) + 1 + self._usedNotes[data] = index + md = f"[{index}]" + else: + md = "[ERR]" + else: + md = tags.get(fmt, "") + temp = f"{temp[:pos]}{md}{temp[pos:]}" + return temp + def _formatKeywords(self, text: str, style: int) -> str: """Apply Markdown formatting to keywords.""" valid, bits, _ = self._project.index.scanThis("@"+text) diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index 22418f326..435f6d0c5 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -29,17 +29,17 @@ import logging import xml.etree.ElementTree as ET +from collections.abc import Sequence +from datetime import datetime from hashlib import sha256 from pathlib import Path from zipfile import ZipFile -from datetime import datetime -from collections.abc import Sequence from novelwriter import __version__ from novelwriter.common import xmlIndent from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels from novelwriter.core.project import NWProject -from novelwriter.core.tokenizer import Tokenizer, stripEscape +from novelwriter.core.tokenizer import T_Formats, Tokenizer, stripEscape logger = logging.getLogger(__name__) @@ -130,6 +130,10 @@ def __init__(self, project: NWProject, isFlat: bool) -> None: self._autoPara: dict[str, ODTParagraphStyle] = {} # Auto-generated paragraph styles self._autoText: dict[int, ODTTextStyle] = {} # Auto-generated text styles + # Footnotes + self._nNote = 0 + self._etNotes: dict[str, ET.Element] = {} # Generated note elements + self._errData = [] # List of errors encountered # Properties @@ -151,6 +155,7 @@ def __init__(self, project: NWProject, isFlat: bool) -> None: self._fSizeHead4 = "14pt" self._fSizeHead = "14pt" self._fSizeText = "12pt" + self._fSizeFoot = "10pt" self._fLineHeight = "115%" self._fBlockIndent = "1.693cm" self._fTextIndent = "0.499cm" @@ -177,6 +182,9 @@ def __init__(self, project: NWProject, isFlat: bool) -> None: self._mBotText = "0.247cm" self._mBotMeta = "0.106cm" + self._mBotFoot = "0.106cm" + self._mLeftFoot = "0.600cm" + # Document Size and Margins self._mDocWidth = "21.0cm" self._mDocHeight = "29.7cm" @@ -258,6 +266,7 @@ def initDocument(self) -> None: self._fSizeHead4 = f"{round(1.15 * self._textSize):d}pt" self._fSizeHead = f"{round(1.15 * self._textSize):d}pt" self._fSizeText = f"{self._textSize:d}pt" + self._fSizeFoot = f"{round(0.8*self._textSize):d}pt" mScale = self._lineHeight/1.15 @@ -279,6 +288,9 @@ def initDocument(self) -> None: self._mBotText = self._emToCm(mScale * self._marginText[1]) self._mBotMeta = self._emToCm(mScale * self._marginMeta[1]) + self._mLeftFoot = self._emToCm(self._marginFoot[0]) + self._mBotFoot = self._emToCm(self._marginFoot[1]) + if self._colourHead: self._colHead12 = "#2a6099" self._opaHead12 = "100%" @@ -289,6 +301,7 @@ def initDocument(self) -> None: self._fLineHeight = f"{round(100 * self._lineHeight):d}%" self._fBlockIndent = self._emToCm(self._blockIndent) + self._fTextIndent = self._emToCm(self._textIndent) self._textAlign = "justify" if self._doJustify else "left" # Clear Errors @@ -399,10 +412,11 @@ def doConvert(self) -> None: """Convert the list of text tokens into XML elements.""" self._result = "" # Not used, but cleared just in case - pFmt = [] + pFmt: list[T_Formats] = [] pText = [] pStyle = None pIndent = True + xText = self._xText for tType, _, tText, tFormat, tStyle in self._tokens: # Styles @@ -444,16 +458,16 @@ def doConvert(self) -> None: if len(pText) > 0 and pStyle is not None: tTxt = "" - tFmt = [] + tFmt: T_Formats = [] for nText, nFmt in zip(pText, pFmt): tLen = len(tTxt) tTxt += f"{nText}\n" - tFmt.extend((p+tLen, fmt) for p, fmt in nFmt) + tFmt.extend((p+tLen, fmt, key) for p, fmt, key in nFmt) # Don't indent a paragraph if it has alignment set tIndent = self._firstIndent and pIndent and pStyle.isUnaligned() self._addTextPar( - "First_20_line_20_indent" if tIndent else "Text_20_body", + xText, "First_20_line_20_indent" if tIndent else "Text_20_body", pStyle, tTxt.rstrip(), tFmt=tFmt ) pIndent = True @@ -463,30 +477,31 @@ def doConvert(self) -> None: pStyle = None elif tType == self.T_TITLE: + # Title must be text:p tHead = tText.replace(nwHeadFmt.BR, "\n") - self._addTextPar("Title", oStyle, tHead, isHead=False) # Title must be text:p + self._addTextPar(xText, "Title", oStyle, tHead, isHead=False) elif tType == self.T_HEAD1: tHead = tText.replace(nwHeadFmt.BR, "\n") - self._addTextPar("Heading_20_1", oStyle, tHead, isHead=True, oLevel="1") + self._addTextPar(xText, "Heading_20_1", oStyle, tHead, isHead=True, oLevel="1") elif tType == self.T_HEAD2: tHead = tText.replace(nwHeadFmt.BR, "\n") - self._addTextPar("Heading_20_2", oStyle, tHead, isHead=True, oLevel="2") + self._addTextPar(xText, "Heading_20_2", oStyle, tHead, isHead=True, oLevel="2") elif tType == self.T_HEAD3: tHead = tText.replace(nwHeadFmt.BR, "\n") - self._addTextPar("Heading_20_3", oStyle, tHead, isHead=True, oLevel="3") + self._addTextPar(xText, "Heading_20_3", oStyle, tHead, isHead=True, oLevel="3") elif tType == self.T_HEAD4: tHead = tText.replace(nwHeadFmt.BR, "\n") - self._addTextPar("Heading_20_4", oStyle, tHead, isHead=True, oLevel="4") + self._addTextPar(xText, "Heading_20_4", oStyle, tHead, isHead=True, oLevel="4") elif tType == self.T_SEP: - self._addTextPar("Separator", oStyle, tText) + self._addTextPar(xText, "Separator", oStyle, tText) elif tType == self.T_SKIP: - self._addTextPar("Separator", oStyle, "") + self._addTextPar(xText, "Separator", oStyle, "") elif tType == self.T_TEXT: if pStyle is None: @@ -495,20 +510,20 @@ def doConvert(self) -> None: pFmt.append(tFormat) elif tType == self.T_SYNOPSIS and self._doSynopsis: - tTemp, fTemp = self._formatSynopsis(tText, True) - self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp) + tTemp, tFmt = self._formatSynopsis(tText, tFormat, True) + self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt) elif tType == self.T_SHORT and self._doSynopsis: - tTemp, fTemp = self._formatSynopsis(tText, False) - self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp) + tTemp, tFmt = self._formatSynopsis(tText, tFormat, False) + self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt) elif tType == self.T_COMMENT and self._doComments: - tTemp, fTemp = self._formatComments(tText) - self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp) + tTemp, tFmt = self._formatComments(tText, tFormat) + self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt) elif tType == self.T_KEYWORD and self._doKeywords: - tTemp, fTemp = self._formatKeywords(tText) - self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp) + tTemp, tFmt = self._formatKeywords(tText) + self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt) return @@ -569,28 +584,32 @@ def putInZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None: # Internal Functions ## - def _formatSynopsis(self, text: str, synopsis: bool) -> tuple[str, list[tuple[int, int]]]: + def _formatSynopsis(self, text: str, fmt: T_Formats, synopsis: bool) -> tuple[str, T_Formats]: """Apply formatting to synopsis lines.""" name = self._localLookup("Synopsis" if synopsis else "Short Description") + shift = len(name) + 2 rTxt = f"{name}: {text}" - rFmt = [(0, self.FMT_B_B), (len(name) + 1, self.FMT_B_E)] + rFmt: T_Formats = [(0, self.FMT_B_B, ""), (len(name) + 1, self.FMT_B_E, "")] + rFmt.extend((p + shift, f, d) for p, f, d in fmt) return rTxt, rFmt - def _formatComments(self, text: str) -> tuple[str, list[tuple[int, int]]]: + def _formatComments(self, text: str, fmt: T_Formats) -> tuple[str, T_Formats]: """Apply formatting to comments.""" name = self._localLookup("Comment") + shift = len(name) + 2 rTxt = f"{name}: {text}" - rFmt = [(0, self.FMT_B_B), (len(name) + 1, self.FMT_B_E)] + rFmt: T_Formats = [(0, self.FMT_B_B, ""), (len(name) + 1, self.FMT_B_E, "")] + rFmt.extend((p + shift, f, d) for p, f, d in fmt) return rTxt, rFmt - def _formatKeywords(self, text: str) -> tuple[str, list[tuple[int, int]]]: + def _formatKeywords(self, text: str) -> tuple[str, T_Formats]: """Apply formatting to keywords.""" valid, bits, _ = self._project.index.scanThis("@"+text) if not valid or not bits or bits[0] not in nwLabels.KEY_NAME: return "", [] rTxt = f"{self._localLookup(nwLabels.KEY_NAME[bits[0]])}: " - rFmt = [(0, self.FMT_B_B), (len(rTxt) - 1, self.FMT_B_E)] + rFmt: T_Formats = [(0, self.FMT_B_B, ""), (len(rTxt) - 1, self.FMT_B_E, "")] if len(bits) > 1: if bits[0] == nwKeyWords.TAG_KEY: rTxt += bits[1] @@ -600,8 +619,8 @@ def _formatKeywords(self, text: str) -> tuple[str, list[tuple[int, int]]]: return rTxt, rFmt def _addTextPar( - self, styleName: str, oStyle: ODTParagraphStyle, tText: str, - tFmt: Sequence[tuple[int, int]] = [], isHead: bool = False, oLevel: str | None = None + self, xParent: ET.Element, styleName: str, oStyle: ODTParagraphStyle, tText: str, + tFmt: Sequence[tuple[int, int, str]] = [], isHead: bool = False, oLevel: str | None = None ) -> None: """Add a text paragraph to the text XML element.""" tAttr = {_mkTag("text", "style-name"): self._paraStyle(styleName, oStyle)} @@ -609,7 +628,7 @@ def _addTextPar( tAttr[_mkTag("text", "outline-level")] = oLevel pTag = "h" if isHead else "p" - xElem = ET.SubElement(self._xText, _mkTag("text", pTag), attrib=tAttr) + xElem = ET.SubElement(xParent, _mkTag("text", pTag), attrib=tAttr) # It's important to set the initial text field to empty, otherwise # xmlIndent will add a line break if the first subelement is a span. @@ -627,7 +646,13 @@ def _addTextPar( xFmt = 0x00 tFrag = "" fLast = 0 - for fPos, fFmt in tFmt: + xNode = None + for fPos, fFmt, fData in tFmt: + + # Add any extra nodes + if xNode is not None: + parProc.appendNode(xNode) + xNode = None # Add the text up to the current fragment if tFrag := tText[fLast:fPos]: @@ -665,11 +690,18 @@ def _addTextPar( xFmt |= X_SUB elif fFmt == self.FMT_SUB_E: xFmt &= M_SUB + elif fFmt == self.FMT_FNOTE: + xNode = self._generateFootnote(fData) + elif fFmt == self.FMT_STRIP: + pass else: pErr += 1 fLast = fPos + if xNode is not None: + parProc.appendNode(xNode) + if tFrag := tText[fLast:]: if xFmt == 0x00: parProc.appendText(tFrag) @@ -735,6 +767,22 @@ def _textStyle(self, hFmt: int) -> str: return style.name + def _generateFootnote(self, key: str) -> ET.Element | None: + """Generate a footnote XML object.""" + if content := self._footnotes.get(key): + self._nNote += 1 + nStyle = ODTParagraphStyle("New") + xNote = ET.Element(_mkTag("text", "note"), attrib={ + _mkTag("text", "id"): f"ftn{self._nNote}", + _mkTag("text", "note-class"): "footnote", + }) + xCite = ET.SubElement(xNote, _mkTag("text", "note-citation")) + xCite.text = str(self._nNote) + xBody = ET.SubElement(xNote, _mkTag("text", "note-body")) + self._addTextPar(xBody, "Footnote", nStyle, content[0], tFmt=content[1]) + return xNote + return None + def _emToCm(self, value: float) -> str: """Converts an em value to centimetres.""" return f"{value*2.54/72*self._textSize:.3f}cm" @@ -757,7 +805,6 @@ def _pageStyles(self) -> None: _mkTag("fo", "margin-bottom"): self._mDocBtm, _mkTag("fo", "margin-left"): self._mDocLeft, _mkTag("fo", "margin-right"): self._mDocRight, - _mkTag("fo", "print-orientation"): "portrait", }) xHead = ET.SubElement(xPage, _mkTag("style", "header-style")) @@ -985,6 +1032,18 @@ def _useableStyles(self) -> None: style.packXML(self._xStyl) self._mainPara[style.name] = style + # Add Footnote Style + style = ODTParagraphStyle("Footnote") + style.setDisplayName("Footnote") + style.setParentStyleName("Standard") + style.setClass("extra") + style.setMarginLeft(self._mLeftFoot) + style.setMarginBottom(self._mBotFoot) + style.setTextIndent("-"+self._mLeftFoot) + style.setFontSize(self._fSizeFoot) + style.packXML(self._xStyl) + self._mainPara[style.name] = style + return def _writeHeader(self) -> None: @@ -1041,7 +1100,7 @@ class ODTParagraphStyle: VALID_ALIGN = ["start", "center", "end", "justify", "inside", "outside", "left", "right"] VALID_BREAK = ["auto", "column", "page", "even-page", "odd-page", "inherit"] VALID_LEVEL = ["1", "2", "3", "4"] - VALID_CLASS = ["text", "chapter"] + VALID_CLASS = ["text", "chapter", "extra"] VALID_WEIGHT = ["normal", "inherit", "bold"] def __init__(self, name: str) -> None: @@ -1464,7 +1523,6 @@ def appendText(self, text: str) -> None: if c == " ": nSpaces += 1 continue - elif nSpaces > 0: self._processSpaces(nSpaces) nSpaces = 0 @@ -1475,26 +1533,22 @@ def appendText(self, text: str) -> None: self._xTail.tail = "" self._nState = X_ROOT_TAIL self._chrPos += 1 - elif self._nState in (X_SPAN_TEXT, X_SPAN_SING): self._xSing = ET.SubElement(self._xTail, TAG_BR) self._xSing.tail = "" self._nState = X_SPAN_SING self._chrPos += 1 - elif c == "\t": if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL): self._xTail = ET.SubElement(self._xRoot, TAG_TAB) self._xTail.tail = "" self._nState = X_ROOT_TAIL self._chrPos += 1 - elif self._nState in (X_SPAN_TEXT, X_SPAN_SING): self._xSing = ET.SubElement(self._xTail, TAG_TAB) self._xSing.tail = "" self._chrPos += 1 self._nState = X_SPAN_SING - else: if self._nState == X_ROOT_TEXT: self._xRoot.text = (self._xRoot.text or "") + c @@ -1529,6 +1583,19 @@ def appendSpan(self, text: str, fmt: str) -> None: self._nState = X_ROOT_TAIL return + def appendNode(self, xNode: ET.Element | None) -> None: + """Append an XML node to the paragraph. We only check for the + X_ROOT_TEXT and X_ROOT_TAIL states. X_SPAN_TEXT is not possible + at all, and X_SPAN_SING only happens internally in an appendSpan + call, returning us to an X_ROOT_TAIL state. + """ + if xNode is not None and self._nState in (X_ROOT_TEXT, X_ROOT_TAIL): + self._xRoot.append(xNode) + self._xTail = xNode + self._xTail.tail = "" + self._nState = X_ROOT_TAIL + return + def checkError(self) -> tuple[int, str]: """Check that the number of characters written matches the number of characters received. diff --git a/novelwriter/enum.py b/novelwriter/enum.py index 6a6dd2cb2..eae462a08 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -65,8 +65,13 @@ class nwItemLayout(Enum): class nwComment(Enum): PLAIN = 0 - SYNOPSIS = 1 - SHORT = 2 + IGNORE = 1 + SYNOPSIS = 2 + SHORT = 3 + NOTE = 4 + FOOTNOTE = 5 + COMMENT = 6 + STORY = 7 # END Enum nwComment @@ -145,6 +150,7 @@ class nwDocInsert(Enum): VSPACE_S = 8 VSPACE_M = 9 LIPSUM = 10 + FOOTNOTE = 11 # END Enum nwDocInsert diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index bb09e72f0..a0c1a7f72 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -39,8 +39,8 @@ from typing import TYPE_CHECKING from PyQt5.QtCore import ( - pyqtSignal, pyqtSlot, QObject, QPoint, QRegularExpression, QRunnable, Qt, - QTimer + QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal, + pyqtSlot ) from PyQt5.QtGui import ( QColor, QCursor, QFont, QKeyEvent, QKeySequence, QMouseEvent, QPalette, @@ -55,7 +55,7 @@ from novelwriter.common import minmax, transferCase from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode from novelwriter.core.document import NWDocument -from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary +from novelwriter.enum import nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary from novelwriter.extensions.eventfilters import WheelEventFilter from novelwriter.extensions.modified import NIconToggleButton, NIconToolButton from novelwriter.gui.dochighlight import BLOCK_META, BLOCK_TITLE @@ -65,7 +65,7 @@ from novelwriter.tools.lipsum import GuiLipsum from novelwriter.types import ( QtAlignCenterTop, QtAlignJustify, QtAlignLeft, QtAlignLeftTop, - QtAlignRight, QtKeepAnchor, QtModCtrl, QtMouseLeft, QtModeNone, QtModShift, + QtAlignRight, QtKeepAnchor, QtModCtrl, QtModeNone, QtModShift, QtMouseLeft, QtMoveAnchor, QtMoveLeft, QtMoveRight ) @@ -840,14 +840,15 @@ def revealLocation(self) -> None: ) return - def insertText(self, insert: str | nwDocInsert) -> bool: + def insertText(self, insert: str | nwDocInsert) -> None: """Insert a specific type of text at the cursor position.""" if self._docHandle is None: logger.error("No document open") - return False + return - newBlock = False - goAfter = False + text = "" + block = False + after = False if isinstance(insert, str): text = insert @@ -862,43 +863,41 @@ def insertText(self, insert: str | nwDocInsert) -> bool: text = self._typDQuoteC elif insert == nwDocInsert.SYNOPSIS: text = "%Synopsis: " - newBlock = True - goAfter = True + block = True + after = True elif insert == nwDocInsert.SHORT: text = "%Short: " - newBlock = True - goAfter = True + block = True + after = True elif insert == nwDocInsert.NEW_PAGE: text = "[newpage]" - newBlock = True - goAfter = False + block = True + after = False elif insert == nwDocInsert.VSPACE_S: text = "[vspace]" - newBlock = True - goAfter = False + block = True + after = False elif insert == nwDocInsert.VSPACE_M: text = "[vspace:2]" - newBlock = True - goAfter = False + block = True + after = False elif insert == nwDocInsert.LIPSUM: text = GuiLipsum.getLipsum(self) - newBlock = True - goAfter = False - else: - return False - else: - return False + block = True + after = False + elif insert == nwDocInsert.FOOTNOTE: + self._insertCommentStructure(nwComment.FOOTNOTE) if text: - if newBlock: - self.insertNewBlock(text, defaultAfter=goAfter) + if block: + self.insertNewBlock(text, defaultAfter=after) else: cursor = self.textCursor() cursor.beginEditBlock() cursor.insertText(text) cursor.endEditBlock() - return True + return def insertNewBlock(self, text: str, defaultAfter: bool = True) -> bool: """Insert a piece of text on a blank line.""" @@ -1164,7 +1163,8 @@ def _openContextMenu(self, pos: QPoint) -> None: lambda _, option=option: self._correctWord(sCursor, option) ) else: - ctxMenu.addAction("%s %s" % (nwUnicode.U_ENDASH, self.tr("No Suggestions"))) + trNone = self.tr("No Suggestions") + ctxMenu.addAction(f"{nwUnicode.U_ENDASH} {trNone}") ctxMenu.addSeparator() action = ctxMenu.addAction(self.tr("Add Word to Dictionary")) @@ -1850,6 +1850,32 @@ def _removeInParLineBreaks(self) -> None: return + def _insertCommentStructure(self, style: nwComment) -> None: + """Insert a shortcut/comment combo.""" + if self._docHandle and style == nwComment.FOOTNOTE: + self.saveText() # Index must be up to date + key = SHARED.project.index.newCommentKey(self._docHandle, style) + code = nwShortcode.COMMENT_STYLES[nwComment.FOOTNOTE] + + cursor = self.textCursor() + block = cursor.block() + text = block.text().rstrip() + if not text or text.startswith("@"): + logger.error("Invalid footnote location") + return + + cursor.beginEditBlock() + cursor.insertText(code.format(key)) + cursor.setPosition(block.position() + block.length() - 1) + cursor.insertBlock() + cursor.insertBlock() + cursor.insertText(f"%Footnote.{key}: ") + cursor.endEditBlock() + + self.setTextCursor(cursor) + + return + ## # Internal Functions ## diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index d9919bb96..643f339bc 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -28,26 +28,27 @@ from time import time -from PyQt5.QtCore import Qt, QRegularExpression +from PyQt5.QtCore import QRegularExpression, Qt from PyQt5.QtGui import ( QBrush, QColor, QFont, QSyntaxHighlighter, QTextBlockUserData, QTextCharFormat, QTextDocument ) from novelwriter import CONFIG, SHARED -from novelwriter.enum import nwComment from novelwriter.common import checkInt from novelwriter.constants import nwRegEx, nwUnicode from novelwriter.core.index import processComment +from novelwriter.enum import nwComment +from novelwriter.types import QRegExUnicode logger = logging.getLogger(__name__) SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—\[\]:]+\b") -SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) +SPELLRX.setPatternOptions(QRegExUnicode) SPELLSC = QRegularExpression(nwRegEx.FMT_SC) -SPELLSC.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) +SPELLSC.setPatternOptions(QRegExUnicode) SPELLSV = QRegularExpression(nwRegEx.FMT_SV) -SPELLSV.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) +SPELLSV.setPatternOptions(QRegExUnicode) BLOCK_NONE = 0 BLOCK_TEXT = 1 @@ -57,8 +58,10 @@ class GuiDocHighlighter(QSyntaxHighlighter): - __slots__ = ("_tHandle", "_isInactive", "_spellCheck", "_spellErr", - "_hRules", "_hStyles", "_rxRules") + __slots__ = ( + "_tHandle", "_isInactive", "_spellCheck", "_spellErr", "_hStyles", + "_txtRules", "_cmnRules", + ) def __init__(self, document: QTextDocument) -> None: super().__init__(document) @@ -70,9 +73,9 @@ def __init__(self, document: QTextDocument) -> None: self._spellCheck = False self._spellErr = QTextCharFormat() - self._hRules: list[tuple[str, dict]] = [] self._hStyles: dict[str, QTextCharFormat] = {} - self._rxRules: list[tuple[QRegularExpression, dict[str, QTextCharFormat]]] = [] + self._txtRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = [] + self._cmnRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = [] self.initHighlighter() @@ -90,34 +93,32 @@ def initHighlighter(self) -> None: colBreak = QColor(SHARED.theme.colEmph) colBreak.setAlpha(64) - self._hRules = [] - self._hStyles = { - "header1": self._makeFormat(SHARED.theme.colHead, "bold", 1.8), - "header2": self._makeFormat(SHARED.theme.colHead, "bold", 1.6), - "header3": self._makeFormat(SHARED.theme.colHead, "bold", 1.4), - "header4": self._makeFormat(SHARED.theme.colHead, "bold", 1.2), - "head1h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.8), - "head2h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.6), - "head3h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.4), - "head4h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.2), - "bold": self._makeFormat(colEmph, "bold"), - "italic": self._makeFormat(colEmph, "italic"), - "strike": self._makeFormat(SHARED.theme.colHidden, "strike"), - "mspaces": self._makeFormat(SHARED.theme.colError, "errline"), - "nobreak": self._makeFormat(colBreak, "background"), - "dialogue1": self._makeFormat(SHARED.theme.colDialN), - "dialogue2": self._makeFormat(SHARED.theme.colDialD), - "dialogue3": self._makeFormat(SHARED.theme.colDialS), - "replace": self._makeFormat(SHARED.theme.colRepTag), - "hidden": self._makeFormat(SHARED.theme.colHidden), - "code": self._makeFormat(SHARED.theme.colCode), - "keyword": self._makeFormat(SHARED.theme.colKey), - "modifier": self._makeFormat(SHARED.theme.colMod), - "value": self._makeFormat(SHARED.theme.colVal), - "optional": self._makeFormat(SHARED.theme.colOpt), - "codevalue": self._makeFormat(SHARED.theme.colVal), - "codeinval": self._makeFormat(None, "errline"), - } + # Create Character Formats + self._addCharFormat("header1", SHARED.theme.colHead, "b", 1.8) + self._addCharFormat("header2", SHARED.theme.colHead, "b", 1.6) + self._addCharFormat("header3", SHARED.theme.colHead, "b", 1.4) + self._addCharFormat("header4", SHARED.theme.colHead, "b", 1.2) + self._addCharFormat("head1h", SHARED.theme.colHeadH, "b", 1.8) + self._addCharFormat("head2h", SHARED.theme.colHeadH, "b", 1.6) + self._addCharFormat("head3h", SHARED.theme.colHeadH, "b", 1.4) + self._addCharFormat("head4h", SHARED.theme.colHeadH, "b", 1.2) + self._addCharFormat("bold", colEmph, "b") + self._addCharFormat("italic", colEmph, "i") + self._addCharFormat("strike", SHARED.theme.colHidden, "s") + self._addCharFormat("mspaces", SHARED.theme.colError, "err") + self._addCharFormat("nobreak", colBreak, "bg") + self._addCharFormat("dialog1", SHARED.theme.colDialN) + self._addCharFormat("dialog2", SHARED.theme.colDialD) + self._addCharFormat("dialog3", SHARED.theme.colDialS) + self._addCharFormat("replace", SHARED.theme.colRepTag) + self._addCharFormat("hidden", SHARED.theme.colHidden) + self._addCharFormat("markup", SHARED.theme.colHidden) + self._addCharFormat("code", SHARED.theme.colCode) + self._addCharFormat("keyword", SHARED.theme.colKey) + self._addCharFormat("modifier", SHARED.theme.colMod) + self._addCharFormat("value", SHARED.theme.colVal) + self._addCharFormat("optional", SHARED.theme.colOpt) + self._addCharFormat("invalid", None, "err") # Cache Spell Error Format self._spellErr = QTextCharFormat() @@ -126,18 +127,22 @@ def initHighlighter(self) -> None: # Multiple or Trailing Spaces if CONFIG.showMultiSpaces: - self._hRules.append(( - r"[ ]{2,}|[ ]*$", { - 0: self._hStyles["mspaces"], - } - )) + rxRule = QRegularExpression(r"[ ]{2,}|[ ]*$") + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 0: self._hStyles["mspaces"], + } + self._txtRules.append((rxRule, hlRule)) + self._cmnRules.append((rxRule, hlRule)) # Non-Breaking Spaces - self._hRules.append(( - f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+", { - 0: self._hStyles["nobreak"], - } - )) + rxRule = QRegularExpression(f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+") + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 0: self._hStyles["nobreak"], + } + self._txtRules.append((rxRule, hlRule)) + self._cmnRules.append((rxRule, hlRule)) # Quoted Strings if CONFIG.highlightQuotes: @@ -147,88 +152,100 @@ def initHighlighter(self) -> None: fmtSngC = CONFIG.fmtSQuoteClose # Straight Quotes - if not (fmtDblO == fmtDblC == "\""): - self._hRules.append(( - "(\\B\")(.*?)(\"\\B)", { - 0: self._hStyles["dialogue1"], - } - )) + rxRule = QRegularExpression(r'(\B")(.*?)("\B)') + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 0: self._hStyles["dialog1"], + } + self._txtRules.append((rxRule, hlRule)) # Double Quotes dblEnd = "|$" if CONFIG.allowOpenDQuote else "" - self._hRules.append(( - f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})", { - 0: self._hStyles["dialogue2"], - } - )) + rxRule = QRegularExpression(f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})") + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 0: self._hStyles["dialog2"], + } + self._txtRules.append((rxRule, hlRule)) # Single Quotes sngEnd = "|$" if CONFIG.allowOpenSQuote else "" - self._hRules.append(( - f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})", { - 0: self._hStyles["dialogue3"], - } - )) - - # Markdown Syntax - self._hRules.append(( - nwRegEx.FMT_EI, { - 1: self._hStyles["hidden"], - 2: self._hStyles["italic"], - 3: self._hStyles["hidden"], + rxRule = QRegularExpression(f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})") + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 0: self._hStyles["dialog3"], } - )) - self._hRules.append(( - nwRegEx.FMT_EB, { - 1: self._hStyles["hidden"], - 2: self._hStyles["bold"], - 3: self._hStyles["hidden"], - } - )) - self._hRules.append(( - nwRegEx.FMT_ST, { - 1: self._hStyles["hidden"], - 2: self._hStyles["strike"], - 3: self._hStyles["hidden"], - } - )) + self._txtRules.append((rxRule, hlRule)) + + # Markdown Italic + rxRule = QRegularExpression(nwRegEx.FMT_EI) + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 1: self._hStyles["markup"], + 2: self._hStyles["italic"], + 3: self._hStyles["markup"], + } + self._txtRules.append((rxRule, hlRule)) + self._cmnRules.append((rxRule, hlRule)) + + # Markdown Bold + rxRule = QRegularExpression(nwRegEx.FMT_EB) + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 1: self._hStyles["markup"], + 2: self._hStyles["bold"], + 3: self._hStyles["markup"], + } + self._txtRules.append((rxRule, hlRule)) + self._cmnRules.append((rxRule, hlRule)) + + # Markdown Strikethrough + rxRule = QRegularExpression(nwRegEx.FMT_ST) + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 1: self._hStyles["markup"], + 2: self._hStyles["strike"], + 3: self._hStyles["markup"], + } + self._txtRules.append((rxRule, hlRule)) + self._cmnRules.append((rxRule, hlRule)) # Shortcodes - self._hRules.append(( - nwRegEx.FMT_SC, { - 1: self._hStyles["code"], - } - )) + rxRule = QRegularExpression(nwRegEx.FMT_SC) + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 1: self._hStyles["code"], + } + self._txtRules.append((rxRule, hlRule)) + self._cmnRules.append((rxRule, hlRule)) # Shortcodes w/Value - self._hRules.append(( - nwRegEx.FMT_SV, { - 1: self._hStyles["code"], - 2: self._hStyles["codevalue"], - 3: self._hStyles["code"], - } - )) + rxRule = QRegularExpression(nwRegEx.FMT_SV) + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 1: self._hStyles["code"], + 2: self._hStyles["value"], + 3: self._hStyles["code"], + } + self._txtRules.append((rxRule, hlRule)) + self._cmnRules.append((rxRule, hlRule)) # Alignment Tags - self._hRules.append(( - r"(^>{1,2}|<{1,2}$)", { - 1: self._hStyles["hidden"], - } - )) + rxRule = QRegularExpression(r"(^>{1,2}|<{1,2}$)") + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 1: self._hStyles["markup"], + } + self._txtRules.append((rxRule, hlRule)) # Auto-Replace Tags - self._hRules.append(( - r"<(\S+?)>", { - 0: self._hStyles["replace"], - } - )) - - # Build a QRegExp for each highlight pattern - self._rxRules = [] - for regEx, regRules in self._hRules: - hReg = QRegularExpression(regEx) - hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) - self._rxRules.append((hReg, regRules)) + rxRule = QRegularExpression(r"<(\S+?)>") + rxRule.setPatternOptions(QRegExUnicode) + hlRule = { + 0: self._hStyles["replace"], + } + self._txtRules.append((rxRule, hlRule)) + self._cmnRules.append((rxRule, hlRule)) return @@ -282,6 +299,8 @@ def highlightBlock(self, text: str) -> None: if self._tHandle is None or not text: return + xOff = 0 + hRules = None if text.startswith("@"): # Keywords and commands self.setCurrentBlockState(BLOCK_META) index = SHARED.project.index @@ -300,7 +319,7 @@ def highlightBlock(self, text: str) -> None: yPos = xPos + len(bit) - len(two) self.setFormat(yPos, len(two), self._hStyles["optional"]) elif not self._isInactive: - self.setFormat(xPos, xLen, self._hStyles["codeinval"]) + self.setFormat(xPos, xLen, self._hStyles["invalid"]) # We never want to run the spell checker on keyword/values, # so we force a return here @@ -339,43 +358,58 @@ def highlightBlock(self, text: str) -> None: elif text.startswith("%"): # Comments self.setCurrentBlockState(BLOCK_TEXT) - cStyle, _, cPos = processComment(text) + hRules = self._cmnRules + + cStyle, cMod, _, cDot, cPos = processComment(text) + cLen = len(text) - cPos + xOff = cPos if cStyle == nwComment.PLAIN: - self.setFormat(0, len(text), self._hStyles["hidden"]) + self.setFormat(0, cLen, self._hStyles["hidden"]) + elif cStyle == nwComment.IGNORE: + self.setFormat(0, cLen, self._hStyles["strike"]) + return # No more processing for these + elif cMod: + self.setFormat(0, cDot, self._hStyles["modifier"]) + self.setFormat(cDot, cPos - cDot, self._hStyles["optional"]) + self.setFormat(cPos, cLen, self._hStyles["hidden"]) else: self.setFormat(0, cPos, self._hStyles["modifier"]) - self.setFormat(cPos, len(text), self._hStyles["hidden"]) + self.setFormat(cPos, cLen, self._hStyles["hidden"]) - else: # Text Paragraph + elif text.startswith("["): # Special Command + self.setCurrentBlockState(BLOCK_TEXT) + hRules = self._txtRules + + sText = text.rstrip().lower() + if sText in ("[newpage]", "[new page]", "[vspace]"): + self.setFormat(0, len(text), self._hStyles["code"]) + return + elif sText.startswith("[vspace:") and sText.endswith("]"): + tLen = len(sText) + tVal = checkInt(sText[8:-1], 0) + cVal = "value" if tVal > 0 else "invalid" + self.setFormat(0, 8, self._hStyles["code"]) + self.setFormat(8, tLen-9, self._hStyles[cVal]) + self.setFormat(tLen-1, tLen, self._hStyles["code"]) + return - if text.startswith("["): # Special Command - sText = text.rstrip().lower() - if sText in ("[newpage]", "[new page]", "[vspace]"): - self.setFormat(0, len(text), self._hStyles["code"]) - return - elif sText.startswith("[vspace:") and sText.endswith("]"): - tLen = len(sText) - tVal = checkInt(sText[8:-1], 0) - cVal = "codevalue" if tVal > 0 else "codeinval" - self.setFormat(0, 8, self._hStyles["code"]) - self.setFormat(8, tLen-9, self._hStyles[cVal]) - self.setFormat(tLen-1, tLen, self._hStyles["code"]) - return - - # Regular Text + else: # Text Paragraph self.setCurrentBlockState(BLOCK_TEXT) - for rX, xFmt in self._rxRules: - rxItt = rX.globalMatch(text, 0) + hRules = self._txtRules + + if hRules: + for rX, hRule in hRules: + rxItt = rX.globalMatch(text, xOff) while rxItt.hasNext(): rxMatch = rxItt.next() - for xM in xFmt: + for xM, hFmt in hRule.items(): xPos = rxMatch.capturedStart(xM) - xLen = rxMatch.capturedLength(xM) - for x in range(xPos, xPos+xLen): - spFmt = self.format(x) - if spFmt != self._hStyles["hidden"]: - spFmt.merge(xFmt[xM]) - self.setFormat(x, 1, spFmt) + xEnd = rxMatch.capturedEnd(xM) + for x in range(xPos, xEnd): + cFmt = self.format(x) + if cFmt.fontStyleName() != "markup": + cFmt.merge(hFmt) + self.setFormat(x, 1, cFmt) data = self.currentBlockUserData() if not isinstance(data, TextBlockData): @@ -383,11 +417,11 @@ def highlightBlock(self, text: str) -> None: self.setCurrentBlockUserData(data) if self._spellCheck: - for xPos, xLen in data.spellCheck(text): + for xPos, xLen in data.spellCheck(text, xOff): for x in range(xPos, xPos+xLen): - spFmt = self.format(x) - spFmt.merge(self._spellErr) - self.setFormat(x, 1, spFmt) + cFmt = self.format(x) + cFmt.merge(self._spellErr) + self.setFormat(x, 1, cFmt) return @@ -395,34 +429,37 @@ def highlightBlock(self, text: str) -> None: # Internal Functions ## - def _makeFormat(self, color: QColor | None = None, style: str | None = None, - size: float | None = None) -> QTextCharFormat: - """Generate a valid character format to be applied to the text - that is to be highlighted. - """ + def _addCharFormat( + self, name: str, color: QColor | None = None, + style: str | None = None, size: float | None = None + ) -> None: + """Generate a highlighter character format.""" charFormat = QTextCharFormat() + charFormat.setFontStyleName(name) - if color is not None: + if color: charFormat.setForeground(color) - if style is not None: + if style: styles = style.split(",") - if "bold" in styles: + if "b" in styles: charFormat.setFontWeight(QFont.Weight.Bold) - if "italic" in styles: + if "i" in styles: charFormat.setFontItalic(True) - if "strike" in styles: + if "s" in styles: charFormat.setFontStrikeOut(True) - if "errline" in styles: + if "err" in styles: charFormat.setUnderlineColor(SHARED.theme.colError) charFormat.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline) - if "background" in styles and color is not None: + if "bg" in styles and color is not None: charFormat.setBackground(QBrush(color, Qt.BrushStyle.SolidPattern)) - if size is not None: - charFormat.setFontPointSize(int(round(size*CONFIG.textSize))) + if size: + charFormat.setFontPointSize(round(size*CONFIG.textSize)) + + self._hStyles[name] = charFormat - return charFormat + return # END Class GuiDocHighlighter @@ -441,14 +478,14 @@ def spellErrors(self) -> list[tuple[int, int]]: """Return spell error data from last check.""" return self._spellErrors - def spellCheck(self, text: str) -> list[tuple[int, int]]: + def spellCheck(self, text: str, offset: int) -> list[tuple[int, int]]: """Run the spell checker and cache the result, and return the list of spell check errors. """ if "[" in text: # Strip shortcodes for rX in [SPELLSC, SPELLSV]: - rxItt = rX.globalMatch(text, 0) + rxItt = rX.globalMatch(text, offset) while rxItt.hasNext(): rxMatch = rxItt.next() xPos = rxMatch.capturedStart(0) @@ -457,12 +494,14 @@ def spellCheck(self, text: str) -> list[tuple[int, int]]: text = text[:xPos] + " "*xLen + text[xEnd:] self._spellErrors = [] - rxSpell = SPELLRX.globalMatch(text.replace("_", " "), 0) + rxSpell = SPELLRX.globalMatch(text.replace("_", " "), offset) while rxSpell.hasNext(): rxMatch = rxSpell.next() if not SHARED.spelling.checkWord(rxMatch.captured(0)): if not rxMatch.captured(0).isnumeric() and not rxMatch.captured(0).isupper(): - self._spellErrors.append((rxMatch.capturedStart(0), rxMatch.capturedLength(0))) + self._spellErrors.append( + (rxMatch.capturedStart(0), rxMatch.capturedLength(0)) + ) return self._spellErrors # END Class TextBlockData diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index bc5caeb50..daa4566af 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -215,6 +215,7 @@ def loadText(self, tHandle: str, updateHistory: bool = True) -> bool: aDoc.doPreProcessing() aDoc.tokenizeText() aDoc.doConvert() + aDoc.appendFootnotes() except Exception: logger.error("Failed to generate preview for document with handle '%s'", tHandle) logException() diff --git a/novelwriter/gui/itemdetails.py b/novelwriter/gui/itemdetails.py index 1a90d2a3b..486acf29a 100644 --- a/novelwriter/gui/itemdetails.py +++ b/novelwriter/gui/itemdetails.py @@ -26,12 +26,14 @@ import logging from PyQt5.QtCore import pyqtSlot -from PyQt5.QtWidgets import QWidget, QGridLayout, QLabel +from PyQt5.QtWidgets import QGridLayout, QLabel, QWidget from novelwriter import CONFIG, SHARED -from novelwriter.constants import trConst, nwLabels +from novelwriter.common import elide +from novelwriter.constants import nwLabels, trConst from novelwriter.types import ( - QtAlignLeft, QtAlignLeftBase, QtAlignRight, QtAlignRightBase, QtAlignRightMiddle + QtAlignLeft, QtAlignLeftBase, QtAlignRight, QtAlignRightBase, + QtAlignRightMiddle ) logger = logging.getLogger(__name__) @@ -236,10 +238,6 @@ def updateViewBox(self, tHandle: str) -> None: # Label # ===== - label = nwItem.itemName - if len(label) > 100: - label = label[:96].rstrip()+" ..." - if nwItem.isFileType(): if nwItem.isActive: self.labelIcon.setPixmap(SHARED.theme.getPixmap("checked", (iPx, iPx))) @@ -248,7 +246,7 @@ def updateViewBox(self, tHandle: str) -> None: else: self.labelIcon.setPixmap(SHARED.theme.getPixmap("noncheckable", (iPx, iPx))) - self.labelData.setText(label) + self.labelData.setText(elide(nwItem.itemName, 100)) # Status # ====== diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 960dee331..7d6b05cf0 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -597,6 +597,12 @@ def _buildInsertMenu(self) -> None: lambda: self.requestDocInsert.emit(nwDocInsert.LIPSUM) ) + # Insert > Footnote + self.aFootnote = self.insMenu.addAction(self.tr("Footnote")) + self.aFootnote.triggered.connect( + lambda: self.requestDocInsert.emit(nwDocInsert.FOOTNOTE) + ) + return def _buildFormatMenu(self) -> None: diff --git a/novelwriter/types.py b/novelwriter/types.py index 49e670ce5..a9fc015db 100644 --- a/novelwriter/types.py +++ b/novelwriter/types.py @@ -23,7 +23,7 @@ """ from __future__ import annotations -from PyQt5.QtCore import Qt +from PyQt5.QtCore import QRegularExpression, Qt from PyQt5.QtGui import QColor, QPainter, QTextCursor from PyQt5.QtWidgets import QDialogButtonBox, QSizePolicy, QStyle @@ -96,3 +96,7 @@ QtSizeIgnored = QSizePolicy.Policy.Ignored QtSizeMinimum = QSizePolicy.Policy.Minimum QtSizeMinimumExpanding = QSizePolicy.Policy.MinimumExpanding + +# Other + +QRegExUnicode = QRegularExpression.PatternOption.UseUnicodePropertiesOption diff --git a/pyproject.toml b/pyproject.toml index d9584ccaa..75bbabb90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ version = {attr = "novelwriter.__version__"} include = ["novelwriter*"] [tool.isort] -py_version="38" +py_version="310" line_length = 99 wrap_length = 79 multi_line_output = 5 diff --git a/requirements-dev.txt b/requirements-dev.txt index 0e13adfc0..8499c6e83 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,3 +2,4 @@ flake8 flake8-pep585 flake8-pyproject flake8-annotations +isort diff --git a/sample/content/636b6aa9b697b.nwd b/sample/content/636b6aa9b697b.nwd index ed5bacb6b..e1f5955ea 100644 --- a/sample/content/636b6aa9b697b.nwd +++ b/sample/content/636b6aa9b697b.nwd @@ -1,8 +1,8 @@ %%~name: Making a Scene %%~path: 6a2d6d5f4f401/636b6aa9b697b %%~kind: NOVEL/DOCUMENT -%%~hash: e1c58699b05b512306a534da70c2952aafc60e8b -%%~date: Unknown/2024-02-25 16:33:40 +%%~hash: c7e664867218b3a9aac5c12119ef0ec63da2e5cc +%%~date: Unknown/2024-04-18 17:56:30 ### Making a Scene @pov: Jane @@ -21,7 +21,9 @@ If you have the need for it, you can also add text that can be automatically rep The editor also supports non breaking spaces, and the spell checker accepts long dashes—like this—as valid word separators. Regular dashes are also supported – and can be automatically inserted when typing two hyphens. -Thin spaces and thin non-breaking spaces are also supported from the Insert menu, and can be used to separate numbers from their units, like: 25 kg. +Thin spaces and thin non-breaking spaces are also supported from the Insert menu, and can be used to separate numbers from their units, like: 25 kg.[footnote:f4xr5] + +%Footnote.f4xr5: Using a non-breaking space is the correct way to separate a number from its unit. This ensures that line wrapping does not split the two. #### Some Section Here diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index 8dcd5bcd0..37feacecc 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Sample Project Jane Smith @@ -36,7 +36,7 @@ Main - + Novel @@ -46,7 +46,7 @@ Title Page - + Page @@ -58,11 +58,11 @@ Chapter One - + Making a Scene - + Another Scene diff --git a/tests/lipsum/content/88d59a277361b.nwd b/tests/lipsum/content/88d59a277361b.nwd index fd6317ac2..e0119b304 100644 --- a/tests/lipsum/content/88d59a277361b.nwd +++ b/tests/lipsum/content/88d59a277361b.nwd @@ -1,10 +1,12 @@ %%~name: Prologue %%~path: b3643d0f92e32/88d59a277361b %%~kind: NOVEL/DOCUMENT -%%~hash: 5f965566ba82bbb83b8aa24f3ab7efde0c5e61cf -%%~date: Unknown/2024-01-30 21:36:00 +%%~hash: 605a96ba35297cd7d49b753b3bda9a20b9b29d93 +%%~date: Unknown/2024-04-27 16:40:18 ##! Prologue % Synopsis: Explanation from the lipsum.com website. -_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. +_Lorem Ipsum_ is simply dummy text[footnote:f9kgf] of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. + +%Footnote.f9kgf: _Lorem ipsum_ is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. (Source: Wikipedia) diff --git a/tests/lipsum/nwProject.nwx b/tests/lipsum/nwProject.nwx index 66aa29e55..f619c34c7 100644 --- a/tests/lipsum/nwProject.nwx +++ b/tests/lipsum/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Lorem Ipsum lipsum.com @@ -9,7 +9,7 @@ en_GB None - 7a992350f3eb6 + 88d59a277361b None b3643d0f92e32 None @@ -19,16 +19,16 @@ Replace Text 2 - New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main @@ -45,7 +45,7 @@ Front Matter - + Prologue diff --git a/tests/reference/coreIndex_LoadSave_tagsIndex.json b/tests/reference/coreIndex_LoadSave_tagsIndex.json index 4c10214ad..39bf19799 100644 --- a/tests/reference/coreIndex_LoadSave_tagsIndex.json +++ b/tests/reference/coreIndex_LoadSave_tagsIndex.json @@ -17,7 +17,10 @@ }, "88d59a277361b": { "headings": { - "T0001": {"level": "H2", "title": "Prologue", "line": 1, "tag": "", "cCount": 584, "wCount": 92, "pCount": 1, "synopsis": "Explanation from the lipsum.com website."} + "T0001": {"level": "H2", "title": "Prologue", "line": 1, "tag": "", "cCount": 600, "wCount": 92, "pCount": 1, "synopsis": "Explanation from the lipsum.com website."} + }, + "notes": { + "footnotes": ["f9kgf"] } }, "db7e733775d4d": { diff --git a/tests/reference/coreToOdt_SaveFlat_document.fodt b/tests/reference/coreToOdt_SaveFlat_document.fodt index b97f7ceaf..78c9ab59a 100644 --- a/tests/reference/coreToOdt_SaveFlat_document.fodt +++ b/tests/reference/coreToOdt_SaveFlat_document.fodt @@ -1,13 +1,13 @@ - 2024-03-14T23:26:28 - novelWriter/2.4a2 + 2024-04-24T18:30:38 + novelWriter/2.5a2 Jane Smith 1234 P42DT12H34M56S Test Project - 2024-03-14T23:26:28 + 2024-04-24T18:30:38 Jane Smith @@ -31,7 +31,7 @@ - + @@ -64,10 +64,14 @@ + + + + - + diff --git a/tests/reference/coreToOdt_SaveFull_styles.xml b/tests/reference/coreToOdt_SaveFull_styles.xml index 6ecb370a7..03395db14 100644 --- a/tests/reference/coreToOdt_SaveFull_styles.xml +++ b/tests/reference/coreToOdt_SaveFull_styles.xml @@ -21,7 +21,7 @@ - + @@ -54,10 +54,14 @@ + + + + - + diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx index 340008781..4c20bd70f 100644 --- a/tests/reference/guiEditor_Main_Final_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project Jane Doe diff --git a/tests/reference/mBuildDocBuild_Extended_Markdown_Lorem_Ipsum.md b/tests/reference/mBuildDocBuild_Extended_Markdown_Lorem_Ipsum.md index 9690f4db5..c7c7d7434 100644 --- a/tests/reference/mBuildDocBuild_Extended_Markdown_Lorem_Ipsum.md +++ b/tests/reference/mBuildDocBuild_Extended_Markdown_Lorem_Ipsum.md @@ -16,7 +16,7 @@ The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for t **Synopsis:** Explanation from the lipsum.com website. -_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. +_Lorem Ipsum_ is simply dummy text[1] of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. # Title: Act One @@ -181,3 +181,7 @@ Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulpu Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim. +### Footnotes + +1. _Lorem ipsum_ is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. (Source: Wikipedia) + diff --git a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm index c5fb9a915..11a26b332 100644 --- a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm +++ b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm @@ -31,7 +31,7 @@

Lorem Ipsum

The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.

Prologue

Synopsis: Explanation from the lipsum.com website.

-

Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.

+

Lorem Ipsum is simply dummy text1 of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.

Title: Act One

“Fusce maximus felis libero”

Chapter: Chapter One

@@ -121,6 +121,10 @@

Ancient Europe

Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula.

Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque.

Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim.

+

Footnotes

+
    +
  1. Lorem ipsum is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. (Source: Wikipedia)

  2. +
diff --git a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json index 58651e501..d8310e7d0 100644 --- a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json +++ b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json @@ -2,8 +2,8 @@ "meta": { "projectName": "Lorem Ipsum", "novelAuthor": "lipsum.com", - "buildTime": 1710799449, - "buildTimeStr": "2024-03-18 23:04:09" + "buildTime": 1714229171, + "buildTimeStr": "2024-04-27 16:46:11" }, "text": { "css": [ @@ -37,7 +37,7 @@ [ "

Prologue

", "

Synopsis: Explanation from the lipsum.com website.

", - "

Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.

" + "

Lorem Ipsum is simply dummy text1 of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.

" ], [ "

Title: Act One

", @@ -157,6 +157,12 @@ "

Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula.

", "

Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque.

", "

Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim.

" + ], + [ + "

Footnotes

", + "
    ", + "
  1. Lorem ipsum is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. (Source: Wikipedia)

  2. ", + "
" ] ] } diff --git a/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.json b/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.json index b727138a1..333a90e15 100644 --- a/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.json +++ b/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.json @@ -2,8 +2,8 @@ "meta": { "projectName": "Lorem Ipsum", "novelAuthor": "lipsum.com", - "buildTime": 1711014280, - "buildTimeStr": "2024-03-21 10:44:40" + "buildTime": 1714229171, + "buildTimeStr": "2024-04-27 16:46:11" }, "text": { "nwd": [ @@ -29,7 +29,9 @@ "", "% Synopsis: Explanation from the lipsum.com website.", "", - "_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum." + "_Lorem Ipsum_ is simply dummy text[footnote:f9kgf] of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.", + "", + "%Footnote.f9kgf: _Lorem ipsum_ is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. (Source: Wikipedia)" ], [ "# Act One", diff --git a/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.txt b/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.txt index db3ab0f33..75476c81c 100644 --- a/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.txt +++ b/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.txt @@ -17,7 +17,9 @@ The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for t % Synopsis: Explanation from the lipsum.com website. -_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. +_Lorem Ipsum_ is simply dummy text[footnote:f9kgf] of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. + +%Footnote.f9kgf: _Lorem ipsum_ is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. (Source: Wikipedia) # Act One diff --git a/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt b/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt index 1585963a1..802a929f7 100644 --- a/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt +++ b/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt @@ -1,13 +1,13 @@ - 2024-03-14T23:42:49 - novelWriter/2.4a2 + 2024-04-27T16:43:44 + novelWriter/2.5a2 lipsum.com - 44 - P0DT0H33M59S + 45 + P0DT0H36M8S Lorem Ipsum - 2024-03-14T23:42:49 + 2024-04-27T16:43:44 lipsum.com @@ -31,7 +31,7 @@
- + @@ -64,10 +64,14 @@ + + + + - + @@ -121,7 +125,12 @@ The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. Prologue Synopsis: Explanation from the lipsum.com website. - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. + Lorem Ipsum is simply dummy text + 1 + + Lorem ipsum is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. (Source: Wikipedia) + + of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. Title: Act One “Fusce maximus felis libero” Chapter: Chapter One diff --git a/tests/reference/mBuildDocBuild_Standard_Markdown_Lorem_Ipsum.md b/tests/reference/mBuildDocBuild_Standard_Markdown_Lorem_Ipsum.md index 9690f4db5..c7c7d7434 100644 --- a/tests/reference/mBuildDocBuild_Standard_Markdown_Lorem_Ipsum.md +++ b/tests/reference/mBuildDocBuild_Standard_Markdown_Lorem_Ipsum.md @@ -16,7 +16,7 @@ The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for t **Synopsis:** Explanation from the lipsum.com website. -_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. +_Lorem Ipsum_ is simply dummy text[1] of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. # Title: Act One @@ -181,3 +181,7 @@ Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulpu Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim. +### Footnotes + +1. _Lorem ipsum_ is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. (Source: Wikipedia) + diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index 1b2f7f9e6..dfe5cdb0b 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -21,26 +21,28 @@ from __future__ import annotations import time -import pytest from pathlib import Path from xml.etree import ElementTree as ET -from tools import writeFile -from mocked import causeOSError +import pytest -from PyQt5.QtGui import QColor, QDesktopServices from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QColor, QDesktopServices from novelwriter.common import ( - checkBool, checkFloat, checkInt, checkIntTuple, checkPath, checkString, - checkStringNone, checkUuid, cssCol, formatFileFilter, formatInt, - formatTime, formatTimeStamp, formatVersion, fuzzyTime, getFileSize, - hexToInt, isHandle, isItemClass, isItemLayout, isItemType, isTitleTag, - jsonEncode, makeFileNameSafe, minmax, numberToRoman, NWConfigParser, - openExternalPath, readTextFile, simplified, transferCase, xmlIndent, yesNo + NWConfigParser, checkBool, checkFloat, checkInt, checkIntTuple, checkPath, + checkString, checkStringNone, checkUuid, cssCol, elide, formatFileFilter, + formatInt, formatTime, formatTimeStamp, formatVersion, fuzzyTime, + getFileSize, hexToInt, isHandle, isItemClass, isItemLayout, isItemType, + isListInstance, isTitleTag, jsonEncode, makeFileNameSafe, minmax, + numberToRoman, openExternalPath, readTextFile, simplified, transferCase, + xmlIndent, yesNo ) +from tests.mocked import causeOSError +from tests.tools import writeFile + @pytest.mark.base def testBaseCommon_checkStringNone(): @@ -272,6 +274,24 @@ def testBaseCommon_isItemLayout(): # END Test testBaseCommon_isItemLayout +@pytest.mark.base +def testBaseCommon_isListInstance(): + """Test the isListInstance function.""" + # String + assert isListInstance("stuff", str) is False + assert isListInstance(["stuff"], str) is True + + # Int + assert isListInstance(1, int) is False + assert isListInstance([1], int) is True + + # Mixed + assert isListInstance([1], str) is False + assert isListInstance(["stuff"], int) is False + +# END Test testBaseCommon_isListInstance + + @pytest.mark.base def testBaseCommon_hexToInt(): """Test the hexToInt function.""" @@ -368,6 +388,26 @@ def testBaseCommon_simplified(): # END Test testBaseCommon_simplified +@pytest.mark.base +def testBaseCommon_elide(): + """Test the elide function.""" + assert elide("Hello World!", 12) == "Hello World!" + assert elide("Hello World!", 11) == "Hello W ..." + assert elide("Hello World!", 10) == "Hello ..." + assert elide("Hello World!", 9) == "Hello ..." + assert elide("Hello World!", 8) == "Hell ..." + assert elide("Hello World!", 7) == "Hel ..." + assert elide("Hello World!", 6) == "He ..." + assert elide("Hello World!", 5) == "H ..." + assert elide("Hello World!", 4) == " ..." + assert elide("Hello World!", 3) == " ..." + assert elide("Hello World!", 2) == " ..." + assert elide("Hello World!", 1) == " ..." + assert elide("Hello World!", 0) == " ..." + +# END Test testBaseCommon_elide + + @pytest.mark.base def testBaseCommon_yesNo(): """Test the yesNo function.""" diff --git a/tests/test_core/test_core_coretools.py b/tests/test_core/test_core_coretools.py index 0dd0f1136..fbceb2294 100644 --- a/tests/test_core/test_core_coretools.py +++ b/tests/test_core/test_core_coretools.py @@ -20,16 +20,14 @@ """ from __future__ import annotations -import uuid -import pytest import shutil +import uuid -from shutil import copyfile from pathlib import Path +from shutil import copyfile from zipfile import ZipFile -from tools import C, NWD_IGNORE, buildTestProject, cmpFiles, XML_IGNORE -from mocked import causeOSError +import pytest from novelwriter import CONFIG from novelwriter.constants import nwConst, nwFiles, nwItemClass @@ -38,6 +36,9 @@ ) from novelwriter.core.project import NWProject +from tests.mocked import causeOSError +from tests.tools import NWD_IGNORE, XML_IGNORE, C, buildTestProject, cmpFiles + @pytest.mark.core def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ipsumText): @@ -512,7 +513,7 @@ def testCoreTools_ProjectBuilderWrapper(monkeypatch, caplog, fncPath, mockGUI): @pytest.mark.core -def testCoreTools_ProjectBuilderA(monkeypatch, fncPath, tstPaths, mockRnd): +def testCoreTools_ProjectBuilderA(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd): """Create a new project from a project dictionary, with chapters.""" monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")) @@ -547,7 +548,7 @@ def testCoreTools_ProjectBuilderA(monkeypatch, fncPath, tstPaths, mockRnd): @pytest.mark.core -def testCoreTools_ProjectBuilderB(monkeypatch, fncPath, tstPaths, mockRnd): +def testCoreTools_ProjectBuilderB(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd): """Create a new project from a project dictionary, without chapters.""" monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")) diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index b04f8c445..71f4bc5de 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -21,19 +21,20 @@ from __future__ import annotations import json -import pytest from shutil import copyfile -from tools import C, buildTestProject, cmpFiles, writeFile -from mocked import causeException +import pytest from novelwriter import SHARED -from novelwriter.enum import nwComment, nwItemClass, nwItemLayout from novelwriter.constants import nwFiles +from novelwriter.core.index import IndexItem, NWIndex, TagsIndex, _checkModKey, processComment from novelwriter.core.item import NWItem -from novelwriter.core.index import IndexItem, NWIndex, TagsIndex, processComment from novelwriter.core.project import NWProject +from novelwriter.enum import nwComment, nwItemClass, nwItemLayout + +from tests.mocked import causeException +from tests.tools import C, buildTestProject, cmpFiles @pytest.mark.core @@ -122,12 +123,14 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths): assert cmpFiles(testFile, compFile) # Write an empty index file and load it - writeFile(projFile, "{}") + projFile.write_text("{}", encoding="utf-8") assert index.loadIndex() is False assert index.indexBroken is True # Write an index file that passes loading, but is still empty - writeFile(projFile, '{"novelWriter.tagsIndex": {}, "novelWriter.itemIndex": {}}') + projFile.write_text( + '{"novelWriter.tagsIndex": {}, "novelWriter.itemIndex": {}}', encoding="utf-8" + ) assert index.loadIndex() is True assert index.indexBroken is False @@ -306,7 +309,7 @@ def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd): @pytest.mark.core -def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd): +def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd): """Check the index text scanner.""" project = NWProject() mockRnd.reset() @@ -376,12 +379,14 @@ def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd): "@char: Jane\n\n" "% this is a comment\n\n" "This is a story about Jane Smith.\n\n" - "Well, not really.\n" + "Well, not really.[footnote:key]\n\n" + "%Footnote.key: Footnote text.\n\n" )) assert index._tagsIndex.tagHandle("Jane") == cHandle assert index._tagsIndex.tagHeading("Jane") == "T0001" assert index._tagsIndex.tagClass("Jane") == "CHARACTER" assert index.getItemHeading(nHandle, "T0001").title == "Hello World!" # type: ignore + assert index._itemIndex[nHandle].noteKeys("footnotes") == {"key"} # type: ignore # Title Indexing # ============== @@ -548,6 +553,45 @@ def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd): # END Test testCoreIndex_ScanText +@pytest.mark.core +def testCoreIndex_CommentKeys(monkeypatch, mockGUI, fncPath, mockRnd): + """Check the index comment key generator.""" + project = NWProject() + mockRnd.reset() + buildTestProject(project, fncPath) + index = project.index + + nKeys = 1000 + + # Generate footnote keys + keys = set() + for _ in range(nKeys): + key = index.newCommentKey(C.hSceneDoc, nwComment.FOOTNOTE) + assert key not in keys + assert key != "err" + keys.add(key) + assert len(keys) == nKeys + + # Generate comment keys + keys = set() + for _ in range(nKeys): + key = index.newCommentKey(C.hSceneDoc, nwComment.COMMENT) + assert key not in keys + keys.add(key) + assert len(keys) == nKeys + + # Induce collision + with monkeypatch.context() as mp: + mp.setattr("random.choices", lambda *a, **k: "aaaa") + assert index.newCommentKey(C.hSceneDoc, nwComment.FOOTNOTE) == "faaaa" + assert index.newCommentKey(C.hSceneDoc, nwComment.FOOTNOTE) == "err" + + # Check invalid comment style + assert index.newCommentKey(C.hSceneDoc, None) == "err" # type: ignore + +# END Test testCoreIndex_CommentKeys + + @pytest.mark.core def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): """Check the index data extraction functions.""" @@ -1249,12 +1293,14 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd): itemIndex.clear() # Data must be dictionary - with pytest.raises(ValueError): + with pytest.raises(ValueError) as exc: itemIndex.unpackData("stuff") # type: ignore + assert str(exc.value) == "itemIndex is not a dict" # Keys must be valid handles - with pytest.raises(ValueError): + with pytest.raises(ValueError) as exc: itemIndex.unpackData({"stuff": "more stuff"}) + assert str(exc.value) == "itemIndex keys must be handles" # Unknown keys should be skipped itemIndex.unpackData({C.hInvalid: {}}) @@ -1266,8 +1312,9 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd): assert itemIndex[nHandle].handle == nHandle # type: ignore # Title tags must be valid - with pytest.raises(ValueError): + with pytest.raises(ValueError) as exc: itemIndex.unpackData({cHandle: {"headings": {"TTTTTTT": {}}}}) + assert str(exc.value) == "The itemIndex contains an invalid title key" # Reference without a heading should be rejected itemIndex.unpackData({ @@ -1281,37 +1328,66 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd): itemIndex.clear() # Tag keys must be strings - with pytest.raises(ValueError): + with pytest.raises(ValueError) as exc: itemIndex.unpackData({ cHandle: { "headings": {"T0001": {}}, "references": {"T0001": {1234: "@pov"}}, + "notes": {"footnotes": [], "comments": []}, } }) + assert str(exc.value) == "itemIndex reference key must be a string" # Type must be strings - with pytest.raises(ValueError): + with pytest.raises(ValueError) as exc: itemIndex.unpackData({ cHandle: { "headings": {"T0001": {}}, "references": {"T0001": {"John": []}}, + "notes": {"footnotes": [], "comments": []}, } }) + assert str(exc.value) == "itemIndex reference type must be a string" # Types must be valid - with pytest.raises(ValueError): + with pytest.raises(ValueError) as exc: itemIndex.unpackData({ cHandle: { "headings": {"T0001": {}}, "references": {"T0001": {"John": "@pov,@char,@stuff"}}, + "notes": {"footnotes": [], "comments": []}, } }) + assert str(exc.value) == "The itemIndex contains an invalid reference type" + + # Note type must be valid + with pytest.raises(ValueError) as exc: + itemIndex.unpackData({ + cHandle: { + "headings": {"T0001": {}}, + "references": {"T0001": {"John": "@pov,@char"}}, + "notes": {"stuff": [], "comments": []}, + } + }) + assert str(exc.value) == "The notes style is invalid" + + # Note keys must be all strings + with pytest.raises(ValueError) as exc: + itemIndex.unpackData({ + cHandle: { + "headings": {"T0001": {}}, + "references": {"T0001": {"John": "@pov,@char"}}, + "notes": {"footnotes": ["fkey", 1], "comments": []}, + } + }) + assert str(exc.value) == "The notes keys must be a list of strings" # This should pass itemIndex.unpackData({ cHandle: { "headings": {"T0001": {}}, "references": {"T0001": {"John": "@pov,@char"}}, + "notes": {"footnotes": ["fkey"], "comments": ["ckey"]}, } }) @@ -1319,29 +1395,87 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd): @pytest.mark.core -def testCoreIndex_processComment(): - """Test the comment processing function.""" - # Regular comment - assert processComment("%Hi") == (nwComment.PLAIN, "Hi", 0) - assert processComment("% Hi") == (nwComment.PLAIN, "Hi", 0) - assert processComment("% Hi:You") == (nwComment.PLAIN, "Hi:You", 0) +def testCoreIndex_checkModKey(): + """Test the _checkModKey function.""" + # Check Requirements # Synopsis - assert processComment("%synopsis:") == (nwComment.PLAIN, "synopsis:", 0) - assert processComment("%synopsis: Hi") == (nwComment.SYNOPSIS, "Hi", 10) - assert processComment("% synopsis: Hi") == (nwComment.SYNOPSIS, "Hi", 11) - assert processComment("% synopsis : Hi") == (nwComment.SYNOPSIS, "Hi", 13) - assert processComment("% Synopsis : Hi") == (nwComment.SYNOPSIS, "Hi", 15) - assert processComment("% \t SYNOPSIS : Hi") == (nwComment.SYNOPSIS, "Hi", 16) - assert processComment("% \t SYNOPSIS : Hi:You") == (nwComment.SYNOPSIS, "Hi:You", 16) - - # Short Description - assert processComment("%short:") == (nwComment.PLAIN, "short:", 0) - assert processComment("%short: Hi") == (nwComment.SHORT, "Hi", 7) - assert processComment("% short: Hi") == (nwComment.SHORT, "Hi", 8) - assert processComment("% short : Hi") == (nwComment.SHORT, "Hi", 10) - assert processComment("% Short : Hi") == (nwComment.SHORT, "Hi", 12) - assert processComment("% \t SHORT : Hi") == (nwComment.SHORT, "Hi", 13) - assert processComment("% \t SHORT : Hi:You") == (nwComment.SHORT, "Hi:You", 13) + assert _checkModKey("synopsis", "") is True + assert _checkModKey("synopsis", "a") is False + + # Short + assert _checkModKey("short", "") is True + assert _checkModKey("short", "a") is False + + # Note + assert _checkModKey("note", "") is True + assert _checkModKey("note", "a") is True + + # Footnote + assert _checkModKey("footnote", "") is False + assert _checkModKey("footnote", "a") is True + + # Invalid + assert _checkModKey("stuff", "") is False + assert _checkModKey("stuff", "a") is False + + # Check Keys + assert _checkModKey("note", "a") is True + assert _checkModKey("note", "a1") is True + assert _checkModKey("note", "a1.2") is False + assert _checkModKey("note", "a1_2") is True + +# END Test testCoreIndex_checkModKey + + +@pytest.mark.core +def testCoreIndex_processComment(): + """Test the comment processing function.""" + # Plain + assert processComment("%Hi") == (nwComment.PLAIN, "", "Hi", 0, 0) + assert processComment("% Hi") == (nwComment.PLAIN, "", "Hi", 0, 0) + assert processComment("% Hi:You") == (nwComment.PLAIN, "", "Hi:You", 0, 0) + assert processComment("% Hi.You:There") == (nwComment.PLAIN, "", "Hi.You:There", 0, 0) + + # Ignore + assert processComment("%~Hi") == (nwComment.IGNORE, "", "Hi", 0, 0) + assert processComment("%~ Hi") == (nwComment.IGNORE, "", "Hi", 0, 0) + + # Invalid + assert processComment("") == (nwComment.PLAIN, "", "", 0, 0) + + # Short : Term not allowed + assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7) + assert processComment("%short.a: Hi") == (nwComment.PLAIN, "", "short.a: Hi", 0, 0) + + # Synopsis : Term not allowed + assert processComment("%synopsis: Hi") == (nwComment.SYNOPSIS, "", "Hi", 0, 10) + assert processComment("%synopsis.a: Hi") == (nwComment.PLAIN, "", "synopsis.a: Hi", 0, 0) + + # Note : Term optional + assert processComment("%note: Hi") == (nwComment.NOTE, "", "Hi", 0, 6) + assert processComment("%note.a: Hi") == (nwComment.NOTE, "a", "Hi", 6, 8) + + # Footnote : Term required + assert processComment("%footnote: Hi") == (nwComment.PLAIN, "", "footnote: Hi", 0, 0) + assert processComment("%footnote.a: Hi") == (nwComment.FOOTNOTE, "a", "Hi", 10, 12) + + # Check Case + assert processComment("%Footnote.a: Hi") == (nwComment.FOOTNOTE, "a", "Hi", 10, 12) + assert processComment("%FOOTNOTE.A: Hi") == (nwComment.FOOTNOTE, "A", "Hi", 10, 12) + assert processComment("%FootNote.A_a: Hi") == (nwComment.FOOTNOTE, "A_a", "Hi", 10, 14) + + # Padding without term + assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7) + assert processComment("% short: Hi") == (nwComment.SHORT, "", "Hi", 0, 8) + assert processComment("% short : Hi") == (nwComment.SHORT, "", "Hi", 0, 10) + assert processComment("% short : Hi") == (nwComment.SHORT, "", "Hi", 0, 12) + assert processComment("% \t short : Hi") == (nwComment.SHORT, "", "Hi", 0, 13) + + # Padding with term + assert processComment("%note.term: Hi") == (nwComment.NOTE, "term", "Hi", 6, 11) + assert processComment("% note.term: Hi") == (nwComment.NOTE, "term", "Hi", 7, 12) + assert processComment("% note. term : Hi") == (nwComment.PLAIN, "", "note. term : Hi", 0, 0) + assert processComment("% note . term : Hi") == (nwComment.PLAIN, "", "note . term : Hi", 0, 0) # END Test testCoreIndex_processComment diff --git a/tests/test_core/test_core_projectxml.py b/tests/test_core/test_core_projectxml.py index 394e29cf4..de277dfa0 100644 --- a/tests/test_core/test_core_projectxml.py +++ b/tests/test_core/test_core_projectxml.py @@ -21,21 +21,22 @@ from __future__ import annotations import json -import pytest -from shutil import copyfile from datetime import datetime -from novelwriter.constants import nwFiles +from shutil import copyfile -from novelwriter.enum import nwStatusShape -from tools import cmpFiles, writeFile -from mocked import causeOSError +import pytest from PyQt5.QtGui import QColor +from novelwriter.constants import nwFiles from novelwriter.core.item import NWItem -from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState from novelwriter.core.projectdata import NWProjectData +from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState +from novelwriter.enum import nwStatusShape + +from tests.mocked import causeOSError +from tests.tools import cmpFiles, writeFile class MockProject: @@ -55,7 +56,7 @@ def mockVersion(monkeypatch): @pytest.mark.core -def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath): +def testCoreProjectXML_ReadCurrent(monkeypatch, mockGUI, tstPaths, fncPath): """Test reading the current XML file format.""" refFile = tstPaths.filesDir / "nwProject-1.5.nwx" tstFile = tstPaths.outDir / "ProjectXML_ReadCurrent.nwx" @@ -249,7 +250,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath): @pytest.mark.core -def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd): +def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockGUI, mockRnd): """Test reading the version 1.0 XML file format.""" refFile = tstPaths.filesDir / "nwProject-1.0.nwx" xmlFile = fncPath / "nwProject-1.0.nwx" @@ -396,7 +397,7 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd): @pytest.mark.core -def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd): +def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockGUI, mockRnd): """Test reading the version 1.1 XML file format.""" refFile = tstPaths.filesDir / "nwProject-1.1.nwx" xmlFile = fncPath / "nwProject-1.1.nwx" @@ -543,7 +544,7 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd): @pytest.mark.core -def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd): +def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockGUI, mockRnd): """Test reading the version 1.2 XML file format.""" refFile = tstPaths.filesDir / "nwProject-1.2.nwx" xmlFile = fncPath / "nwProject-1.2.nwx" @@ -693,7 +694,7 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd): @pytest.mark.core -def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd): +def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockGUI, mockRnd): """Test reading the version 1.3 XML file format.""" refFile = tstPaths.filesDir / "nwProject-1.3.nwx" xmlFile = fncPath / "nwProject-1.3.nwx" @@ -843,7 +844,7 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd): @pytest.mark.core -def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockRnd): +def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockGUI, mockRnd): """Test reading the version 1.4 XML file format.""" refFile = tstPaths.filesDir / "nwProject-1.4.nwx" xmlFile = fncPath / "nwProject-1.4.nwx" diff --git a/tests/test_core/test_core_tohtml.py b/tests/test_core/test_core_tohtml.py index 0152607d0..c2d6def16 100644 --- a/tests/test_core/test_core_tohtml.py +++ b/tests/test_core/test_core_tohtml.py @@ -20,12 +20,12 @@ """ from __future__ import annotations -import pytest +import json -from tools import readFile +import pytest -from novelwriter.core.tohtml import ToHtml from novelwriter.core.project import NWProject +from novelwriter.core.tohtml import ToHtml @pytest.mark.core @@ -225,6 +225,22 @@ def testCoreToHtml_ConvertParagraphs(mockGUI): "Bod, Jane

\n" ) + # Tags + html._text = "@tag: Bod\n" + html.tokenizeText() + html.doConvert() + assert html.result == ( + "

Tag: Bod

\n" + ) + + html._text = "@tag: Bod | Nobody Owens\n" + html.tokenizeText() + html.doConvert() + assert html.result == ( + "

Tag: Bod " + "| Nobody Owens

\n" + ) + # Multiple Keywords html._isFirst = False html.setKeywords(True) @@ -241,6 +257,30 @@ def testCoreToHtml_ConvertParagraphs(mockGUI): "Locations: Europe

\n" ) + # Footnotes + # ========= + + html._text = ( + "Text with one[footnote:fa] or two[footnote:fb] footnotes.\n\n" + "%footnote.fa: Footnote text A.\n\n" + ) + html.tokenizeText() + html.doConvert() + assert html.result == ( + "

Text with one1 " + "or twoERR footnotes.

\n" + ) + + html.appendFootnotes() + assert html.result == ( + "

Text with one1 " + "or twoERR footnotes.

\n" + "

Footnotes

\n" + "
    \n" + "
  1. Footnote text A.

  2. \n" + "
\n" + ) + # Preview Mode # ============ @@ -453,7 +493,7 @@ def testCoreToHtml_SpecialCases(mockGUI): html.doConvert() assert html.result == ( "

" - "Comment: Test > text _<**bold**>_ and more." + "Comment: Test > text <bold> and more." "

\n" ) @@ -480,7 +520,7 @@ def testCoreToHtml_SpecialCases(mockGUI): @pytest.mark.core -def testCoreToHtml_Complex(mockGUI, fncPath): +def testCoreToHtml_Save(mockGUI, fncPath): """Test the save method of the ToHtml class.""" project = NWProject() html = ToHtml(project) @@ -498,36 +538,28 @@ def testCoreToHtml_Complex(mockGUI, fncPath): "### Scene 2\n\nThe text of scene two.\n", "#### A Section\n\n\tMore text in scene two.\n", ] - resText = [ - ( - "

My Novel

\n" - "

By Jane Doh

\n" - ), - ( - "

Chapter 1

\n" - "

The text of chapter one.

\n" - ), - ( - "

Scene 1

\n" - "

The text of scene one.

\n" - ), - ( - "

A Section

\n" - "

More text in scene one.

\n" - ), - ( - "

Chapter 2

\n" - "

The text of chapter two.

\n" - ), - ( - "

Scene 2

\n" - "

The text of scene two.

\n" - ), - ( - "

A Section

\n" - "

\tMore text in scene two.

\n" - ), - ] + resText = [( + "

My Novel

\n" + "

By Jane Doh

\n" + ), ( + "

Chapter 1

\n" + "

The text of chapter one.

\n" + ), ( + "

Scene 1

\n" + "

The text of scene one.

\n" + ), ( + "

A Section

\n" + "

More text in scene one.

\n" + ), ( + "

Chapter 2

\n" + "

The text of chapter two.

\n" + ), ( + "

Scene 2

\n" + "

The text of scene two.

\n" + ), ( + "

A Section

\n" + "

\tMore text in scene two.

\n" + )] for i in range(len(docText)): html._text = docText[i] @@ -541,9 +573,10 @@ def testCoreToHtml_Complex(mockGUI, fncPath): html.replaceTabs(nSpaces=2, spaceChar=" ") resText[6] = "

A Section

\n

  More text in scene two.

\n" - # Check File - # ========== + # Check Files + # =========== + # HTML hStyle = html.getStyleSheet() htmlDoc = ( "\n" @@ -568,9 +601,20 @@ def testCoreToHtml_Complex(mockGUI, fncPath): saveFile = fncPath / "outFile.htm" html.saveHtml5(saveFile) - assert readFile(saveFile) == htmlDoc - -# END Test testCoreToHtml_Complex + assert saveFile.read_text(encoding="utf-8") == htmlDoc + + # JSON + HTML + saveFile = fncPath / "outFile.json" + html.saveHtmlJson(saveFile) + data = json.loads(saveFile.read_text(encoding="utf-8")) + assert data["meta"]["projectName"] == "" + assert data["meta"]["novelAuthor"] == "" + assert data["meta"]["buildTime"] > 0 + assert data["meta"]["buildTimeStr"] != "" + assert data["text"]["css"] == hStyle + assert len(data["text"]["html"]) == len(resText) + +# END Test testCoreToHtml_Save @pytest.mark.core diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py index 86cc0d50d..82fd68144 100644 --- a/tests/test_core/test_core_tokenizer.py +++ b/tests/test_core/test_core_tokenizer.py @@ -21,14 +21,15 @@ from __future__ import annotations import json -import pytest -from tools import C, buildTestProject, readFile +import pytest from novelwriter.constants import nwHeadFmt -from novelwriter.core.tomd import ToMarkdown from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import HeadingFormatter, Tokenizer, stripEscape +from novelwriter.core.tomd import ToMarkdown + +from tests.tools import C, buildTestProject, readFile class BareTokenizer(Tokenizer): @@ -869,30 +870,31 @@ def testCoreToken_ExtractFormats(mockGUI): # Plain bold text, fmt = tokens._extractFormats("Text with **bold** in it.") assert text == "Text with bold in it." - assert fmt == [(10, tokens.FMT_B_B), (14, tokens.FMT_B_E)] + assert fmt == [(10, tokens.FMT_B_B, ""), (14, tokens.FMT_B_E, "")] # Plain italics text, fmt = tokens._extractFormats("Text with _italics_ in it.") assert text == "Text with italics in it." - assert fmt == [(10, tokens.FMT_I_B), (17, tokens.FMT_I_E)] + assert fmt == [(10, tokens.FMT_I_B, ""), (17, tokens.FMT_I_E, "")] # Plain strikethrough text, fmt = tokens._extractFormats("Text with ~~strikethrough~~ in it.") assert text == "Text with strikethrough in it." - assert fmt == [(10, tokens.FMT_D_B), (23, tokens.FMT_D_E)] + assert fmt == [(10, tokens.FMT_D_B, ""), (23, tokens.FMT_D_E, "")] # Nested bold/italics text, fmt = tokens._extractFormats("Text with **bold and _italics_** in it.") assert text == "Text with bold and italics in it." assert fmt == [ - (10, tokens.FMT_B_B), (19, tokens.FMT_I_B), (26, tokens.FMT_I_E), (26, tokens.FMT_B_E) + (10, tokens.FMT_B_B, ""), (19, tokens.FMT_I_B, ""), + (26, tokens.FMT_I_E, ""), (26, tokens.FMT_B_E, ""), ] # Bold with overlapping italics # Here, bold is ignored because it is not on word boundary text, fmt = tokens._extractFormats("Text with **bold and overlapping _italics**_ in it.") assert text == "Text with **bold and overlapping italics** in it." - assert fmt == [(33, tokens.FMT_I_B), (42, tokens.FMT_I_E)] + assert fmt == [(33, tokens.FMT_I_B, ""), (42, tokens.FMT_I_E, "")] # Shortcodes # ========== @@ -900,43 +902,44 @@ def testCoreToken_ExtractFormats(mockGUI): # Plain bold text, fmt = tokens._extractFormats("Text with [b]bold[/b] in it.") assert text == "Text with bold in it." - assert fmt == [(10, tokens.FMT_B_B), (14, tokens.FMT_B_E)] + assert fmt == [(10, tokens.FMT_B_B, ""), (14, tokens.FMT_B_E, "")] # Plain italics text, fmt = tokens._extractFormats("Text with [i]italics[/i] in it.") assert text == "Text with italics in it." - assert fmt == [(10, tokens.FMT_I_B), (17, tokens.FMT_I_E)] + assert fmt == [(10, tokens.FMT_I_B, ""), (17, tokens.FMT_I_E, "")] # Plain strikethrough text, fmt = tokens._extractFormats("Text with [s]strikethrough[/s] in it.") assert text == "Text with strikethrough in it." - assert fmt == [(10, tokens.FMT_D_B), (23, tokens.FMT_D_E)] + assert fmt == [(10, tokens.FMT_D_B, ""), (23, tokens.FMT_D_E, "")] # Plain underline text, fmt = tokens._extractFormats("Text with [u]underline[/u] in it.") assert text == "Text with underline in it." - assert fmt == [(10, tokens.FMT_U_B), (19, tokens.FMT_U_E)] + assert fmt == [(10, tokens.FMT_U_B, ""), (19, tokens.FMT_U_E, "")] # Plain mark text, fmt = tokens._extractFormats("Text with [m]highlight[/m] in it.") assert text == "Text with highlight in it." - assert fmt == [(10, tokens.FMT_M_B), (19, tokens.FMT_M_E)] + assert fmt == [(10, tokens.FMT_M_B, ""), (19, tokens.FMT_M_E, "")] # Plain superscript text, fmt = tokens._extractFormats("Text with super[sup]script[/sup] in it.") assert text == "Text with superscript in it." - assert fmt == [(15, tokens.FMT_SUP_B), (21, tokens.FMT_SUP_E)] + assert fmt == [(15, tokens.FMT_SUP_B, ""), (21, tokens.FMT_SUP_E, "")] # Plain subscript text, fmt = tokens._extractFormats("Text with sub[sub]script[/sub] in it.") assert text == "Text with subscript in it." - assert fmt == [(13, tokens.FMT_SUB_B), (19, tokens.FMT_SUB_E)] + assert fmt == [(13, tokens.FMT_SUB_B, ""), (19, tokens.FMT_SUB_E, "")] # Nested bold/italics text, fmt = tokens._extractFormats("Text with [b]bold and [i]italics[/i][/b] in it.") assert text == "Text with bold and italics in it." assert fmt == [ - (10, tokens.FMT_B_B), (19, tokens.FMT_I_B), (26, tokens.FMT_I_E), (26, tokens.FMT_B_E) + (10, tokens.FMT_B_B, ""), (19, tokens.FMT_I_B, ""), + (26, tokens.FMT_I_E, ""), (26, tokens.FMT_B_E, ""), ] # Bold with overlapping italics @@ -946,7 +949,8 @@ def testCoreToken_ExtractFormats(mockGUI): ) assert text == "Text with bold and overlapping italics in it." assert fmt == [ - (10, tokens.FMT_B_B), (31, tokens.FMT_I_B), (38, tokens.FMT_B_E), (38, tokens.FMT_I_E) + (10, tokens.FMT_B_B, ""), (31, tokens.FMT_I_B, ""), + (38, tokens.FMT_B_E, ""), (38, tokens.FMT_I_E, ""), ] # So does this @@ -955,7 +959,8 @@ def testCoreToken_ExtractFormats(mockGUI): ) assert text == "Text with bold and overlapping italics in it." assert fmt == [ - (10, tokens.FMT_B_B), (31, tokens.FMT_I_B), (38, tokens.FMT_B_E), (41, tokens.FMT_I_E) + (10, tokens.FMT_B_B, ""), (31, tokens.FMT_I_B, ""), + (38, tokens.FMT_B_E, ""), (41, tokens.FMT_I_E, ""), ] # END Test testCoreToken_ExtractFormats @@ -998,8 +1003,8 @@ def testCoreToken_TextFormat(mockGUI): Tokenizer.T_TEXT, 0, "Some bolded text on this lines", [ - (5, Tokenizer.FMT_B_B), - (16, Tokenizer.FMT_B_E), + (5, Tokenizer.FMT_B_B, ""), + (16, Tokenizer.FMT_B_E, ""), ], Tokenizer.A_NONE ), @@ -1014,8 +1019,8 @@ def testCoreToken_TextFormat(mockGUI): Tokenizer.T_TEXT, 0, "Some italic text on this lines", [ - (5, Tokenizer.FMT_I_B), - (16, Tokenizer.FMT_I_E), + (5, Tokenizer.FMT_I_B, ""), + (16, Tokenizer.FMT_I_E, ""), ], Tokenizer.A_NONE ), @@ -1030,10 +1035,10 @@ def testCoreToken_TextFormat(mockGUI): Tokenizer.T_TEXT, 0, "Some bold italic text on this lines", [ - (5, Tokenizer.FMT_B_B), - (5, Tokenizer.FMT_I_B), - (21, Tokenizer.FMT_I_E), - (21, Tokenizer.FMT_B_E), + (5, Tokenizer.FMT_B_B, ""), + (5, Tokenizer.FMT_I_B, ""), + (21, Tokenizer.FMT_I_E, ""), + (21, Tokenizer.FMT_B_E, ""), ], Tokenizer.A_NONE ), @@ -1048,8 +1053,8 @@ def testCoreToken_TextFormat(mockGUI): Tokenizer.T_TEXT, 0, "Some strikethrough text on this lines", [ - (5, Tokenizer.FMT_D_B), - (23, Tokenizer.FMT_D_E), + (5, Tokenizer.FMT_D_B, ""), + (23, Tokenizer.FMT_D_E, ""), ], Tokenizer.A_NONE ), @@ -1064,12 +1069,12 @@ def testCoreToken_TextFormat(mockGUI): Tokenizer.T_TEXT, 0, "Some nested bold and italic and strikethrough text here", [ - (5, Tokenizer.FMT_B_B), - (21, Tokenizer.FMT_I_B), - (27, Tokenizer.FMT_I_E), - (32, Tokenizer.FMT_D_B), - (45, Tokenizer.FMT_D_E), - (50, Tokenizer.FMT_B_E), + (5, Tokenizer.FMT_B_B, ""), + (21, Tokenizer.FMT_I_B, ""), + (27, Tokenizer.FMT_I_E, ""), + (32, Tokenizer.FMT_D_B, ""), + (45, Tokenizer.FMT_D_E, ""), + (50, Tokenizer.FMT_B_E, ""), ], Tokenizer.A_NONE ), @@ -2137,7 +2142,7 @@ def testCoreToken_CounterHandling(mockGUI): @pytest.mark.core -def testCoreToken_HeadingFormatter(fncPath, mockRnd): +def testCoreToken_HeadingFormatter(fncPath, mockGUI, mockRnd): """Check the HeadingFormatter class.""" project = NWProject() project.setProjectLang("en_GB") diff --git a/tests/test_core/test_core_tomd.py b/tests/test_core/test_core_tomd.py index 4767ebd42..db5752629 100644 --- a/tests/test_core/test_core_tomd.py +++ b/tests/test_core/test_core_tomd.py @@ -22,10 +22,8 @@ import pytest -from tools import readFile - -from novelwriter.core.tomd import ToMarkdown from novelwriter.core.project import NWProject +from novelwriter.core.tomd import ToMarkdown @pytest.mark.core @@ -134,6 +132,13 @@ def testCoreToMarkdown_ConvertParagraphs(mockGUI): toMD.doConvert() assert toMD.result == "Line one \nLine two \nLine three\n\n" + # Text wo/Hard Break + toMD._text = "Line one \nLine two \nLine three\n" + toMD.setPreserveBreaks(False) + toMD.tokenizeText() + toMD.doConvert() + assert toMD.result == "Line one Line two Line three\n\n" + # Synopsis, Short toMD._text = "%synopsis: The synopsis ...\n" toMD.tokenizeText() @@ -188,6 +193,22 @@ def testCoreToMarkdown_ConvertParagraphs(mockGUI): "**Locations:** Europe\n\n" ) + # Footnotes + toMD._text = ( + "Text with one[footnote:fa] or two[footnote:fb] footnotes.\n\n" + "%footnote.fa: Footnote text A.\n\n" + ) + toMD.tokenizeText() + toMD.doConvert() + assert toMD.result == "Text with one[1] or two[ERR] footnotes.\n\n" + + toMD.appendFootnotes() + assert toMD.result == ( + "Text with one[1] or two[ERR] footnotes.\n\n" + "### Footnotes\n\n" + "1. Footnote text A.\n\n" + ) + # END Test testCoreToMarkdown_ConvertParagraphs @@ -233,17 +254,18 @@ def testCoreToMarkdown_ConvertDirect(mockGUI): @pytest.mark.core -def testCoreToMarkdown_Complex(mockGUI, fncPath): +def testCoreToMarkdown_Save(mockGUI, fncPath): """Test the save method of the ToMarkdown class.""" project = NWProject() toMD = ToMarkdown(project) + toMD.setKeepMarkdown(True) toMD._isNovel = True # Build Project # ============= docText = [ - "# My Novel\n**By Jane Doh**\n", + "# My Novel\n\n**By Jane Doh**\n", "## Chapter 1\n\nThe text of chapter one.\n", "### Scene 1\n\nThe text of scene one.\n", "#### A Section\n\nMore text in scene one.\n", @@ -273,15 +295,16 @@ def testCoreToMarkdown_Complex(mockGUI, fncPath): toMD.replaceTabs(nSpaces=4, spaceChar=" ") resText[6] = "#### A Section\n\n More text in scene two.\n\n" + assert toMD.allMarkdown == resText # Check File # ========== saveFile = fncPath / "outFile.md" toMD.saveMarkdown(saveFile) - assert readFile(saveFile) == "".join(resText) + assert saveFile.read_text(encoding="utf-8") == "".join(resText) -# END Test testCoreToHtml_Complex +# END Test testCoreToMarkdown_Save @pytest.mark.core diff --git a/tests/test_core/test_core_toodt.py b/tests/test_core/test_core_toodt.py index b34cb40d7..2428a5677 100644 --- a/tests/test_core/test_core_toodt.py +++ b/tests/test_core/test_core_toodt.py @@ -20,18 +20,19 @@ """ from __future__ import annotations -import pytest -import zipfile import xml.etree.ElementTree as ET +import zipfile from shutil import copyfile -from tools import ODT_IGNORE, cmpFiles +import pytest from novelwriter.common import xmlIndent from novelwriter.constants import nwHeadFmt -from novelwriter.core.toodt import ToOdt, ODTParagraphStyle, ODTTextStyle, XMLParagraph, _mkTag from novelwriter.core.project import NWProject +from novelwriter.core.toodt import ODTParagraphStyle, ODTTextStyle, ToOdt, XMLParagraph, _mkTag + +from tests.tools import ODT_IGNORE, cmpFiles XML_NS = [ ' xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"', @@ -132,7 +133,7 @@ def testCoreToOdt_TextFormatting(mockGUI): assert list(odt._mainPara.keys()) == [ "Text_20_body", "First_20_line_20_indent", "Text_20_Meta", "Title", "Separator", - "Heading_20_1", "Heading_20_2", "Heading_20_3", "Heading_20_4", "Header", + "Heading_20_1", "Heading_20_2", "Heading_20_3", "Heading_20_4", "Header", "Footnote", ] key = "55db6c1d22ff5aba93f0f67c8d4a857a26e2d3813dfbcba1ef7c0d424f501be5" @@ -145,51 +146,51 @@ def testCoreToOdt_TextFormatting(mockGUI): oStyle = ODTParagraphStyle("test") # No Text - odt.initDocument() - odt._addTextPar("Standard", oStyle, "") - assert xmlToText(odt._xText) == ( + xTest = ET.Element(_mkTag("office", "text")) + odt._addTextPar(xTest, "Standard", oStyle, "") + assert xmlToText(xTest) == ( '' '' '' ) # No Format - odt.initDocument() - odt._addTextPar("Standard", oStyle, "Hello World") + xTest = ET.Element(_mkTag("office", "text")) + odt._addTextPar(xTest, "Standard", oStyle, "Hello World") assert odt.errData == [] - assert xmlToText(odt._xText) == ( + assert xmlToText(xTest) == ( '' 'Hello World' '' ) # Heading Level None - odt.initDocument() - odt._addTextPar("Standard", oStyle, "Hello World", isHead=True) + xTest = ET.Element(_mkTag("office", "text")) + odt._addTextPar(xTest, "Standard", oStyle, "Hello World", isHead=True) assert odt.errData == [] - assert xmlToText(odt._xText) == ( + assert xmlToText(xTest) == ( '' 'Hello World' '' ) # Heading Level 1 - odt.initDocument() - odt._addTextPar("Standard", oStyle, "Hello World", isHead=True, oLevel="1") + xTest = ET.Element(_mkTag("office", "text")) + odt._addTextPar(xTest, "Standard", oStyle, "Hello World", isHead=True, oLevel="1") assert odt.errData == [] - assert xmlToText(odt._xText) == ( + assert xmlToText(xTest) == ( '' 'Hello World' '' ) # Formatted Text - odt.initDocument() text = "A bold word" - fmt = [(2, odt.FMT_B_B), (6, odt.FMT_B_E)] - odt._addTextPar("Standard", oStyle, text, tFmt=fmt) + fmt = [(2, odt.FMT_B_B, ""), (6, odt.FMT_B_E, "")] + xTest = ET.Element(_mkTag("office", "text")) + odt._addTextPar(xTest, "Standard", oStyle, text, tFmt=fmt) assert odt.errData == [] - assert xmlToText(odt._xText) == ( + assert xmlToText(xTest) == ( '' 'A bold ' 'word' @@ -197,25 +198,26 @@ def testCoreToOdt_TextFormatting(mockGUI): ) # Incorrectly Formatted Text - odt.initDocument() text = "A few words" - fmt = [(2, odt.FMT_B_B), (5, odt.FMT_B_E), (7, 99999)] - odt._addTextPar("Standard", oStyle, text, tFmt=fmt) + fmt = [(2, odt.FMT_B_B, ""), (5, odt.FMT_B_E, ""), (7, 99999, "")] + xTest = ET.Element(_mkTag("office", "text")) + odt._addTextPar(xTest, "Standard", oStyle, text, tFmt=fmt) assert odt.errData == ["Unknown format tag encountered"] - assert xmlToText(odt._xText) == ( + assert xmlToText(xTest) == ( '' 'A few ' 'words' '' ) + odt._errData = [] # Unclosed format - odt.initDocument() text = "A bold word" - fmt = [(2, odt.FMT_B_B)] - odt._addTextPar("Standard", oStyle, text, tFmt=fmt) + fmt = [(2, odt.FMT_B_B, "")] + xTest = ET.Element(_mkTag("office", "text")) + odt._addTextPar(xTest, "Standard", oStyle, text, tFmt=fmt) assert odt.errData == [] - assert xmlToText(odt._xText) == ( + assert xmlToText(xTest) == ( '' 'A ' 'bold word' @@ -223,12 +225,12 @@ def testCoreToOdt_TextFormatting(mockGUI): ) # Tabs and Breaks - odt.initDocument() text = "Hello\n\tWorld" fmt = [] - odt._addTextPar("Standard", oStyle, text, tFmt=fmt) + xTest = ET.Element(_mkTag("office", "text")) + odt._addTextPar(xTest, "Standard", oStyle, text, tFmt=fmt) assert odt.errData == [] - assert xmlToText(odt._xText) == ( + assert xmlToText(xTest) == ( '' 'HelloWorld' '' @@ -619,6 +621,43 @@ def getStyle(styleName): '' ) + # Footnotes + odt._text = ( + "Text with one[footnote:fa], **two**[footnote:fd], " + "or three[footnote:fb] footnotes.[footnote:fe]\n\n" + "%footnote.fa: Footnote text A.[footnote:fc]\n\n" + "%footnote.fc: This footnote is skipped.\n\n" + "%footnote.fd: Another footnote.\n\n" + "%footnote.fe: Again?\n\n" + ) + odt.tokenizeText() + odt.initDocument() + odt.doConvert() + odt.closeDocument() + assert xmlToText(odt._xText) == ( + '' + 'Text with one' + '' + '1' + '' + 'Footnote text A.' + '' + ', two' + '' + '2' + '' + 'Another footnote.' + '' + ', or three footnotes.' + '' + '3' + '' + 'Again?' + '' + '' + '' + ) + # Test for issue #1412 # ==================== # See: https://github.com/vkbo/novelWriter/issues/1412 @@ -835,22 +874,28 @@ def testCoreToOdt_Format(mockGUI): project = NWProject() odt = ToOdt(project, isFlat=True) - assert odt._formatSynopsis("synopsis text", True) == ( - "Synopsis: synopsis text", [(0, ToOdt.FMT_B_B), (9, ToOdt.FMT_B_E)] + assert odt._formatSynopsis("synopsis text", [(9, ToOdt.FMT_STRIP, "")], True) == ( + "Synopsis: synopsis text", [ + (0, ToOdt.FMT_B_B, ""), (9, ToOdt.FMT_B_E, ""), (19, ToOdt.FMT_STRIP, "") + ] ) - assert odt._formatSynopsis("short text", False) == ( - "Short Description: short text", [(0, ToOdt.FMT_B_B), (18, ToOdt.FMT_B_E)] + assert odt._formatSynopsis("short text", [(6, ToOdt.FMT_STRIP, "")], False) == ( + "Short Description: short text", [ + (0, ToOdt.FMT_B_B, ""), (18, ToOdt.FMT_B_E, ""), (25, ToOdt.FMT_STRIP, "") + ] ) - assert odt._formatComments("comment text") == ( - "Comment: comment text", [(0, ToOdt.FMT_B_B), (8, ToOdt.FMT_B_E)] + assert odt._formatComments("comment text", [(8, ToOdt.FMT_STRIP, "")]) == ( + "Comment: comment text", [ + (0, ToOdt.FMT_B_B, ""), (8, ToOdt.FMT_B_E, ""), (17, ToOdt.FMT_STRIP, "") + ] ) assert odt._formatKeywords("") == ("", []) assert odt._formatKeywords("tag: Jane") == ( - "Tag: Jane", [(0, ToOdt.FMT_B_B), (4, ToOdt.FMT_B_E)] + "Tag: Jane", [(0, ToOdt.FMT_B_B, ""), (4, ToOdt.FMT_B_E, "")] ) assert odt._formatKeywords("char: Bod, Jane") == ( - "Characters: Bod, Jane", [(0, ToOdt.FMT_B_B), (11, ToOdt.FMT_B_E)] + "Characters: Bod, Jane", [(0, ToOdt.FMT_B_B, ""), (11, ToOdt.FMT_B_E, "")] ) # END Test testCoreToOdt_Format diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index be409df28..24ad4f10b 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -22,11 +22,8 @@ import pytest -from mocked import causeOSError -from tools import C, buildTestProject - -from PyQt5.QtCore import QThreadPool, Qt -from PyQt5.QtGui import QClipboard, QTextBlock, QTextCursor, QTextOption +from PyQt5.QtCore import QEvent, Qt, QThreadPool +from PyQt5.QtGui import QClipboard, QMouseEvent, QTextBlock, QTextCursor, QTextOption from PyQt5.QtWidgets import QAction, QApplication, QMenu from novelwriter import CONFIG, SHARED @@ -35,33 +32,56 @@ from novelwriter.enum import ( nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwTrinary, nwWidget ) -from novelwriter.gui.doceditor import GuiDocEditor, GuiDocToolBar +from novelwriter.gui.doceditor import GuiDocEditor from novelwriter.text.counting import standardCounter -from novelwriter.types import QtAlignJustify, QtAlignLeft, QtKeepAnchor, QtMouseLeft, QtMoveRight +from novelwriter.types import ( + QtAlignJustify, QtAlignLeft, QtKeepAnchor, QtModCtrl, QtMouseLeft, + QtMoveRight +) + +from tests.mocked import causeOSError +from tests.tools import C, buildTestProject KEY_DELAY = 1 +def getMenuForPos(editor: GuiDocEditor, pos: int, select: bool = False) -> QMenu | None: + """Create a context menu for a text position and return the menu + object. + """ + cursor = editor.textCursor() + cursor.setPosition(pos) + if select: + cursor.select(QTextCursor.SelectionType.WordUnderCursor) + editor.setTextCursor(cursor) + editor._openContextMenu(editor.cursorRect().center()) + for obj in editor.children(): + if isinstance(obj, QMenu) and obj.objectName() == "ContextMenu": + return obj + return None + + @pytest.mark.gui def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd): """Test initialising the editor.""" # Open project buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) + docEditor = nwGUI.docEditor - nwGUI.docEditor.setPlainText("### Lorem Ipsum\n\n%s" % ipsumText[0]) + docEditor.setPlainText("### Lorem Ipsum\n\n%s" % ipsumText[0]) nwGUI.saveDocument() # Check Defaults - qDoc = nwGUI.docEditor.document() + qDoc = docEditor.document() assert qDoc.defaultTextOption().alignment() == QtAlignLeft - assert nwGUI.docEditor.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded - assert nwGUI.docEditor.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded - assert nwGUI.docEditor._typPadChar == nwUnicode.U_NBSP - assert nwGUI.docEditor.docHeader.itemTitle.text() == ( + assert docEditor.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded + assert docEditor.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded + assert docEditor._typPadChar == nwUnicode.U_NBSP + assert docEditor.docHeader.itemTitle.text() == ( "Novel \u203a New Chapter \u203a New Scene" ) - assert nwGUI.docEditor.docHeader._docOutline == {0: "### New Scene"} + assert docEditor.docHeader._docOutline == {0: "### New Scene"} # Check that editor handles settings CONFIG.textFont = "" @@ -73,43 +93,43 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd): CONFIG.fmtPadThin = True CONFIG.showFullPath = False - nwGUI.docEditor.initEditor() + docEditor.initEditor() - qDoc = nwGUI.docEditor.document() + qDoc = docEditor.document() assert CONFIG.textFont == qDoc.defaultFont().family() assert qDoc.defaultTextOption().alignment() == QtAlignJustify assert qDoc.defaultTextOption().flags() & QTextOption.ShowTabsAndSpaces assert qDoc.defaultTextOption().flags() & QTextOption.ShowLineAndParagraphSeparators - assert nwGUI.docEditor.verticalScrollBarPolicy() == Qt.ScrollBarAlwaysOff - assert nwGUI.docEditor.horizontalScrollBarPolicy() == Qt.ScrollBarAlwaysOff - assert nwGUI.docEditor._typPadChar == nwUnicode.U_THNBSP - assert nwGUI.docEditor.docHeader.itemTitle.text() == "New Scene" + assert docEditor.verticalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + assert docEditor.horizontalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + assert docEditor._typPadChar == nwUnicode.U_THNBSP + assert docEditor.docHeader.itemTitle.text() == "New Scene" # Header # ====== # Go to outline - nwGUI.docEditor.setCursorLine(3) - nwGUI.docEditor.docHeader.outlineMenu.actions()[0].trigger() - assert nwGUI.docEditor.getCursorPosition() == 0 + docEditor.setCursorLine(3) + docEditor.docHeader.outlineMenu.actions()[0].trigger() + assert docEditor.getCursorPosition() == 0 # Select item from header - with qtbot.waitSignal(nwGUI.docEditor.requestProjectItemSelected, timeout=1000) as signal: - qtbot.mouseClick(nwGUI.docEditor.docHeader, QtMouseLeft) - assert signal.args == [nwGUI.docEditor.docHeader._docHandle, True] + with qtbot.waitSignal(docEditor.requestProjectItemSelected, timeout=1000) as signal: + qtbot.mouseClick(docEditor.docHeader, QtMouseLeft) + assert signal.args == [docEditor.docHeader._docHandle, True] # Close from header - with qtbot.waitSignal(nwGUI.docEditor.docHeader.closeDocumentRequest, timeout=1000): - nwGUI.docEditor.docHeader.closeButton.click() + with qtbot.waitSignal(docEditor.docHeader.closeDocumentRequest, timeout=1000): + docEditor.docHeader.closeButton.click() - assert nwGUI.docEditor.docHeader.tbButton.isVisible() is False - assert nwGUI.docEditor.docHeader.searchButton.isVisible() is False - assert nwGUI.docEditor.docHeader.closeButton.isVisible() is False - assert nwGUI.docEditor.docHeader.minmaxButton.isVisible() is False + assert docEditor.docHeader.tbButton.isVisible() is False + assert docEditor.docHeader.searchButton.isVisible() is False + assert docEditor.docHeader.closeButton.isVisible() is False + assert docEditor.docHeader.minmaxButton.isVisible() is False # Select item from header - with qtbot.waitSignal(nwGUI.docEditor.requestProjectItemSelected, timeout=1000) as signal: - qtbot.mouseClick(nwGUI.docEditor.docHeader, QtMouseLeft) + with qtbot.waitSignal(docEditor.requestProjectItemSelected, timeout=1000) as signal: + qtbot.mouseClick(docEditor.docHeader, QtMouseLeft) assert signal.args == ["", True] # qtbot.stop() @@ -122,28 +142,29 @@ def testGuiEditor_LoadText(qtbot, nwGUI, projPath, ipsumText, mockRnd): """Test loading text into the editor.""" buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) is True + docEditor = nwGUI.docEditor longText = "### Lorem Ipsum\n\n%s" % "\n\n".join(ipsumText*20) - nwGUI.docEditor.replaceText(longText) + docEditor.replaceText(longText) nwGUI.saveDocument() nwGUI.closeDocument() # Invalid handle - assert nwGUI.docEditor.loadText("abcdefghijklm") is False + assert docEditor.loadText("abcdefghijklm") is False # Regular open - assert nwGUI.docEditor.loadText(C.hSceneDoc) is True + assert docEditor.loadText(C.hSceneDoc) is True # Regular open, with line number (1 indexed) - assert nwGUI.docEditor.loadText(C.hSceneDoc, tLine=4) is True - cursPos = nwGUI.docEditor.getCursorPosition() - assert nwGUI.docEditor.document().findBlock(cursPos).blockNumber() == 3 + assert docEditor.loadText(C.hSceneDoc, tLine=4) is True + cursPos = docEditor.getCursorPosition() + assert docEditor.document().findBlock(cursPos).blockNumber() == 3 # Load empty document - nwGUI.docEditor.replaceText("") + docEditor.replaceText("") nwGUI.saveDocument() - assert nwGUI.docEditor.loadText(C.hSceneDoc) is True - assert nwGUI.docEditor.toPlainText() == "" + assert docEditor.loadText(C.hSceneDoc) is True + assert docEditor.toPlainText() == "" # qtbot.stop() @@ -155,35 +176,36 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, projPath, ipsumTex """Test saving text from the editor.""" buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) is True + docEditor = nwGUI.docEditor longText = "### Lorem Ipsum\n\n%s" % "\n\n".join(ipsumText) - nwGUI.docEditor.replaceText(longText) + docEditor.replaceText(longText) # Missing item - nwItem = nwGUI.docEditor._nwItem - nwGUI.docEditor._nwItem = None - assert nwGUI.docEditor.saveText() is False - nwGUI.docEditor._nwItem = nwItem + nwItem = docEditor._nwItem + docEditor._nwItem = None + assert docEditor.saveText() is False + docEditor._nwItem = nwItem # Unknown handle - nwGUI.docEditor._docHandle = "0123456789abcdef" - assert nwGUI.docEditor.saveText() is False - nwGUI.docEditor._docHandle = C.hSceneDoc + docEditor._docHandle = "0123456789abcdef" + assert docEditor.saveText() is False + docEditor._docHandle = C.hSceneDoc # Cause error when saving with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) - assert nwGUI.docEditor.saveText() is False + assert docEditor.saveText() is False assert "Could not save document." in caplog.text # Change header level assert SHARED.project.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT # type: ignore - nwGUI.docEditor.replaceText(longText[1:]) - assert nwGUI.docEditor.saveText() is True + docEditor.replaceText(longText[1:]) + assert docEditor.saveText() is True assert SHARED.project.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT # type: ignore # Regular save - assert nwGUI.docEditor.saveText() is True + assert docEditor.saveText() is True # qtbot.stop() @@ -195,6 +217,7 @@ def testGuiEditor_MetaData(qtbot, nwGUI, projPath, mockRnd): """Test extracting various meta data and other values.""" buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) is True + docEditor = nwGUI.docEditor # Get Text # This should replace line and paragraph separators, but preserve @@ -204,8 +227,8 @@ def testGuiEditor_MetaData(qtbot, nwGUI, projPath, mockRnd): "Some\u2028text.\u2029" "More\u00a0text.\u2029" ) - nwGUI.docEditor.replaceText(newText) - assert nwGUI.docEditor.getText() == ( + docEditor.replaceText(newText) + assert docEditor.getText() == ( "### New Scene\n\n" "Some\n" "text.\n" @@ -213,28 +236,28 @@ def testGuiEditor_MetaData(qtbot, nwGUI, projPath, mockRnd): ) # Check Properties - assert nwGUI.docEditor.docChanged is True - assert nwGUI.docEditor.docHandle == C.hSceneDoc - assert nwGUI.docEditor.lastActive > 0.0 - assert nwGUI.docEditor.isEmpty is False + assert docEditor.docChanged is True + assert docEditor.docHandle == C.hSceneDoc + assert docEditor.lastActive > 0.0 + assert docEditor.isEmpty is False # Cursor Position - nwGUI.docEditor.setCursorPosition(10) - assert nwGUI.docEditor.getCursorPosition() == 10 + docEditor.setCursorPosition(10) + assert docEditor.getCursorPosition() == 10 assert SHARED.project.tree[C.hSceneDoc].cursorPos != 10 # type: ignore - nwGUI.docEditor.saveCursorPosition() + docEditor.saveCursorPosition() assert SHARED.project.tree[C.hSceneDoc].cursorPos == 10 # type: ignore - nwGUI.docEditor.setCursorLine(None) - assert nwGUI.docEditor.getCursorPosition() == 10 - nwGUI.docEditor.setCursorLine(3) - assert nwGUI.docEditor.getCursorPosition() == 15 + docEditor.setCursorLine(None) + assert docEditor.getCursorPosition() == 10 + docEditor.setCursorLine(3) + assert docEditor.getCursorPosition() == 15 # Document Changed Signal - nwGUI.docEditor._docChanged = False - with qtbot.waitSignal(nwGUI.docEditor.editedStatusChanged, raising=True, timeout=100): - nwGUI.docEditor.setDocumentChanged(True) - assert nwGUI.docEditor._docChanged is True + docEditor._docChanged = False + with qtbot.waitSignal(docEditor.editedStatusChanged, raising=True, timeout=100): + docEditor.setDocumentChanged(True) + assert docEditor._docChanged is True # qtbot.stop() @@ -252,19 +275,6 @@ def testGuiEditor_ContextMenu(monkeypatch, qtbot, nwGUI, projPath, mockRnd): sceneItem = SHARED.project.tree[C.hSceneDoc] assert sceneItem is not None - def getMenuForPos(pos: int, select: bool = False) -> QMenu | None: - nonlocal docEditor - cursor = docEditor.textCursor() - cursor.setPosition(pos) - if select: - cursor.select(QTextCursor.SelectionType.WordUnderCursor) - docEditor.setTextCursor(cursor) - docEditor._openContextMenu(docEditor.cursorRect().center()) - for obj in docEditor.children(): - if isinstance(obj, QMenu) and obj.objectName() == "ContextMenu": - return obj - return None - docText = ( "### A Scene\n\n" "@pov: Jane\n" @@ -274,7 +284,7 @@ def getMenuForPos(pos: int, select: bool = False) -> QMenu | None: assert docEditor.getText() == docText # Rename Item from Heading - ctxMenu = getMenuForPos(1) + ctxMenu = getMenuForPos(docEditor, 1) assert ctxMenu is not None actions = [x.text() for x in ctxMenu.actions() if x.text()] assert actions == [ @@ -290,7 +300,7 @@ def getMenuForPos(pos: int, select: bool = False) -> QMenu | None: ctxMenu.deleteLater() # Create Character - ctxMenu = getMenuForPos(21) + ctxMenu = getMenuForPos(docEditor, 21) assert ctxMenu is not None actions = [x.text() for x in ctxMenu.actions() if x.text()] assert actions == [ @@ -305,7 +315,7 @@ def getMenuForPos(pos: int, select: bool = False) -> QMenu | None: ctxMenu.deleteLater() # Follow Character Tag - ctxMenu = getMenuForPos(21) + ctxMenu = getMenuForPos(docEditor, 21) assert ctxMenu is not None actions = [x.text() for x in ctxMenu.actions() if x.text()] assert actions == [ @@ -318,7 +328,7 @@ def getMenuForPos(pos: int, select: bool = False) -> QMenu | None: ctxMenu.deleteLater() # Select Word - ctxMenu = getMenuForPos(31) + ctxMenu = getMenuForPos(docEditor, 31) assert ctxMenu is not None actions = [x.text() for x in ctxMenu.actions() if x.text()] assert actions == [ @@ -330,7 +340,7 @@ def getMenuForPos(pos: int, select: bool = False) -> QMenu | None: ctxMenu.deleteLater() # Select Paragraph - ctxMenu = getMenuForPos(31) + ctxMenu = getMenuForPos(docEditor, 31) assert ctxMenu is not None actions = [x.text() for x in ctxMenu.actions() if x.text()] assert actions == [ @@ -342,7 +352,7 @@ def getMenuForPos(pos: int, select: bool = False) -> QMenu | None: ctxMenu.deleteLater() # Select All - ctxMenu = getMenuForPos(31) + ctxMenu = getMenuForPos(docEditor, 31) assert ctxMenu is not None actions = [x.text() for x in ctxMenu.actions() if x.text()] assert actions == [ @@ -354,7 +364,7 @@ def getMenuForPos(pos: int, select: bool = False) -> QMenu | None: ctxMenu.deleteLater() # Copy Text - ctxMenu = getMenuForPos(31, True) + ctxMenu = getMenuForPos(docEditor, 31, True) assert ctxMenu is not None assert docEditor.textCursor().selectedText() == "text" actions = [x.text() for x in ctxMenu.actions() if x.text()] @@ -383,6 +393,90 @@ def getMenuForPos(pos: int, select: bool = False) -> QMenu | None: # END Test testGuiEditor_ContextMenu +@pytest.mark.gui +def testGuiEditor_SpellChecking(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd): + """Test the document spell checker.""" + monkeypatch.setattr(QMenu, "exec", lambda *a: None) + + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True + docEditor = nwGUI.docEditor + + text = "### A Scene\n\n%s" % "\n\n".join(ipsumText) + docEditor.replaceText(text) + + # Toggle State + # ============ + + # No language set + SHARED.spelling._language = None + SHARED.project.data.setSpellCheck(False) + docEditor.toggleSpellCheck(True) + assert SHARED.project.data.spellCheck is False + + # No spell checker available + SHARED.spelling._language = "en" + docEditor.toggleSpellCheck(True) + assert SHARED.project.data.spellCheck is True + + CONFIG.hasEnchant = False + docEditor.toggleSpellCheck(True) + assert SHARED.project.data.spellCheck is False + CONFIG.hasEnchant = True + docEditor.toggleSpellCheck(True) + assert SHARED.project.data.spellCheck is True + + # Plain Toggle + docEditor.toggleSpellCheck(None) + assert SHARED.project.data.spellCheck is False + + # Run SpellCheck + # ============== + SHARED.project.data.setSpellCheck(True) + + # With Suggestion + with monkeypatch.context() as mp: + mp.setattr(docEditor._qDocument, "spellErrorAtPos", lambda *a: ("Lorem", 0, 5, ["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 + ctxMenu.actions()[7].trigger() + QApplication.processEvents() + assert docEditor.getText() == text.replace("Lorem", "Lorax", 1) + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # Without Suggestion + with monkeypatch.context() as mp: + mp.setattr(docEditor._qDocument, "spellErrorAtPos", lambda *a: ("Lorax", 0, 5, [])) + ctxMenu = getMenuForPos(docEditor, 16) + 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) + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # Add to Dictionary + with monkeypatch.context() as mp: + mp.setattr(docEditor._qDocument, "spellErrorAtPos", lambda *a: ("Lorax", 0, 5, [])) + ctxMenu = getMenuForPos(docEditor, 16) + assert ctxMenu is not None + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert "Add Word to Dictionary" in actions + assert "Lorax" not in SHARED.spelling._userDict + ctxMenu.actions()[7].trigger() + assert "Lorax" in SHARED.spelling._userDict + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # qtbot.stop() + +# END Test testGuiEditor_SpellChecking + + @pytest.mark.gui def testGuiEditor_Actions(qtbot, nwGUI, projPath, ipsumText, mockRnd): """Test the document actions. This is not an extensive test of the @@ -392,10 +486,11 @@ def testGuiEditor_Actions(qtbot, nwGUI, projPath, ipsumText, mockRnd): """ buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) is True + docEditor = nwGUI.docEditor text = "### A Scene\n\n%s" % "\n\n".join(ipsumText) - nwGUI.docEditor.replaceText(text) - doc = nwGUI.docEditor.document() + docEditor.replaceText(text) + doc = docEditor.document() # Select/Cut/Copy/Paste/Undo/Redo # =============================== @@ -403,26 +498,26 @@ def testGuiEditor_Actions(qtbot, nwGUI, projPath, ipsumText, mockRnd): QApplication.clipboard().clear() # Select All - assert nwGUI.docEditor.docAction(nwDocAction.SEL_ALL) is True - cursor = nwGUI.docEditor.textCursor() + assert docEditor.docAction(nwDocAction.SEL_ALL) is True + cursor = docEditor.textCursor() assert cursor.hasSelection() is True assert cursor.selectedText() == text.replace("\n", "\u2029") cursor.clearSelection() # Select Paragraph - nwGUI.docEditor.setCursorPosition(1000) - assert nwGUI.docEditor.getCursorPosition() == 1000 - assert nwGUI.docEditor.docAction(nwDocAction.SEL_PARA) is True - cursor = nwGUI.docEditor.textCursor() + docEditor.setCursorPosition(1000) + assert docEditor.getCursorPosition() == 1000 + assert docEditor.docAction(nwDocAction.SEL_PARA) is True + cursor = docEditor.textCursor() assert cursor.selectedText() == ipsumText[1] # Cut Selected Text - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(1000) - assert nwGUI.docEditor.docAction(nwDocAction.SEL_PARA) is True - assert nwGUI.docEditor.docAction(nwDocAction.CUT) is True + docEditor.replaceText(text) + docEditor.setCursorPosition(1000) + assert docEditor.docAction(nwDocAction.SEL_PARA) is True + assert docEditor.docAction(nwDocAction.CUT) is True - newText = nwGUI.docEditor.getText() + newText = docEditor.getText() newPara = list(filter(str.strip, newText.split("\n"))) assert newPara[0] == "### A Scene" assert newPara[1] == ipsumText[0] @@ -431,23 +526,23 @@ def testGuiEditor_Actions(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert newPara[4] == ipsumText[4] # Paste Back In - assert nwGUI.docEditor.docAction(nwDocAction.PASTE) is True - assert nwGUI.docEditor.getText() == text + assert docEditor.docAction(nwDocAction.PASTE) is True + assert docEditor.getText() == text # Copy Next Paragraph - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(1500) - assert nwGUI.docEditor.docAction(nwDocAction.SEL_PARA) is True - assert nwGUI.docEditor.docAction(nwDocAction.COPY) is True + docEditor.replaceText(text) + docEditor.setCursorPosition(1500) + assert docEditor.docAction(nwDocAction.SEL_PARA) is True + assert docEditor.docAction(nwDocAction.COPY) is True # Paste at End - nwGUI.docEditor.setCursorPosition(doc.characterCount()) - cursor = nwGUI.docEditor.textCursor() + docEditor.setCursorPosition(doc.characterCount()) + cursor = docEditor.textCursor() cursor.insertBlock() cursor.insertBlock() - assert nwGUI.docEditor.docAction(nwDocAction.PASTE) is True - newText = nwGUI.docEditor.getText() + assert docEditor.docAction(nwDocAction.PASTE) is True + newText = docEditor.getText() newPara = list(filter(str.strip, newText.split("\n"))) assert newPara[5] == ipsumText[4] assert newPara[6] == ipsumText[2] @@ -458,217 +553,217 @@ def testGuiEditor_Actions(qtbot, nwGUI, projPath, ipsumText, mockRnd): # ================== text = "### A Scene\n\n%s" % ipsumText[0] - nwGUI.docEditor.replaceText(text) + docEditor.replaceText(text) # Emphasis - nwGUI.docEditor.setCursorPosition(50) - assert nwGUI.docEditor.docAction(nwDocAction.MD_ITALIC) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "_consectetur_") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(50) + assert docEditor.docAction(nwDocAction.MD_ITALIC) is True + assert docEditor.getText() == text.replace("consectetur", "_consectetur_") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Strong - nwGUI.docEditor.setCursorPosition(50) - assert nwGUI.docEditor.docAction(nwDocAction.MD_BOLD) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "**consectetur**") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(50) + assert docEditor.docAction(nwDocAction.MD_BOLD) is True + assert docEditor.getText() == text.replace("consectetur", "**consectetur**") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Strikeout - nwGUI.docEditor.setCursorPosition(50) - assert nwGUI.docEditor.docAction(nwDocAction.MD_STRIKE) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "~~consectetur~~") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(50) + assert docEditor.docAction(nwDocAction.MD_STRIKE) is True + assert docEditor.getText() == text.replace("consectetur", "~~consectetur~~") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Redo - assert nwGUI.docEditor.docAction(nwDocAction.REDO) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "~~consectetur~~") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + assert docEditor.docAction(nwDocAction.REDO) is True + assert docEditor.getText() == text.replace("consectetur", "~~consectetur~~") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Shortcodes # ========== text = "### A Scene\n\n%s" % ipsumText[0] - nwGUI.docEditor.replaceText(text) + docEditor.replaceText(text) # Italic - nwGUI.docEditor.setCursorPosition(46) - assert nwGUI.docEditor.docAction(nwDocAction.SC_ITALIC) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "[i]consectetur[/i]") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(46) + assert docEditor.docAction(nwDocAction.SC_ITALIC) is True + assert docEditor.getText() == text.replace("consectetur", "[i]consectetur[/i]") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Bold - nwGUI.docEditor.setCursorPosition(46) - assert nwGUI.docEditor.docAction(nwDocAction.SC_BOLD) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "[b]consectetur[/b]") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(46) + assert docEditor.docAction(nwDocAction.SC_BOLD) is True + assert docEditor.getText() == text.replace("consectetur", "[b]consectetur[/b]") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Strikethrough - nwGUI.docEditor.setCursorPosition(46) - assert nwGUI.docEditor.docAction(nwDocAction.SC_STRIKE) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "[s]consectetur[/s]") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(46) + assert docEditor.docAction(nwDocAction.SC_STRIKE) is True + assert docEditor.getText() == text.replace("consectetur", "[s]consectetur[/s]") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Underline - nwGUI.docEditor.setCursorPosition(46) - assert nwGUI.docEditor.docAction(nwDocAction.SC_ULINE) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "[u]consectetur[/u]") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(46) + assert docEditor.docAction(nwDocAction.SC_ULINE) is True + assert docEditor.getText() == text.replace("consectetur", "[u]consectetur[/u]") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Mark - nwGUI.docEditor.setCursorPosition(46) - assert nwGUI.docEditor.docAction(nwDocAction.SC_MARK) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "[m]consectetur[/m]") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(46) + assert docEditor.docAction(nwDocAction.SC_MARK) is True + assert docEditor.getText() == text.replace("consectetur", "[m]consectetur[/m]") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Superscript - nwGUI.docEditor.setCursorPosition(46) - assert nwGUI.docEditor.docAction(nwDocAction.SC_SUP) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "[sup]consectetur[/sup]") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(46) + assert docEditor.docAction(nwDocAction.SC_SUP) is True + assert docEditor.getText() == text.replace("consectetur", "[sup]consectetur[/sup]") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Subscript - nwGUI.docEditor.setCursorPosition(46) - assert nwGUI.docEditor.docAction(nwDocAction.SC_SUB) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "[sub]consectetur[/sub]") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(46) + assert docEditor.docAction(nwDocAction.SC_SUB) is True + assert docEditor.getText() == text.replace("consectetur", "[sub]consectetur[/sub]") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Quotes # ====== text = "### A Scene\n\n%s" % ipsumText[0] - nwGUI.docEditor.replaceText(text) + docEditor.replaceText(text) # Add Single Quotes - nwGUI.docEditor.setCursorPosition(50) - assert nwGUI.docEditor.docAction(nwDocAction.S_QUOTE) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "\u2018consectetur\u2019") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(50) + assert docEditor.docAction(nwDocAction.S_QUOTE) is True + assert docEditor.getText() == text.replace("consectetur", "\u2018consectetur\u2019") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Add Double Quotes - nwGUI.docEditor.setCursorPosition(50) - assert nwGUI.docEditor.docAction(nwDocAction.D_QUOTE) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "\u201cconsectetur\u201d") - assert nwGUI.docEditor.docAction(nwDocAction.UNDO) is True - assert nwGUI.docEditor.getText() == text + docEditor.setCursorPosition(50) + assert docEditor.docAction(nwDocAction.D_QUOTE) is True + assert docEditor.getText() == text.replace("consectetur", "\u201cconsectetur\u201d") + assert docEditor.docAction(nwDocAction.UNDO) is True + assert docEditor.getText() == text # Replace Single Quotes repText = text.replace("consectetur", "'consectetur'") - nwGUI.docEditor.replaceText(repText) - assert nwGUI.docEditor.docAction(nwDocAction.SEL_ALL) is True - assert nwGUI.docEditor.docAction(nwDocAction.REPL_SNG) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "\u2018consectetur\u2019") + docEditor.replaceText(repText) + assert docEditor.docAction(nwDocAction.SEL_ALL) is True + assert docEditor.docAction(nwDocAction.REPL_SNG) is True + assert docEditor.getText() == text.replace("consectetur", "\u2018consectetur\u2019") # Replace Double Quotes repText = text.replace("consectetur", "\"consectetur\"") - nwGUI.docEditor.replaceText(repText) - assert nwGUI.docEditor.docAction(nwDocAction.SEL_ALL) is True - assert nwGUI.docEditor.docAction(nwDocAction.REPL_DBL) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "\u201cconsectetur\u201d") + docEditor.replaceText(repText) + assert docEditor.docAction(nwDocAction.SEL_ALL) is True + assert docEditor.docAction(nwDocAction.REPL_DBL) is True + assert docEditor.getText() == text.replace("consectetur", "\u201cconsectetur\u201d") # Remove Line Breaks # ================== text = "### A Scene\n\n%s" % ipsumText[0] repText = text[:100] + text[100:].replace(" ", "\n", 3) - nwGUI.docEditor.replaceText(repText) - assert nwGUI.docEditor.docAction(nwDocAction.RM_BREAKS) is True - assert nwGUI.docEditor.getText().strip() == text.strip() + docEditor.replaceText(repText) + assert docEditor.docAction(nwDocAction.RM_BREAKS) is True + assert docEditor.getText().strip() == text.strip() # Format Block # ============ text = "## Scene Title\n\nScene text.\n\n" - nwGUI.docEditor.replaceText(text) + docEditor.replaceText(text) # Header 1 - nwGUI.docEditor.setCursorPosition(0) - assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_H1) is True - assert nwGUI.docEditor.getText() == "# Scene Title\n\nScene text.\n\n" + docEditor.setCursorPosition(0) + assert docEditor.docAction(nwDocAction.BLOCK_H1) is True + assert docEditor.getText() == "# Scene Title\n\nScene text.\n\n" # Header 2 - nwGUI.docEditor.setCursorPosition(0) - assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_H2) is True - assert nwGUI.docEditor.getText() == "## Scene Title\n\nScene text.\n\n" + docEditor.setCursorPosition(0) + assert docEditor.docAction(nwDocAction.BLOCK_H2) is True + assert docEditor.getText() == "## Scene Title\n\nScene text.\n\n" # Header 3 - nwGUI.docEditor.setCursorPosition(0) - assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_H3) is True - assert nwGUI.docEditor.getText() == "### Scene Title\n\nScene text.\n\n" + docEditor.setCursorPosition(0) + assert docEditor.docAction(nwDocAction.BLOCK_H3) is True + assert docEditor.getText() == "### Scene Title\n\nScene text.\n\n" # Header 4 - nwGUI.docEditor.setCursorPosition(0) - assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_H4) is True - assert nwGUI.docEditor.getText() == "#### Scene Title\n\nScene text.\n\n" + docEditor.setCursorPosition(0) + assert docEditor.docAction(nwDocAction.BLOCK_H4) is True + assert docEditor.getText() == "#### Scene Title\n\nScene text.\n\n" # Comment - nwGUI.docEditor.setCursorPosition(20) - assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_COM) is True - assert nwGUI.docEditor.getText() == "#### Scene Title\n\n% Scene text.\n\n" + docEditor.setCursorPosition(20) + assert docEditor.docAction(nwDocAction.BLOCK_COM) is True + assert docEditor.getText() == "#### Scene Title\n\n% Scene text.\n\n" # Ignore Text - nwGUI.docEditor.setCursorPosition(20) - assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_IGN) is True - assert nwGUI.docEditor.getText() == "#### Scene Title\n\n%~ Scene text.\n\n" + docEditor.setCursorPosition(20) + assert docEditor.docAction(nwDocAction.BLOCK_IGN) is True + assert docEditor.getText() == "#### Scene Title\n\n%~ Scene text.\n\n" # Text - nwGUI.docEditor.setCursorPosition(20) - assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "#### Scene Title\n\nScene text.\n\n" + docEditor.setCursorPosition(20) + assert docEditor.docAction(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "#### Scene Title\n\nScene text.\n\n" # Align Left - nwGUI.docEditor.setCursorPosition(20) - assert nwGUI.docEditor.docAction(nwDocAction.ALIGN_L) is True - assert nwGUI.docEditor.getText() == "#### Scene Title\n\nScene text. <<\n\n" + docEditor.setCursorPosition(20) + assert docEditor.docAction(nwDocAction.ALIGN_L) is True + assert docEditor.getText() == "#### Scene Title\n\nScene text. <<\n\n" # Align Right - nwGUI.docEditor.setCursorPosition(20) - assert nwGUI.docEditor.docAction(nwDocAction.ALIGN_R) is True - assert nwGUI.docEditor.getText() == "#### Scene Title\n\n>> Scene text.\n\n" + docEditor.setCursorPosition(20) + assert docEditor.docAction(nwDocAction.ALIGN_R) is True + assert docEditor.getText() == "#### Scene Title\n\n>> Scene text.\n\n" # Align Centre - nwGUI.docEditor.setCursorPosition(20) - assert nwGUI.docEditor.docAction(nwDocAction.ALIGN_C) is True - assert nwGUI.docEditor.getText() == "#### Scene Title\n\n>> Scene text. <<\n\n" + docEditor.setCursorPosition(20) + assert docEditor.docAction(nwDocAction.ALIGN_C) is True + assert docEditor.getText() == "#### Scene Title\n\n>> Scene text. <<\n\n" # Indent Left - nwGUI.docEditor.setCursorPosition(20) - assert nwGUI.docEditor.docAction(nwDocAction.INDENT_L) is True - assert nwGUI.docEditor.getText() == "#### Scene Title\n\n> Scene text.\n\n" + docEditor.setCursorPosition(20) + assert docEditor.docAction(nwDocAction.INDENT_L) is True + assert docEditor.getText() == "#### Scene Title\n\n> Scene text.\n\n" # Indent Right - nwGUI.docEditor.setCursorPosition(20) - assert nwGUI.docEditor.docAction(nwDocAction.INDENT_R) is True - assert nwGUI.docEditor.getText() == "#### Scene Title\n\n> Scene text. <\n\n" + docEditor.setCursorPosition(20) + assert docEditor.docAction(nwDocAction.INDENT_R) is True + assert docEditor.getText() == "#### Scene Title\n\n> Scene text. <\n\n" # Text (Reset) - nwGUI.docEditor.setCursorPosition(20) - assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "#### Scene Title\n\nScene text.\n\n" + docEditor.setCursorPosition(20) + assert docEditor.docAction(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "#### Scene Title\n\nScene text.\n\n" # Invalid Actions # =============== # No Document Handle - nwGUI.docEditor._docHandle = None - assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_TXT) is False - nwGUI.docEditor._docHandle = C.hSceneDoc + docEditor._docHandle = None + assert docEditor.docAction(nwDocAction.BLOCK_TXT) is False + docEditor._docHandle = C.hSceneDoc # Wrong Action Type - assert nwGUI.docEditor.docAction(None) is False + assert docEditor.docAction(None) is False # Unknown Action - assert nwGUI.docEditor.docAction(nwDocAction.NO_ACTION) is False + assert docEditor.docAction(nwDocAction.NO_ACTION) is False # qtbot.stop() @@ -685,8 +780,8 @@ def testGuiEditor_ToolBar(qtbot, nwGUI, projPath, mockRnd): buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) is True - docEditor: GuiDocEditor = nwGUI.docEditor - docToolBar: GuiDocToolBar = docEditor.docToolBar + docEditor = nwGUI.docEditor + docToolBar = docEditor.docToolBar text = ( "### A Scene\n\n" @@ -784,75 +879,96 @@ def testGuiEditor_Insert(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd """Test the document insert functions.""" buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) is True - - text = "### A Scene\n\n%s" % "\n\n".join(ipsumText) - nwGUI.docEditor.replaceText(text) + docEditor = nwGUI.docEditor + text = "### A Scene\n\n%s" % ipsumText[0] # Insert Text # =========== - text = "### A Scene\n\n%s" % ipsumText[0] - nwGUI.docEditor.replaceText(text) - # No Document Handle - nwGUI.docEditor._docHandle = None - nwGUI.docEditor.setCursorPosition(24) - assert nwGUI.docEditor.insertText("Stuff") is False - nwGUI.docEditor._docHandle = C.hSceneDoc + docEditor.replaceText(text) + docEditor._docHandle = None + docEditor.setCursorPosition(24) + docEditor.insertText("Stuff") + assert docEditor.getText() == text + docEditor._docHandle = C.hSceneDoc # Insert String - nwGUI.docEditor.setCursorPosition(24) - assert nwGUI.docEditor.insertText(", ipsumer,") is True - assert nwGUI.docEditor.getText() == text[:24] + ", ipsumer," + text[24:] + docEditor.replaceText(text) + docEditor.setCursorPosition(24) + docEditor.insertText(", ipsumer,") + assert docEditor.getText() == text[:24] + ", ipsumer," + text[24:] # Single Quotes - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(41) - assert nwGUI.docEditor.insertText(nwDocInsert.QUOTE_LS) is True - nwGUI.docEditor.setCursorPosition(53) - assert nwGUI.docEditor.insertText(nwDocInsert.QUOTE_RS) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "\u2018consectetur\u2019") + docEditor.replaceText(text) + docEditor.setCursorPosition(41) + docEditor.insertText(nwDocInsert.QUOTE_LS) + docEditor.setCursorPosition(53) + docEditor.insertText(nwDocInsert.QUOTE_RS) + assert docEditor.getText() == text.replace("consectetur", "\u2018consectetur\u2019") # Double Quotes - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(41) - assert nwGUI.docEditor.insertText(nwDocInsert.QUOTE_LD) is True - nwGUI.docEditor.setCursorPosition(53) - assert nwGUI.docEditor.insertText(nwDocInsert.QUOTE_RD) is True - assert nwGUI.docEditor.getText() == text.replace("consectetur", "\u201cconsectetur\u201d") + docEditor.replaceText(text) + docEditor.setCursorPosition(41) + docEditor.insertText(nwDocInsert.QUOTE_LD) + docEditor.setCursorPosition(53) + docEditor.insertText(nwDocInsert.QUOTE_RD) + assert docEditor.getText() == text.replace("consectetur", "\u201cconsectetur\u201d") # Invalid Inserts - assert nwGUI.docEditor.insertText(nwDocInsert.NO_INSERT) is False - assert nwGUI.docEditor.insertText(123) is False + docEditor.replaceText(text) + docEditor.insertText(nwDocInsert.NO_INSERT) + assert docEditor.getText() == text + docEditor.insertText(123) + assert docEditor.getText() == text + + # Insert Comments + # =============== + + docEditor.replaceText(text) + count = docEditor.document().characterCount() + + # Invalid Position + docEditor.setCursorPosition(12) + docEditor.insertText(nwDocInsert.FOOTNOTE) + assert docEditor.getText() == text + + # Valid Position + docEditor.setCursorPosition(count) + docEditor.insertText(nwDocInsert.FOOTNOTE) + assert "[footnote:" in docEditor.getText() + assert "%Footnote." in docEditor.getText() + assert docEditor.getCursorPosition() > count # Insert KeyWords # =============== text = "### A Scene\n\n\n%s" % ipsumText[0] - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorLine(3) + docEditor.replaceText(text) + docEditor.setCursorLine(3) # Invalid Keyword - assert nwGUI.docEditor.insertKeyWord("stuff") is False - assert nwGUI.docEditor.getText() == text - - # Valid Keyword - assert nwGUI.docEditor.insertKeyWord(nwKeyWords.POV_KEY) is True - assert nwGUI.docEditor.insertText("Jane\n") - assert nwGUI.docEditor.getText() == text.replace( - "\n\n\n", "\n\n@pov: Jane\n\n", 1 - ) + docEditor.insertKeyWord("stuff") + assert docEditor.getText() == text # Invalid Block with monkeypatch.context() as mp: mp.setattr(QTextBlock, "isValid", lambda *a, **k: False) - assert nwGUI.docEditor.insertKeyWord(nwKeyWords.POV_KEY) is False + docEditor.insertKeyWord(nwKeyWords.POV_KEY) + assert docEditor.getText() == text + + # Valid Keyword + docEditor.insertKeyWord(nwKeyWords.POV_KEY) + docEditor.insertText("Jane\n") + assert docEditor.getText() == text.replace( + "\n\n\n", "\n\n@pov: Jane\n\n", 1 + ) # Insert In-Block - nwGUI.docEditor.setCursorPosition(20) - assert nwGUI.docEditor.insertKeyWord(nwKeyWords.CHAR_KEY) is True - assert nwGUI.docEditor.insertText("John") - assert nwGUI.docEditor.getText() == text.replace( + docEditor.setCursorPosition(20) + docEditor.insertKeyWord(nwKeyWords.CHAR_KEY) + docEditor.insertText("John") + assert docEditor.getText() == text.replace( "\n\n\n", "\n\n@pov: Jane\n@char: John\n\n", 1 ) @@ -866,38 +982,39 @@ def testGuiEditor_TextManipulation(qtbot, nwGUI, projPath, ipsumText, mockRnd): """Test the text manipulation functions.""" buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) is True + docEditor = nwGUI.docEditor text = "### A Scene\n\n%s" % "\n\n".join(ipsumText) - nwGUI.docEditor.replaceText(text) + docEditor.replaceText(text) # Wrap Selection # ============== text = "### A Scene\n\n%s" % "\n\n".join(ipsumText[0:2]) - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) + docEditor.replaceText(text) + docEditor.setCursorPosition(45) # Wrap Equal - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) - nwGUI.docEditor._wrapSelection("=") - assert nwGUI.docEditor.getText() == text.replace("consectetur", "=consectetur=") + docEditor.replaceText(text) + docEditor.setCursorPosition(45) + docEditor._wrapSelection("=") + assert docEditor.getText() == text.replace("consectetur", "=consectetur=") # Wrap Unequal - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) - nwGUI.docEditor._wrapSelection("=", "*") - assert nwGUI.docEditor.getText() == text.replace("consectetur", "=consectetur*") + docEditor.replaceText(text) + docEditor.setCursorPosition(45) + docEditor._wrapSelection("=", "*") + assert docEditor.getText() == text.replace("consectetur", "=consectetur*") # Past Paragraph - nwGUI.docEditor.replaceText(text) - cursor = nwGUI.docEditor.textCursor() + docEditor.replaceText(text) + cursor = docEditor.textCursor() cursor.setPosition(13, QTextCursor.MoveAnchor) cursor.setPosition(1000, QTextCursor.KeepAnchor) - nwGUI.docEditor.setTextCursor(cursor) - nwGUI.docEditor._wrapSelection("=") + docEditor.setTextCursor(cursor) + docEditor._wrapSelection("=") - newText = nwGUI.docEditor.getText() + newText = docEditor.getText() newPara = list(filter(str.strip, newText.split("\n"))) assert newPara[1] == "="+ipsumText[0]+"=" assert newPara[2] == ipsumText[1] @@ -908,101 +1025,101 @@ def testGuiEditor_TextManipulation(qtbot, nwGUI, projPath, ipsumText, mockRnd): text = "### A Scene\n\n%s" % "\n\n".join(ipsumText[0:2]) # Block format repetition - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(39) - nwGUI.docEditor._toggleFormat(1, "=") - assert nwGUI.docEditor.getText() == text.replace("amet", "=amet=", 1) - nwGUI.docEditor._toggleFormat(1, "=") - assert nwGUI.docEditor.getText() == text.replace("amet", "=amet=", 1) + docEditor.replaceText(text) + docEditor.setCursorPosition(39) + docEditor._toggleFormat(1, "=") + assert docEditor.getText() == text.replace("amet", "=amet=", 1) + docEditor._toggleFormat(1, "=") + assert docEditor.getText() == text.replace("amet", "=amet=", 1) # Wrap Single Equal - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) - nwGUI.docEditor._toggleFormat(1, "=") - assert nwGUI.docEditor.getText() == text.replace("consectetur", "=consectetur=") + docEditor.replaceText(text) + docEditor.setCursorPosition(45) + docEditor._toggleFormat(1, "=") + assert docEditor.getText() == text.replace("consectetur", "=consectetur=") # Past Paragraph - nwGUI.docEditor.replaceText(text) - cursor = nwGUI.docEditor.textCursor() + docEditor.replaceText(text) + cursor = docEditor.textCursor() cursor.setPosition(13, QTextCursor.MoveAnchor) cursor.setPosition(1000, QTextCursor.KeepAnchor) - nwGUI.docEditor.setTextCursor(cursor) - nwGUI.docEditor._toggleFormat(1, "=") + docEditor.setTextCursor(cursor) + docEditor._toggleFormat(1, "=") - newText = nwGUI.docEditor.getText() + newText = docEditor.getText() newPara = list(filter(str.strip, newText.split("\n"))) assert newPara[1] == "="+ipsumText[0]+"=" assert newPara[2] == ipsumText[1] # Wrap Double Equal - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) - nwGUI.docEditor._toggleFormat(2, "=") - assert nwGUI.docEditor.getText() == text.replace("consectetur", "==consectetur==") + docEditor.replaceText(text) + docEditor.setCursorPosition(45) + docEditor._toggleFormat(2, "=") + assert docEditor.getText() == text.replace("consectetur", "==consectetur==") # Toggle Double Equal with Selection - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorSelection(41, 11) - nwGUI.docEditor._toggleFormat(2, "=") - assert nwGUI.docEditor.getText() == text.replace("consectetur", "==consectetur==") - assert nwGUI.docEditor.getSelectedText() == "consectetur" - nwGUI.docEditor._toggleFormat(2, "=") - assert nwGUI.docEditor.getText() == text - assert nwGUI.docEditor.getSelectedText() == "consectetur" + docEditor.replaceText(text) + docEditor.setCursorSelection(41, 11) + docEditor._toggleFormat(2, "=") + assert docEditor.getText() == text.replace("consectetur", "==consectetur==") + assert docEditor.getSelectedText() == "consectetur" + docEditor._toggleFormat(2, "=") + assert docEditor.getText() == text + assert docEditor.getSelectedText() == "consectetur" # Toggle Double Equal - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) - nwGUI.docEditor._toggleFormat(2, "=") - nwGUI.docEditor._toggleFormat(2, "=") - assert nwGUI.docEditor.getText() == text + docEditor.replaceText(text) + docEditor.setCursorPosition(45) + docEditor._toggleFormat(2, "=") + docEditor._toggleFormat(2, "=") + assert docEditor.getText() == text # Toggle Triple+Double Equal - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) - nwGUI.docEditor._toggleFormat(3, "=") - nwGUI.docEditor._toggleFormat(2, "=") - assert nwGUI.docEditor.getText() == text.replace("consectetur", "=consectetur=") + docEditor.replaceText(text) + docEditor.setCursorPosition(45) + docEditor._toggleFormat(3, "=") + docEditor._toggleFormat(2, "=") + assert docEditor.getText() == text.replace("consectetur", "=consectetur=") # Toggle Unequal repText = text.replace("consectetur", "=consectetur==") - nwGUI.docEditor.replaceText(repText) - nwGUI.docEditor.setCursorPosition(45) - nwGUI.docEditor._toggleFormat(1, "=") - assert nwGUI.docEditor.getText() == text.replace("consectetur", "consectetur=") - nwGUI.docEditor._toggleFormat(1, "=") - assert nwGUI.docEditor.getText() == repText + docEditor.replaceText(repText) + docEditor.setCursorPosition(45) + docEditor._toggleFormat(1, "=") + assert docEditor.getText() == text.replace("consectetur", "consectetur=") + docEditor._toggleFormat(1, "=") + assert docEditor.getText() == repText # Replace Quotes # ============== # No Selection text = "### A Scene\n\n%s" % ipsumText[0].replace("consectetur", "=consectetur=") - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) - nwGUI.docEditor._replaceQuotes("=", "<", ">") - assert nwGUI.docEditor.getText() == text + docEditor.replaceText(text) + docEditor.setCursorPosition(45) + docEditor._replaceQuotes("=", "<", ">") + assert docEditor.getText() == text # First Paragraph Selected # This should not replace anything in second paragraph text = "### A Scene\n\n%s" % "\n\n".join(ipsumText[0:2]).replace("ipsum", "=ipsum=") - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) - assert nwGUI.docEditor.docAction(nwDocAction.SEL_PARA) - nwGUI.docEditor._replaceQuotes("=", "<", ">") + docEditor.replaceText(text) + docEditor.setCursorPosition(45) + assert docEditor.docAction(nwDocAction.SEL_PARA) + docEditor._replaceQuotes("=", "<", ">") - newText = nwGUI.docEditor.getText() + newText = docEditor.getText() newPara = list(filter(str.strip, newText.split("\n"))) assert newPara[1] == ipsumText[0].replace("ipsum", "") assert newPara[2] == ipsumText[1].replace("ipsum", "=ipsum=") # Edge of Document text = ipsumText[0].replace("Lorem", "=Lorem=") - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) - assert nwGUI.docEditor.docAction(nwDocAction.SEL_ALL) - nwGUI.docEditor._replaceQuotes("=", "<", ">") - assert nwGUI.docEditor.getText() == text.replace("=Lorem=", "") + docEditor.replaceText(text) + docEditor.setCursorPosition(45) + assert docEditor.docAction(nwDocAction.SEL_ALL) + docEditor._replaceQuotes("=", "<", ">") + assert docEditor.getText() == text.replace("=Lorem=", "") # Remove Line Breaks # ================== @@ -1011,34 +1128,34 @@ def testGuiEditor_TextManipulation(qtbot, nwGUI, projPath, ipsumText, mockRnd): parTwo = ipsumText[1].replace(" ", "\n", 5) # Check Blocks - cursor = nwGUI.docEditor.textCursor() + cursor = docEditor.textCursor() cursor.clearSelection() text = "### A Scene\n\n%s\n\n%s" % (parOne, parTwo) - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) - assert len(nwGUI.docEditor._selectedBlocks(cursor)) == 0 + docEditor.replaceText(text) + docEditor.setCursorPosition(45) + assert len(docEditor._selectedBlocks(cursor)) == 0 cursor.select(QTextCursor.SelectionType.Document) - assert len(nwGUI.docEditor._selectedBlocks(cursor)) == 15 + assert len(docEditor._selectedBlocks(cursor)) == 15 # Remove All text = "### A Scene\n\n%s\n\n%s" % (parOne, parTwo) - nwGUI.docEditor.replaceText(text) - nwGUI.docEditor.setCursorPosition(45) - nwGUI.docEditor._removeInParLineBreaks() - assert nwGUI.docEditor.getText() == "### A Scene\n\n%s\n" % "\n\n".join(ipsumText[0:2]) + docEditor.replaceText(text) + docEditor.setCursorPosition(45) + docEditor._removeInParLineBreaks() + assert docEditor.getText() == "### A Scene\n\n%s\n" % "\n\n".join(ipsumText[0:2]) # Remove in First Paragraph # Second paragraphs should remain unchanged text = "### A Scene\n\n%s\n\n%s" % (parOne, parTwo) - nwGUI.docEditor.replaceText(text) - cursor = nwGUI.docEditor.textCursor() + docEditor.replaceText(text) + cursor = docEditor.textCursor() cursor.setPosition(16, QTextCursor.MoveAnchor) cursor.setPosition(680, QTextCursor.KeepAnchor) - nwGUI.docEditor.setTextCursor(cursor) - nwGUI.docEditor._removeInParLineBreaks() + docEditor.setTextCursor(cursor) + docEditor._removeInParLineBreaks() - newText = nwGUI.docEditor.getText() + newText = docEditor.getText() newPara = list(filter(str.strip, newText.split("\n"))) twoBits = parTwo.split() assert newPara[1] == ipsumText[0] @@ -1052,21 +1169,21 @@ def testGuiEditor_TextManipulation(qtbot, nwGUI, projPath, ipsumText, mockRnd): # Key Press Events # ================ text = "### A Scene\n\n%s\n\n%s" % (parOne, parTwo) - nwGUI.docEditor.replaceText(text) - assert nwGUI.docEditor.getText() == text + docEditor.replaceText(text) + assert docEditor.getText() == text # Select All - qtbot.keyClick(nwGUI.docEditor, Qt.Key_A, modifier=Qt.ControlModifier, delay=KEY_DELAY) - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Delete, delay=KEY_DELAY) - assert nwGUI.docEditor.getText() == "" + qtbot.keyClick(docEditor, Qt.Key_A, modifier=Qt.ControlModifier, delay=KEY_DELAY) + qtbot.keyClick(docEditor, Qt.Key_Delete, delay=KEY_DELAY) + assert docEditor.getText() == "" # Undo - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Z, modifier=Qt.ControlModifier, delay=KEY_DELAY) - assert nwGUI.docEditor.getText() == text + qtbot.keyClick(docEditor, Qt.Key_Z, modifier=Qt.ControlModifier, delay=KEY_DELAY) + assert docEditor.getText() == text # Redo - qtbot.keyClick(nwGUI.docEditor, Qt.Key_Y, modifier=Qt.ControlModifier, delay=KEY_DELAY) - assert nwGUI.docEditor.getText() == "" + qtbot.keyClick(docEditor, Qt.Key_Y, modifier=Qt.ControlModifier, delay=KEY_DELAY) + assert docEditor.getText() == "" # qtbot.stop() @@ -1078,332 +1195,333 @@ def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, projPath, ipsumText """Test the block formatting function.""" buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) is True + docEditor = nwGUI.docEditor # Invalid and Generic # =================== text = "### A Scene\n\n%s" % ipsumText[0] - nwGUI.docEditor.replaceText(text) + docEditor.replaceText(text) # Invalid Block - nwGUI.docEditor.setCursorPosition(0) + docEditor.setCursorPosition(0) with monkeypatch.context() as mp: mp.setattr(QTextBlock, "isValid", lambda *a, **k: False) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is False + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is False # Keyword - nwGUI.docEditor.replaceText("@pov: Jane\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is False - assert nwGUI.docEditor.getText() == "@pov: Jane\n\n" - assert nwGUI.docEditor.getCursorPosition() == 5 + docEditor.replaceText("@pov: Jane\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is False + assert docEditor.getText() == "@pov: Jane\n\n" + assert docEditor.getCursorPosition() == 5 # Unsupported Format - nwGUI.docEditor.replaceText("% Comment\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.NO_ACTION) is False + docEditor.replaceText("% Comment\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.NO_ACTION) is False # Block Stripping : Left Side # =========================== # Strip Comment w/Space - nwGUI.docEditor.replaceText("% Comment\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Comment\n\n" - assert nwGUI.docEditor.getCursorPosition() == 3 + docEditor.replaceText("% Comment\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Comment\n\n" + assert docEditor.getCursorPosition() == 3 # Strip Comment wo/Space - nwGUI.docEditor.replaceText("%Comment\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Comment\n\n" - assert nwGUI.docEditor.getCursorPosition() == 4 + docEditor.replaceText("%Comment\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Comment\n\n" + assert docEditor.getCursorPosition() == 4 # Strip Header 1 - nwGUI.docEditor.replaceText("# Title\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Title\n\n" - assert nwGUI.docEditor.getCursorPosition() == 3 + docEditor.replaceText("# Title\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Title\n\n" + assert docEditor.getCursorPosition() == 3 # Strip Header 2 - nwGUI.docEditor.replaceText("## Title\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Title\n\n" - assert nwGUI.docEditor.getCursorPosition() == 2 + docEditor.replaceText("## Title\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Title\n\n" + assert docEditor.getCursorPosition() == 2 # Strip Header 3 - nwGUI.docEditor.replaceText("### Title\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Title\n\n" - assert nwGUI.docEditor.getCursorPosition() == 1 + docEditor.replaceText("### Title\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Title\n\n" + assert docEditor.getCursorPosition() == 1 # Strip Header 4 - nwGUI.docEditor.replaceText("#### Title\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Title\n\n" - assert nwGUI.docEditor.getCursorPosition() == 0 + docEditor.replaceText("#### Title\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Title\n\n" + assert docEditor.getCursorPosition() == 0 # Strip Novel Title - nwGUI.docEditor.replaceText("#! Title\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Title\n\n" - assert nwGUI.docEditor.getCursorPosition() == 2 + docEditor.replaceText("#! Title\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Title\n\n" + assert docEditor.getCursorPosition() == 2 # Strip Unnumbered Chapter - nwGUI.docEditor.replaceText("##! Title\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Title\n\n" - assert nwGUI.docEditor.getCursorPosition() == 1 + docEditor.replaceText("##! Title\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Title\n\n" + assert docEditor.getCursorPosition() == 1 # Strip Hard Scene - nwGUI.docEditor.replaceText("###! Title\n\n") - nwGUI.docEditor.setCursorPosition(6) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Title\n\n" - assert nwGUI.docEditor.getCursorPosition() == 1 + docEditor.replaceText("###! Title\n\n") + docEditor.setCursorPosition(6) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Title\n\n" + assert docEditor.getCursorPosition() == 1 # Strip Text - nwGUI.docEditor.replaceText("Generic text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Generic text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 5 + docEditor.replaceText("Generic text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Generic text\n\n" + assert docEditor.getCursorPosition() == 5 # Strip Left Angle Brackets : Double w/Space - nwGUI.docEditor.replaceText(">> Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 2 + docEditor.replaceText(">> Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 2 # Strip Left Angle Brackets : Single w/Space - nwGUI.docEditor.replaceText("> Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 3 + docEditor.replaceText("> Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 3 # Strip Left Angle Brackets : Double wo/Space - nwGUI.docEditor.replaceText(">>Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 3 + docEditor.replaceText(">>Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 3 # Strip Left Angle Brackets : Single wo/Space - nwGUI.docEditor.replaceText(">Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 4 + docEditor.replaceText(">Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 4 # Block Stripping : Right Side # ============================ # Strip Right Angle Brackets : Double w/Space - nwGUI.docEditor.replaceText("Some text <<\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 5 + docEditor.replaceText("Some text <<\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 5 # Strip Right Angle Brackets : Single w/Space - nwGUI.docEditor.replaceText("Some text <\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 5 + docEditor.replaceText("Some text <\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 5 # Strip Right Angle Brackets : Double wo/Space - nwGUI.docEditor.replaceText("Some text<<\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 5 + docEditor.replaceText("Some text<<\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 5 # Strip Right Angle Brackets : Single wo/Space - nwGUI.docEditor.replaceText("Some text<\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 5 + docEditor.replaceText("Some text<\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 5 # Block Stripping : Both Sides # ============================ - nwGUI.docEditor.replaceText(">> Some text <<\n\n") - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" + docEditor.replaceText(">> Some text <<\n\n") + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Some text\n\n" - nwGUI.docEditor.replaceText(">Some text <<\n\n") - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" + docEditor.replaceText(">Some text <<\n\n") + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Some text\n\n" - nwGUI.docEditor.replaceText(">Some text<\n\n") - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" + docEditor.replaceText(">Some text<\n\n") + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Some text\n\n" # New Formats # =========== # Comment - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_COM) is True - assert nwGUI.docEditor.getText() == "% Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 7 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_COM) is True + assert docEditor.getText() == "% Some text\n\n" + assert docEditor.getCursorPosition() == 7 # Toggle Comment w/Space - nwGUI.docEditor.replaceText("% Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_COM) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 3 + docEditor.replaceText("% Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_COM) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 3 # Toggle Comment wo/Space - nwGUI.docEditor.replaceText("%Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_COM) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 4 + docEditor.replaceText("%Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_COM) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 4 # Toggle Ignore Text w/Space - nwGUI.docEditor.replaceText("%~ Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_IGN) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 2 + docEditor.replaceText("%~ Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_IGN) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 2 # Toggle Ignore Text wo/Space - nwGUI.docEditor.replaceText("%~Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_IGN) is True - assert nwGUI.docEditor.getText() == "Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 3 + docEditor.replaceText("%~Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_IGN) is True + assert docEditor.getText() == "Some text\n\n" + assert docEditor.getCursorPosition() == 3 # Header 1 - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_H1) is True - assert nwGUI.docEditor.getText() == "# Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 7 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_H1) is True + assert docEditor.getText() == "# Some text\n\n" + assert docEditor.getCursorPosition() == 7 # Header 2 - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_H2) is True - assert nwGUI.docEditor.getText() == "## Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 8 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_H2) is True + assert docEditor.getText() == "## Some text\n\n" + assert docEditor.getCursorPosition() == 8 # Header 3 - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_H3) is True - assert nwGUI.docEditor.getText() == "### Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 9 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_H3) is True + assert docEditor.getText() == "### Some text\n\n" + assert docEditor.getCursorPosition() == 9 # Header 4 - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_H4) is True - assert nwGUI.docEditor.getText() == "#### Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 10 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_H4) is True + assert docEditor.getText() == "#### Some text\n\n" + assert docEditor.getCursorPosition() == 10 # Novel Title - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TTL) is True - assert nwGUI.docEditor.getText() == "#! Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 8 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_TTL) is True + assert docEditor.getText() == "#! Some text\n\n" + assert docEditor.getCursorPosition() == 8 # Unnumbered Chapter - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_UNN) is True - assert nwGUI.docEditor.getText() == "##! Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 9 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_UNN) is True + assert docEditor.getText() == "##! Some text\n\n" + assert docEditor.getCursorPosition() == 9 # Hard Scene - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_HSC) is True - assert nwGUI.docEditor.getText() == "###! Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 10 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.BLOCK_HSC) is True + assert docEditor.getText() == "###! Some text\n\n" + assert docEditor.getCursorPosition() == 10 # Left Indent - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.INDENT_L) is True - assert nwGUI.docEditor.getText() == "> Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 7 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.INDENT_L) is True + assert docEditor.getText() == "> Some text\n\n" + assert docEditor.getCursorPosition() == 7 # Right Indent - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.INDENT_R) is True - assert nwGUI.docEditor.getText() == "Some text <\n\n" - assert nwGUI.docEditor.getCursorPosition() == 5 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.INDENT_R) is True + assert docEditor.getText() == "Some text <\n\n" + assert docEditor.getCursorPosition() == 5 # Right/Left Indent - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.INDENT_L) is True - assert nwGUI.docEditor._formatBlock(nwDocAction.INDENT_R) is True - assert nwGUI.docEditor.getText() == "> Some text <\n\n" - assert nwGUI.docEditor.getCursorPosition() == 7 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.INDENT_L) is True + assert docEditor._formatBlock(nwDocAction.INDENT_R) is True + assert docEditor.getText() == "> Some text <\n\n" + assert docEditor.getCursorPosition() == 7 # Left Align - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.ALIGN_L) is True - assert nwGUI.docEditor.getText() == "Some text <<\n\n" - assert nwGUI.docEditor.getCursorPosition() == 5 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.ALIGN_L) is True + assert docEditor.getText() == "Some text <<\n\n" + assert docEditor.getCursorPosition() == 5 # Right Align - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.ALIGN_R) is True - assert nwGUI.docEditor.getText() == ">> Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 8 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.ALIGN_R) is True + assert docEditor.getText() == ">> Some text\n\n" + assert docEditor.getCursorPosition() == 8 # Centre Align - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.ALIGN_C) is True - assert nwGUI.docEditor.getText() == ">> Some text <<\n\n" - assert nwGUI.docEditor.getCursorPosition() == 8 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.ALIGN_C) is True + assert docEditor.getText() == ">> Some text <<\n\n" + assert docEditor.getCursorPosition() == 8 # Left/Right Align (Overrides) - nwGUI.docEditor.replaceText("Some text\n\n") - nwGUI.docEditor.setCursorPosition(5) - assert nwGUI.docEditor._formatBlock(nwDocAction.ALIGN_L) is True - assert nwGUI.docEditor._formatBlock(nwDocAction.ALIGN_R) is True - assert nwGUI.docEditor.getText() == ">> Some text\n\n" - assert nwGUI.docEditor.getCursorPosition() == 8 + docEditor.replaceText("Some text\n\n") + docEditor.setCursorPosition(5) + assert docEditor._formatBlock(nwDocAction.ALIGN_L) is True + assert docEditor._formatBlock(nwDocAction.ALIGN_R) is True + assert docEditor.getText() == ">> Some text\n\n" + assert docEditor.getCursorPosition() == 8 # Other Checks # ============ # Final Cursor Position Out of Range - nwGUI.docEditor.replaceText("#### Title\n\n") - nwGUI.docEditor.setCursorPosition(3) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True - assert nwGUI.docEditor.getText() == "Title\n\n" - assert nwGUI.docEditor.getCursorPosition() == 5 + docEditor.replaceText("#### Title\n\n") + docEditor.setCursorPosition(3) + assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert docEditor.getText() == "Title\n\n" + assert docEditor.getCursorPosition() == 5 # Third Line # This also needs to add a new block - nwGUI.docEditor.replaceText("#### Title\n\nThe Text\n\n") - nwGUI.docEditor.setCursorLine(3) - assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_COM) is True - assert nwGUI.docEditor.getText() == "#### Title\n\n% The Text\n\n" + docEditor.replaceText("#### Title\n\nThe Text\n\n") + docEditor.setCursorLine(3) + assert docEditor._formatBlock(nwDocAction.BLOCK_COM) is True + assert docEditor.getText() == "#### Title\n\n% The Text\n\n" # qtbot.stop() @@ -1415,69 +1533,70 @@ def testGuiEditor_MultiBlockFormatting(qtbot, nwGUI, projPath, ipsumText, mockRn """Test the block formatting function.""" buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) is True + docEditor = nwGUI.docEditor text = "### A Scene\n\n@char: Jane, John\n\n" + "\n\n".join(ipsumText) + "\n\n" - nwGUI.docEditor.replaceText(text) - assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + docEditor.replaceText(text) + assert [x[:5] for x in docEditor.getText().splitlines()] == [ "### A", "", "@char", "", "Lorem", "", "Nulla", "", "Nulla", "", "Pelle", "", "Integ", "" ] # Toggle Comment - cursor = nwGUI.docEditor.textCursor() + cursor = docEditor.textCursor() cursor.setPosition(50) cursor.movePosition(QtMoveRight, QtKeepAnchor, 2000) - nwGUI.docEditor.setTextCursor(cursor) + docEditor.setTextCursor(cursor) - nwGUI.docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM) - assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM) + assert [x[:5] for x in docEditor.getText().splitlines()] == [ "### A", "", "@char", "", "% Lor", "", "% Nul", "", "% Nul", "", "% Pel", "", "Integ", "" ] # Un-toggle the second - cursor = nwGUI.docEditor.textCursor() + cursor = docEditor.textCursor() cursor.setPosition(800) - nwGUI.docEditor.setTextCursor(cursor) + docEditor.setTextCursor(cursor) - nwGUI.docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM) - assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM) + assert [x[:5] for x in docEditor.getText().splitlines()] == [ "### A", "", "@char", "", "% Lor", "", "Nulla", "", "% Nul", "", "% Pel", "", "Integ", "" ] # Un-toggle all - cursor = nwGUI.docEditor.textCursor() + cursor = docEditor.textCursor() cursor.setPosition(50) cursor.movePosition(QtMoveRight, QtKeepAnchor, 3000) - nwGUI.docEditor.setTextCursor(cursor) + docEditor.setTextCursor(cursor) - nwGUI.docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM) - assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM) + assert [x[:5] for x in docEditor.getText().splitlines()] == [ "### A", "", "@char", "", "Lorem", "", "Nulla", "", "Nulla", "", "Pelle", "", "Integ", "" ] # Toggle Ignore Text - cursor = nwGUI.docEditor.textCursor() + cursor = docEditor.textCursor() cursor.setPosition(50) cursor.movePosition(QtMoveRight, QtKeepAnchor, 2000) - nwGUI.docEditor.setTextCursor(cursor) + docEditor.setTextCursor(cursor) - nwGUI.docEditor._iterFormatBlocks(nwDocAction.BLOCK_IGN) - assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + docEditor._iterFormatBlocks(nwDocAction.BLOCK_IGN) + assert [x[:5] for x in docEditor.getText().splitlines()] == [ "### A", "", "@char", "", "%~ Lo", "", "%~ Nu", "", "%~ Nu", "", "%~ Pe", "", "Integ", "" ] # Clear all paragraphs - cursor = nwGUI.docEditor.textCursor() + cursor = docEditor.textCursor() cursor.setPosition(50) cursor.movePosition(QtMoveRight, QtKeepAnchor, 3000) - nwGUI.docEditor.setTextCursor(cursor) + docEditor.setTextCursor(cursor) - nwGUI.docEditor._iterFormatBlocks(nwDocAction.BLOCK_TXT) - assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + docEditor._iterFormatBlocks(nwDocAction.BLOCK_TXT) + assert [x[:5] for x in docEditor.getText().splitlines()] == [ "### A", "", "@char", "", "Lorem", "", "Nulla", "", "Nulla", "", "Pelle", "", "Integ", "" ] # Final text should be identical to initial text - assert nwGUI.docEditor.getText() == text + assert docEditor.getText() == text # qtbot.stop() @@ -1489,59 +1608,64 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): """Test the document editor tags functionality.""" buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) is True + docEditor = nwGUI.docEditor # Create Scene text = "### A Scene\n\n@char: Jane, John\n\n@object: Gun\n\n@:\n\n" + ipsumText[0] + "\n\n" - nwGUI.docEditor.replaceText(text) + docEditor.replaceText(text) # Create Character text = "### Jane Doe\n\n@tag: Jane\n\n" + ipsumText[1] + "\n\n" cHandle = SHARED.project.newFile("Jane Doe", C.hCharRoot) assert nwGUI.openDocument(cHandle) is True - nwGUI.docEditor.replaceText(text) + docEditor.replaceText(text) nwGUI.saveDocument() assert nwGUI.projView.projTree.revealNewTreeItem(cHandle) - nwGUI.docEditor.updateTagHighLighting() + docEditor.updateTagHighLighting() # Follow Tag # ========== assert nwGUI.openDocument(C.hSceneDoc) is True # Empty Block - nwGUI.docEditor.setCursorLine(2) - assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL + docEditor.setCursorLine(2) + assert docEditor._processTag() is nwTrinary.NEUTRAL # Not On Tag - nwGUI.docEditor.setCursorLine(1) - assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL + docEditor.setCursorLine(1) + assert docEditor._processTag() is nwTrinary.NEUTRAL # On Tag Keyword - nwGUI.docEditor.setCursorPosition(15) - assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL + docEditor.setCursorPosition(15) + assert docEditor._processTag() is nwTrinary.NEUTRAL # On Known Tag, No Follow - nwGUI.docEditor.setCursorPosition(22) - assert nwGUI.docEditor._processTag(follow=False) is nwTrinary.POSITIVE + docEditor.setCursorPosition(22) + assert docEditor._processTag(follow=False) is nwTrinary.POSITIVE assert nwGUI.docViewer._docHandle is None # On Known Tag, Follow - nwGUI.docEditor.setCursorPosition(22) + docEditor.setCursorPosition(22) + position = docEditor.cursorRect().center() + event = QMouseEvent( + QEvent.Type.MouseButtonPress, position, QtMouseLeft, QtMouseLeft, QtModCtrl + ) assert nwGUI.docViewer._docHandle is None - assert nwGUI.docEditor._processTag(follow=True) is nwTrinary.POSITIVE + docEditor.mouseReleaseEvent(event) assert nwGUI.docViewer._docHandle == cHandle assert nwGUI.closeViewerPanel() is True assert nwGUI.docViewer._docHandle is None # On Unknown Tag, Create It assert "0000000000011" not in SHARED.project.tree - nwGUI.docEditor.setCursorPosition(28) - assert nwGUI.docEditor._processTag(create=True) is nwTrinary.NEGATIVE + docEditor.setCursorPosition(28) + assert docEditor._processTag(create=True) is nwTrinary.NEGATIVE assert "0000000000011" in SHARED.project.tree # On Unknown Tag, Missing Root assert "0000000000012" not in SHARED.project.tree - nwGUI.docEditor.setCursorPosition(42) - assert nwGUI.docEditor._processTag(create=True) is nwTrinary.NEGATIVE + docEditor.setCursorPosition(42) + assert docEditor._processTag(create=True) is nwTrinary.NEGATIVE oHandle = SHARED.project.tree.findRoot(nwItemClass.OBJECT) assert oHandle == "0000000000012" @@ -1549,8 +1673,8 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert oItem is not None assert oItem.itemParent == "0000000000012" - nwGUI.docEditor.setCursorPosition(47) - assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL + docEditor.setCursorPosition(47) + assert docEditor._processTag() is nwTrinary.NEUTRAL # qtbot.stop() @@ -1562,6 +1686,7 @@ def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd): """Test the document editor meta completer functionality.""" buildTestProject(nwGUI, projPath) assert nwGUI.openDocument(C.hSceneDoc) is True + docEditor = nwGUI.docEditor # Create Character text = ( @@ -1572,11 +1697,10 @@ def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd): ) cHandle = SHARED.project.newFile("People", C.hCharRoot) assert nwGUI.openDocument(cHandle) is True - nwGUI.docEditor.replaceText(text) + docEditor.replaceText(text) nwGUI.saveDocument() assert nwGUI.projView.projTree.revealNewTreeItem(cHandle) - docEditor = nwGUI.docEditor docEditor.replaceText("") completer = docEditor._completer @@ -1699,6 +1823,8 @@ def testGuiEditor_CursorVisibility(qtbot, monkeypatch, nwGUI, projPath, mockRnd) @pytest.mark.gui def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd): """Test the word counter.""" + docEditor = nwGUI.docEditor + class MockThreadPool: def __init__(self): @@ -1712,21 +1838,21 @@ def objectID(self): threadPool = MockThreadPool() monkeypatch.setattr(QThreadPool, "globalInstance", lambda *a: threadPool) - nwGUI.docEditor.timerDoc.blockSignals(True) - nwGUI.docEditor.timerSel.blockSignals(True) + docEditor.timerDoc.blockSignals(True) + docEditor.timerSel.blockSignals(True) buildTestProject(nwGUI, projPath) # Run on an empty document - nwGUI.docEditor._runDocumentTasks() - assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" - nwGUI.docEditor._updateDocCounts(0, 0, 0) - assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" + docEditor._runDocumentTasks() + assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" + docEditor._updateDocCounts(0, 0, 0) + assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" - nwGUI.docEditor._runSelCounter() - assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" - nwGUI.docEditor._updateSelCounts(0, 0, 0) - assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" + docEditor._runSelCounter() + assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" + docEditor._updateSelCounts(0, 0, 0) + assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" # Open a document and populate it SHARED.project.tree[C.hSceneDoc]._initCount = 0 # type: ignore @@ -1735,37 +1861,37 @@ def objectID(self): text = "\n\n".join(ipsumText) cC, wC, pC = standardCounter(text) - nwGUI.docEditor.replaceText(text) + docEditor.replaceText(text) # Check that a busy counter is blocked with monkeypatch.context() as mp: - mp.setattr(nwGUI.docEditor.wCounterDoc, "isRunning", lambda *a: True) - nwGUI.docEditor._runDocumentTasks() - assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" + mp.setattr(docEditor.wCounterDoc, "isRunning", lambda *a: True) + docEditor._runDocumentTasks() + assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" with monkeypatch.context() as mp: - mp.setattr(nwGUI.docEditor.wCounterSel, "isRunning", lambda *a: True) - nwGUI.docEditor._runSelCounter() - assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" + mp.setattr(docEditor.wCounterSel, "isRunning", lambda *a: True) + docEditor._runSelCounter() + assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" # Run the full word counter - nwGUI.docEditor._runDocumentTasks() - assert threadPool.objectID() == id(nwGUI.docEditor.wCounterDoc) + docEditor._runDocumentTasks() + assert threadPool.objectID() == id(docEditor.wCounterDoc) - nwGUI.docEditor.wCounterDoc.run() - # nwGUI.docEditor._updateDocCounts(cC, wC, pC) + docEditor.wCounterDoc.run() + # docEditor._updateDocCounts(cC, wC, pC) assert SHARED.project.tree[C.hSceneDoc]._charCount == cC # type: ignore assert SHARED.project.tree[C.hSceneDoc]._wordCount == wC # type: ignore assert SHARED.project.tree[C.hSceneDoc]._paraCount == pC # type: ignore - assert nwGUI.docEditor.docFooter.wordsText.text() == f"Words: {wC} (+{wC})" + assert docEditor.docFooter.wordsText.text() == f"Words: {wC} (+{wC})" # Select all text and run the selection word counter - nwGUI.docEditor.docAction(nwDocAction.SEL_ALL) - nwGUI.docEditor._runSelCounter() - assert threadPool.objectID() == id(nwGUI.docEditor.wCounterSel) + docEditor.docAction(nwDocAction.SEL_ALL) + docEditor._runSelCounter() + assert threadPool.objectID() == id(docEditor.wCounterSel) - nwGUI.docEditor.wCounterSel.run() - assert nwGUI.docEditor.docFooter.wordsText.text() == f"Words: {wC} selected" + docEditor.wCounterSel.run() + assert docEditor.docFooter.wordsText.text() == f"Words: {wC} selected" # qtbot.stop() diff --git a/tests/test_gui/test_gui_mainmenu.py b/tests/test_gui/test_gui_mainmenu.py index 2d65c1622..ce2a981a2 100644 --- a/tests/test_gui/test_gui_mainmenu.py +++ b/tests/test_gui/test_gui_mainmenu.py @@ -22,10 +22,9 @@ import pytest -from tools import C, writeFile, buildTestProject - -from PyQt5.QtGui import QTextCursor, QTextBlock +from PyQt5.QtGui import QTextBlock, QTextCursor from PyQt5.QtWidgets import QAction, QFileDialog, QMessageBox +from tools import C, buildTestProject, writeFile from novelwriter import CONFIG, SHARED from novelwriter.constants import nwKeyWords, nwUnicode @@ -146,7 +145,7 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, prjLipsum): # Check comment with no space before text nwGUI.docEditor.setCursorPosition(54) - assert nwGUI.docEditor.insertText("%") + nwGUI.docEditor.insertText("%") fmtStr = "%Pellentesque nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[54:102] == fmtStr @@ -435,14 +434,14 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): nwGUI.docEditor.clear() # Test Faulty Inserts - assert nwGUI.docEditor.insertText("hello world") + nwGUI.docEditor.insertText("hello world") assert nwGUI.docEditor.getText() == "hello world" nwGUI.docEditor.clear() - assert nwGUI.docEditor.insertText(nwDocInsert.NO_INSERT) is False + nwGUI.docEditor.insertText(nwDocInsert.NO_INSERT) assert nwGUI.docEditor.isEmpty - assert nwGUI.docEditor.insertText(None) is False + nwGUI.docEditor.insertText(None) assert nwGUI.docEditor.isEmpty # qtbot.stop() diff --git a/tests/test_gui/test_gui_search.py b/tests/test_gui/test_gui_search.py index 1bd3d3594..2b96514d3 100644 --- a/tests/test_gui/test_gui_search.py +++ b/tests/test_gui/test_gui_search.py @@ -50,7 +50,7 @@ def totalCount(): search.searchText.setText("Lorem") search.searchAction.activate(QAction.ActionEvent.Trigger) assert search.searchResult.topLevelItemCount() == 14 - assert totalCount() == 42 + assert totalCount() == 43 firstDoc = search.searchResult.topLevelItem(0) firstResult = firstDoc.child(0) @@ -98,7 +98,7 @@ def totalCount(): search.toggleCase.setChecked(True) search.searchAction.activate(QAction.ActionEvent.Trigger) assert search.searchResult.topLevelItemCount() == 7 - assert totalCount() == 17 + assert totalCount() == 18 search.toggleCase.setChecked(False) # Whole Words