diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index 94b4c633..999ce000 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -59,6 +59,7 @@ maximise = typ_arrow-maximise.svg menu = typ_th-dot-menu.svg minimise = typ_arrow-minimise.svg noncheckable = mixed_input-none.svg +panel = nw_panel.svg proj_chapter = mixed_document-chapter.svg proj_details = typ_th-list-grey.svg proj_document = typ_document-text.svg @@ -68,7 +69,6 @@ proj_scene = mixed_document-scene.svg proj_section = mixed_document-section.svg proj_stats = typ_chart-bar-grey.svg proj_title = mixed_document-title.svg -reference = typ_at.svg refresh = typ_refresh.svg remove = typ_minus.svg revert = typ_refresh-flipped.svg @@ -91,14 +91,21 @@ sticky-off = typ_pin-outline.svg sticky-on = typ_pin.svg unchecked = mixed_input-unchecked.svg up = typ_chevron-up.svg +view = typ_eye.svg view_build = typ_export-grey.svg view_editor = mixed_edit.svg view_novel = typ_book-grey.svg view_outline = typ_puzzle-outline.svg deco_doc_h0 = nw_deco-h0.svg +deco_doc_h0_n = nw_deco-h0.svg deco_doc_h1 = nw_deco-h1.svg +deco_doc_h1_n = nw_deco-h1.svg deco_doc_h2 = nw_deco-h2.svg +deco_doc_h2_n = nw_deco-h2-narrow.svg deco_doc_h3 = nw_deco-h3.svg +deco_doc_h3_n = nw_deco-h3-narrow.svg deco_doc_h4 = nw_deco-h4.svg +deco_doc_h4_n = nw_deco-h4-narrow.svg deco_doc_more = nw_deco-noveltree-more.svg +deco_doc_nt_n = nw_deco-note.svg diff --git a/novelwriter/assets/icons/typicons_dark/nw_deco-h2-narrow.svg b/novelwriter/assets/icons/typicons_dark/nw_deco-h2-narrow.svg new file mode 100644 index 00000000..ced9f866 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/nw_deco-h2-narrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_dark/nw_deco-h3-narrow.svg b/novelwriter/assets/icons/typicons_dark/nw_deco-h3-narrow.svg new file mode 100644 index 00000000..e5a9fad9 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/nw_deco-h3-narrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_dark/nw_deco-h4-narrow.svg b/novelwriter/assets/icons/typicons_dark/nw_deco-h4-narrow.svg new file mode 100644 index 00000000..3394646e --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/nw_deco-h4-narrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_dark/nw_deco-note.svg b/novelwriter/assets/icons/typicons_dark/nw_deco-note.svg new file mode 100644 index 00000000..85944eb6 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/nw_deco-note.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_dark/nw_panel.svg b/novelwriter/assets/icons/typicons_dark/nw_panel.svg new file mode 100644 index 00000000..2863918f --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/nw_panel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_at.svg b/novelwriter/assets/icons/typicons_dark/typ_at.svg deleted file mode 100644 index 9966ede0..00000000 --- a/novelwriter/assets/icons/typicons_dark/typ_at.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/novelwriter/assets/icons/typicons_dark/typ_eye.svg b/novelwriter/assets/icons/typicons_dark/typ_eye.svg new file mode 100644 index 00000000..34d22862 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index 62314511..3c6f545c 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -59,6 +59,7 @@ maximise = typ_arrow-maximise.svg menu = typ_th-dot-menu.svg minimise = typ_arrow-minimise.svg noncheckable = mixed_input-none.svg +panel = nw_panel.svg proj_chapter = mixed_document-chapter.svg proj_details = typ_th-list-grey.svg proj_document = typ_document-text.svg @@ -68,7 +69,6 @@ proj_scene = mixed_document-scene.svg proj_section = mixed_document-section.svg proj_stats = typ_chart-bar-grey.svg proj_title = mixed_document-title.svg -reference = typ_at.svg refresh = typ_refresh.svg remove = typ_minus.svg revert = typ_refresh-flipped.svg @@ -91,14 +91,21 @@ sticky-off = typ_pin-outline.svg sticky-on = typ_pin.svg unchecked = mixed_input-unchecked.svg up = typ_chevron-up.svg +view = typ_eye.svg view_build = typ_export-grey.svg view_editor = mixed_edit.svg view_novel = typ_book-grey.svg view_outline = typ_puzzle-outline.svg deco_doc_h0 = nw_deco-h0.svg +deco_doc_h0_n = nw_deco-h0.svg deco_doc_h1 = nw_deco-h1.svg +deco_doc_h1_n = nw_deco-h1.svg deco_doc_h2 = nw_deco-h2.svg +deco_doc_h2_n = nw_deco-h2-narrow.svg deco_doc_h3 = nw_deco-h3.svg +deco_doc_h3_n = nw_deco-h3-narrow.svg deco_doc_h4 = nw_deco-h4.svg +deco_doc_h4_n = nw_deco-h4-narrow.svg deco_doc_more = nw_deco-noveltree-more.svg +deco_doc_nt_n = nw_deco-note.svg diff --git a/novelwriter/assets/icons/typicons_light/nw_deco-h2-narrow.svg b/novelwriter/assets/icons/typicons_light/nw_deco-h2-narrow.svg new file mode 100644 index 00000000..fe43924d --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/nw_deco-h2-narrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/nw_deco-h3-narrow.svg b/novelwriter/assets/icons/typicons_light/nw_deco-h3-narrow.svg new file mode 100644 index 00000000..79a75ac4 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/nw_deco-h3-narrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/nw_deco-h4-narrow.svg b/novelwriter/assets/icons/typicons_light/nw_deco-h4-narrow.svg new file mode 100644 index 00000000..7a7279bc --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/nw_deco-h4-narrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/nw_deco-note.svg b/novelwriter/assets/icons/typicons_light/nw_deco-note.svg new file mode 100644 index 00000000..972d71c7 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/nw_deco-note.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/nw_panel.svg b/novelwriter/assets/icons/typicons_light/nw_panel.svg new file mode 100644 index 00000000..c78ab50b --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/nw_panel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_at.svg b/novelwriter/assets/icons/typicons_light/typ_at.svg deleted file mode 100644 index 0578267f..00000000 --- a/novelwriter/assets/icons/typicons_light/typ_at.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/novelwriter/assets/icons/typicons_light/typ_eye.svg b/novelwriter/assets/icons/typicons_light/typ_eye.svg new file mode 100644 index 00000000..9cae4229 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/config.py b/novelwriter/config.py index 5f7a3487..ba422efa 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -179,7 +179,7 @@ class Config: self.spellLanguage = "en" # State - self.showRefPanel = True # The reference panel for the viewer is visible + self.showViewerPanel = True # The panel for the viewer is visible self.showEditToolBar = False # The document editor toolbar visibility self.useShortcodes = False # Use shortcodes for basic formatting self.viewComments = True # Comments are shown in the viewer @@ -600,7 +600,7 @@ class Config: # State sec = "State" - self.showRefPanel = conf.rdBool(sec, "showrefpanel", self.showRefPanel) + self.showViewerPanel = conf.rdBool(sec, "showviewerpanel", self.showViewerPanel) self.showEditToolBar = conf.rdBool(sec, "showedittoolbar", self.showEditToolBar) self.useShortcodes = conf.rdBool(sec, "useshortcodes", self.useShortcodes) self.viewComments = conf.rdBool(sec, "viewcomments", self.viewComments) @@ -707,7 +707,7 @@ class Config: } conf["State"] = { - "showrefpanel": str(self.showRefPanel), + "showviewerpanel": str(self.showViewerPanel), "showedittoolbar": str(self.showEditToolBar), "useshortcodes": str(self.useShortcodes), "viewcomments": str(self.viewComments), diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 514ea9f2..d8ce3562 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -153,6 +153,21 @@ class nwKeyWords: # END Class nwKeyWords +class nwLists: + + USER_CLASSES = [ + nwItemClass.CHARACTER, + nwItemClass.PLOT, + nwItemClass.WORLD, + nwItemClass.TIMELINE, + nwItemClass.OBJECT, + nwItemClass.ENTITY, + nwItemClass.CUSTOM, + ] + +# END Class nwLists + + class nwLabels: CLASS_NAME = { @@ -221,8 +236,8 @@ class nwLabels: nwOutline.FOCUS: QT_TRANSLATE_NOOP("Constant", "Focus"), nwOutline.CHAR: KEY_NAME[nwKeyWords.CHAR_KEY], nwOutline.PLOT: KEY_NAME[nwKeyWords.PLOT_KEY], - nwOutline.TIME: KEY_NAME[nwKeyWords.TIME_KEY], nwOutline.WORLD: KEY_NAME[nwKeyWords.WORLD_KEY], + nwOutline.TIME: KEY_NAME[nwKeyWords.TIME_KEY], nwOutline.OBJECT: KEY_NAME[nwKeyWords.OBJECT_KEY], nwOutline.ENTITY: KEY_NAME[nwKeyWords.ENTITY_KEY], nwOutline.CUSTOM: KEY_NAME[nwKeyWords.CUSTOM_KEY], diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index e4210dd2..3c833b2c 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -35,6 +35,7 @@ from time import time from typing import TYPE_CHECKING, ItemsView, Iterable, Iterator from pathlib import Path +from novelwriter import SHARED from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout from novelwriter.error import logException from novelwriter.common import checkInt, isHandle, isItemClass, isTitleTag, jsonEncode @@ -112,6 +113,7 @@ class NWIndex: self._itemIndex.clear() self._indexChange = 0.0 self._rootChange = {} + SHARED.indexSignalProxy({"event": "clearIndex"}) return def rebuildIndex(self) -> None: @@ -121,16 +123,22 @@ class NWIndex: if nwItem.isFileType(): tHandle = nwItem.itemHandle theDoc = self._project.storage.getDocument(tHandle) - self.scanText(tHandle, theDoc.readDocument() or "") + self.scanText(tHandle, theDoc.readDocument() or "", blockSignal=True) self._indexBroken = False + SHARED.indexSignalProxy({"event": "buildIndex"}) return def deleteHandle(self, tHandle: str) -> None: """Delete all entries of a given document handle.""" logger.debug("Removing item '%s' from the index", tHandle) - for tTag in self._itemIndex.allItemTags(tHandle): + delTags = self._itemIndex.allItemTags(tHandle) + for tTag in delTags: del self._tagsIndex[tTag] del self._itemIndex[tHandle] + SHARED.indexSignalProxy({ + "event": "updateTags", + "deleted": delTags, + }) return def reIndexHandle(self, tHandle: str | None) -> bool: @@ -138,14 +146,12 @@ class NWIndex: moved from the archive or trash folders back into the active project. """ - if tHandle is None or not self._project.tree.checkType(tHandle, nwItemType.FILE): - return False - - logger.debug("Re-indexing item '%s'", tHandle) - theDoc = self._project.storage.getDocument(tHandle) - self.scanText(tHandle, theDoc.readDocument() or "") - - return True + if tHandle and self._project.tree.checkType(tHandle, nwItemType.FILE): + logger.debug("Re-indexing item '%s'", tHandle) + theDoc = self._project.storage.getDocument(tHandle) + self.scanText(tHandle, theDoc.readDocument() or "") + return True + return False def indexChangedSince(self, checkTime: int | float) -> bool: """Check if the index has changed since a given time.""" @@ -200,6 +206,7 @@ class NWIndex: self.reIndexHandle(fHandle) self._indexChange = time() + SHARED.indexSignalProxy({"event": "buildIndex"}) logger.debug("Index loaded in %.3f ms", (time() - tStart)*1000) @@ -238,7 +245,7 @@ class NWIndex: # Index Building ## - def scanText(self, tHandle: str, text: str) -> bool: + def scanText(self, tHandle: str, text: str, blockSignal: bool = False) -> bool: """Scan a piece of text associated with a handle. This will update the indices accordingly. This function takes the handle and text as separate inputs as we want to primarily scan the @@ -282,6 +289,11 @@ class NWIndex: nowTime = time() self._indexChange = nowTime self._rootChange[tItem.itemRoot] = nowTime + if not blockSignal: + SHARED.indexSignalProxy({ + "event": "scanText", + "handle": tHandle, + }) return True @@ -289,7 +301,7 @@ class NWIndex: # Internal Indexer Helpers ## - def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict) -> None: + 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 @@ -346,9 +358,21 @@ class NWIndex: # Prune no longer used tags for tTag, isActive in tags.items(): - if not isActive: - logger.debug("Deleting removed tag '%s'", tTag) + updated = [] + deleted = [] + if isActive: + logger.debug("Added/updated tag '%s'", tTag) + updated.append(tTag) + else: + logger.debug("Removed tag '%s'", tTag) del self._tagsIndex[tTag] + deleted.append(tTag) + if updated or deleted: + SHARED.indexSignalProxy({ + "event": "updateTags", + "updated": updated, + "deleted": deleted, + }) return @@ -385,7 +409,7 @@ class NWIndex: return def _indexKeyword(self, tHandle: str, line: str, sTitle: str, - itemClass: nwItemClass, tags: dict) -> None: + itemClass: nwItemClass, tags: dict[str, bool]) -> None: """Validate and save the information about a reference to a tag in another file, or the setting of a tag in the file. A record of active tags is updated so that no longer used tags can be @@ -596,10 +620,8 @@ class NWIndex: return tRefs - def getBackReferenceList(self, tHandle: str) -> dict[str, str]: - """Build a list of files referring back to our file, specified - by tHandle. - """ + def getBackReferenceList(self, tHandle: str) -> dict[str, tuple[str, IndexHeading]]: + """Build a dict of files referring back to our file.""" if tHandle is None or tHandle not in self._itemIndex: return {} @@ -611,20 +633,43 @@ class NWIndex: for aHandle, sTitle, hItem in self._itemIndex.iterAllHeaders(): for aTag in hItem.references: if aTag in tTags and aHandle not in tRefs: - tRefs[aHandle] = sTitle + tRefs[aHandle] = (sTitle, hItem) return tRefs - def getTagSource(self, tagKey: str) -> tuple[str, str]: + def getTagSource(self, tagKey: str) -> tuple[str | None, str]: """Return the source location of a given tag.""" tHandle = self._tagsIndex.tagHandle(tagKey) sTitle = self._tagsIndex.tagHeading(tagKey) return tHandle, sTitle - def getTags(self, itemClass: nwItemClass) -> list[str]: + def getDocumentTags(self, tHandle: str | None) -> list[str]: + """Return all tags used by a specific document.""" + return self._itemIndex.allItemTags(tHandle) if tHandle else [] + + def getClassTags(self, itemClass: nwItemClass) -> list[str]: """Return all tags based on itemClass.""" return self._tagsIndex.filterTagNames(itemClass.name) + def getTagsData(self) -> Iterator[tuple[str, str, str, IndexItem | None, IndexHeading | None]]: + """Return all known tags.""" + for tag, data in self._tagsIndex.items(): + iItem = self._itemIndex[data.get("handle")] + hItem = None if iItem is None else iItem[data.get("heading")] + yield tag, data.get("name", ""), data.get("class", ""), iItem, hItem + return + + def getSingleTag(self, tagKey: str) -> tuple[str, str, IndexItem | None, IndexHeading | None]: + """Return tag data for a specific tag.""" + tName = self._tagsIndex.tagName(tagKey) + tClass = self._tagsIndex.tagClass(tagKey) + tHandle = self._tagsIndex.tagHandle(tagKey) + tHeading = self._tagsIndex.tagHeading(tagKey) + if tName and tClass and tHandle and tHeading: + iItem = self._itemIndex[tHandle] + return tName, tClass, iItem, None if iItem is None else iItem[tHeading] + return "", "", None, None + # END Class NWIndex @@ -643,7 +688,7 @@ class TagsIndex: __slots__ = ("_tags") def __init__(self) -> None: - self._tags: dict[str, dict] = {} + self._tags: dict[str, dict[str, str]] = {} return def __contains__(self, tagKey: str) -> bool: @@ -665,6 +710,10 @@ class TagsIndex: self._tags = {} return + def items(self) -> ItemsView: + """Return a dictionary view of all tags.""" + return self._tags.items() + def add(self, tagKey: str, tHandle: str, sTitle: str, itemClass: nwItemClass) -> None: """Add a key to the index and set all values.""" self._tags[tagKey.lower()] = { @@ -676,7 +725,7 @@ class TagsIndex: """Get the display name of a given tag.""" return self._tags.get(tagKey.lower(), {}).get("name", "") - def tagHandle(self, tagKey: str) -> str: + def tagHandle(self, tagKey: str) -> str | None: """Get the handle of a given tag.""" return self._tags.get(tagKey.lower(), {}).get("handle", None) @@ -937,6 +986,11 @@ class IndexItem: # Properties ## + @property + def handle(self) -> str: + """Return the item handle of the index item.""" + return self._handle + @property def item(self) -> NWItem: """Return the project item of the index item.""" diff --git a/novelwriter/enum.py b/novelwriter/enum.py index 3d652266..c1d2aef4 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -64,7 +64,7 @@ class nwItemLayout(Enum): class nwTrinary(Enum): NEGATIVE = -1 - UNKNOWN = 0 + NEUTRAL = 0 POSITIVE = 1 # END Enum nwTrinary diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index d4743c9b..170fcc86 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -500,10 +500,7 @@ class GuiDocEditor(QPlainTextEdit): else: self.novelStructureChanged.emit() - # ToDo: This should be a signal if oldHeader != newHeader: - self.mainGui.projView.setTreeItemValues(tHandle) - self.mainGui.itemDetails.updateViewBox(tHandle) self.docFooter.updateInfo() # Update the status bar @@ -1783,13 +1780,13 @@ class GuiDocEditor(QPlainTextEdit): block = cursor.block() text = block.text() if len(text) == 0: - return nwTrinary.UNKNOWN + return nwTrinary.NEUTRAL if text.startswith("@") and isinstance(self._nwItem, NWItem): isGood, tBits, tPos = SHARED.project.index.scanThis(text) if not isGood: - return nwTrinary.UNKNOWN + return nwTrinary.NEUTRAL tag = "" exist = False @@ -1806,7 +1803,7 @@ class GuiDocEditor(QPlainTextEdit): if not tag or tag.startswith("@"): # The keyword cannot be looked up, so we ignore that - return nwTrinary.UNKNOWN + return nwTrinary.NEUTRAL if follow and exist: logger.debug("Attempting to follow tag '%s'", tag) @@ -1826,7 +1823,7 @@ class GuiDocEditor(QPlainTextEdit): return nwTrinary.POSITIVE if exist else nwTrinary.NEGATIVE - return nwTrinary.UNKNOWN + return nwTrinary.NEUTRAL def _openContextFromCursor(self) -> None: """Open the spell check context menu at the cursor.""" @@ -2054,7 +2051,7 @@ class MetaCompleter(QMenu): length = len(lookup) suffix = "" options = list(filter( - lambda x: lookup in x.lower(), SHARED.project.index.getTags( + lambda x: lookup in x.lower(), SHARED.project.index.getClassTags( nwKeyWords.KEY_CLASS.get(kw.strip(), nwItemClass.NO_CLASS) ) ))[:15] diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index ff548fb6..8992505d 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -4,7 +4,6 @@ novelWriter – GUI Document Viewer File History: Created: 2019-05-10 [0.0.1] GuiDocViewer -Created: 2019-10-31 [0.3.2] GuiDocViewDetails Created: 2020-04-25 [0.4.5] GuiDocViewHeader Created: 2020-06-09 [0.8] GuiDocViewFooter Created: 2020-09-08 [1.0b1] GuiDocViewHistory @@ -38,8 +37,8 @@ from PyQt5.QtGui import ( QTextOption ) from PyQt5.QtWidgets import ( - QAction, qApp, QFrame, QHBoxLayout, QLabel, QMenu, QScrollArea, - QTextBrowser, QToolButton, QWidget + QAction, qApp, QFrame, QHBoxLayout, QLabel, QMenu, QTextBrowser, + QToolButton, QWidget ) from novelwriter import CONFIG, SHARED @@ -57,7 +56,9 @@ logger = logging.getLogger(__name__) class GuiDocViewer(QTextBrowser): + documentLoaded = pyqtSignal(str) loadDocumentTagRequest = pyqtSignal(str, Enum) + togglePanelVisibility = pyqtSignal() def __init__(self, mainGui: GuiMain) -> None: super().__init__(parent=mainGui) @@ -191,6 +192,7 @@ class GuiDocViewer(QTextBrowser): """Load text into the viewer from an item handle.""" if not SHARED.project.tree.checkType(tHandle, nwItemType.FILE): logger.warning("Item not found") + self.documentLoaded.emit("") return False logger.debug("Generating preview for item '%s'", tHandle) @@ -223,14 +225,12 @@ class GuiDocViewer(QTextBrowser): if updateHistory: self.docHistory.append(tHandle) - self.setHtml(aDoc.theResult.replace("\t", "!!tab!!")) self.setDocumentTitle(tHandle) - # Loop through the text and put back in the tabs. Tabs are removed by - # the setHtml function, so the ToHtml class puts in a placeholder. + # Replace tabs before setting the HTML, and then put them back in + self.setHtml(aDoc.theResult.replace("\t", "!!tab!!")) while self.find("!!tab!!"): - theCursor = self.textCursor() - theCursor.insertText("\t") + self.textCursor().insertText("\t") if self._docHandle == tHandle: # This is a refresh, so we set the scrollbar back to where it was @@ -241,13 +241,11 @@ class GuiDocViewer(QTextBrowser): self.docHeader.setTitleFromHandle(self._docHandle) self.updateDocMargins() - # Make sure the main GUI knows we changed the content - self.mainGui.viewMeta.refreshReferences(tHandle) - # Since we change the content while it may still be rendering, we mark # the document dirty again to make sure it's re-rendered properly. self.redrawText() qApp.restoreOverrideCursor() + self.documentLoaded.emit(tHandle) return True @@ -263,33 +261,31 @@ class GuiDocViewer(QTextBrowser): self.updateDocMargins() return - def docAction(self, theAction: nwDocAction) -> bool: + def docAction(self, action: nwDocAction) -> bool: """Process document actions on the current document.""" - logger.debug("Requesting action: '%s'", theAction.name) + logger.debug("Requesting action: '%s'", action.name) if self._docHandle is None: logger.error("No document open") return False - if theAction == nwDocAction.CUT: + if action == nwDocAction.CUT: self.copy() - elif theAction == nwDocAction.COPY: + elif action == nwDocAction.COPY: self.copy() - elif theAction == nwDocAction.SEL_ALL: + elif action == nwDocAction.SEL_ALL: self._makeSelection(QTextCursor.Document) - elif theAction == nwDocAction.SEL_PARA: + elif action == nwDocAction.SEL_PARA: self._makeSelection(QTextCursor.BlockUnderCursor) else: - logger.debug("Unknown or unsupported document action '%s'", str(theAction)) + logger.debug("Unknown or unsupported document action '%s'", str(action)) return False return True - def navigateTo(self, tAnchor: str) -> bool: + def navigateTo(self, tAnchor: str) -> None: """Go to a specific #link in the document.""" - if not isinstance(tAnchor, str): - return False - if tAnchor.startswith("#"): + if isinstance(tAnchor, str) and tAnchor.startswith("#"): logger.debug("Moving to anchor '%s'", tAnchor) self.setSource(QUrl(tAnchor)) - return True + return def clearNavHistory(self) -> None: """Clear the navigation history.""" @@ -342,7 +338,7 @@ class GuiDocViewer(QTextBrowser): @pyqtSlot(str) def updateDocInfo(self, tHandle: str) -> None: - """Update the header titlebar if needed.""" + """Update the header title bar if needed.""" if tHandle == self._docHandle: self.docHeader.setTitleFromHandle(self._docHandle) self.updateDocMargins() @@ -366,11 +362,11 @@ class GuiDocViewer(QTextBrowser): @pyqtSlot("QUrl") def _linkClicked(self, url: QUrl) -> None: - """Process a clicked link internally in the document.""" - theLink = url.url() - logger.debug("Clicked link: '%s'", theLink) - if len(theLink) > 0: - theBits = theLink.split("=") + """Process a clicked link in the document.""" + link = url.url() + logger.debug("Clicked link: '%s'", link) + if len(link) > 0: + theBits = link.split("=") if len(theBits) == 2: self.loadDocumentTagRequest.emit(theBits[1], nwDocMode.VIEW) return @@ -383,9 +379,6 @@ class GuiDocViewer(QTextBrowser): mnuContext = QMenu(self) - # Cut, Copy and Paste - # =================== - if userSelection: mnuCopy = QAction(self.tr("Copy"), mnuContext) mnuCopy.triggered.connect(lambda: self.docAction(nwDocAction.COPY)) @@ -393,9 +386,6 @@ class GuiDocViewer(QTextBrowser): mnuContext.addSeparator() - # Selections - # ========== - mnuSelAll = QAction(self.tr("Select All"), mnuContext) mnuSelAll.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL)) mnuContext.addAction(mnuSelAll) @@ -442,29 +432,28 @@ class GuiDocViewer(QTextBrowser): ## def _makeSelection(self, selType: QTextCursor.SelectionType) -> None: - """Handle select of text based on a selection mode.""" - theCursor = self.textCursor() - theCursor.clearSelection() - theCursor.select(selType) + """Handle selection of text based on a selection mode.""" + cursor = self.textCursor() + cursor.clearSelection() + cursor.select(selType) if selType == QTextCursor.BlockUnderCursor: # This selection mode also selects the preceding paragraph # separator, which we want to avoid. - posS = theCursor.selectionStart() - posE = theCursor.selectionEnd() - selTxt = theCursor.selectedText() + posS = cursor.selectionStart() + posE = cursor.selectionEnd() + selTxt = cursor.selectedText() if selTxt.startswith(nwUnicode.U_PSEP): - theCursor.setPosition(posS+1, QTextCursor.MoveAnchor) - theCursor.setPosition(posE, QTextCursor.KeepAnchor) + cursor.setPosition(posS+1, QTextCursor.MoveAnchor) + cursor.setPosition(posE, QTextCursor.KeepAnchor) - self.setTextCursor(theCursor) + self.setTextCursor(cursor) return def _makePosSelection(self, selType: QTextCursor.SelectionType, pos: QPoint) -> None: """Handle text selection at a given location.""" - theCursor = self.cursorForPosition(pos) - self.setTextCursor(theCursor) + self.setTextCursor(self.cursorForPosition(pos)) self._makeSelection(selType) return @@ -530,20 +519,16 @@ class GuiDocViewer(QTextBrowser): class GuiDocViewHistory: - def __init__(self, docViewer): - + def __init__(self, docViewer: GuiDocViewer) -> None: self.docViewer = docViewer - self._navHistory = [] self._posHistory = [] self._currPos = -1 self._prevPos = -1 - return - def clear(self): - """Clear the view history. - """ + def clear(self) -> None: + """Clear the view history.""" logger.debug("View history cleared") self._navHistory = [] self._posHistory = [] @@ -551,7 +536,7 @@ class GuiDocViewHistory: self._prevPos = -1 return - def append(self, tHandle): + def append(self, tHandle: str) -> bool: """Append a document handle and its scroll bar position to the history, but only if the document is different than the current active entry. Any further entries are truncated. @@ -577,78 +562,65 @@ class GuiDocViewHistory: return True - def forward(self): - """Navigate to the next entry in the view history. - """ + def forward(self) -> None: + """Navigate to the next entry in the view history.""" newPos = self._currPos + 1 if newPos < len(self._navHistory): logger.debug("Move forward in view history") self._prevPos = self._currPos self._updateScrollBar() - self.docViewer.loadText(self._navHistory[newPos], updateHistory=False) self.docViewer.setScrollPosition(self._posHistory[newPos]) self._currPos = newPos self._updateNavButtons() - self._dumpHistory() - return - def backward(self): - """Navigate to the previous entry in the view history. - """ + def backward(self) -> None: + """Navigate to the previous entry in the view history.""" newPos = self._currPos - 1 if newPos >= 0: logger.debug("Move backward in view history") self._prevPos = self._currPos self._updateScrollBar() - self.docViewer.loadText(self._navHistory[newPos], updateHistory=False) self.docViewer.setScrollPosition(self._posHistory[newPos]) self._currPos = newPos self._updateNavButtons() - self._dumpHistory() - return ## # Internal Functions ## - def _updateScrollBar(self): - """Update the scrollbar position of the previous entry. - """ + def _updateScrollBar(self) -> None: + """Update the scrollbar position of the previous entry.""" if self._prevPos >= 0 and self._prevPos < len(self._posHistory): self._posHistory[self._prevPos] = self.docViewer.scrollPosition return - def _updateNavButtons(self): - """Update the navigation buttons in the document header. - """ + def _updateNavButtons(self) -> None: + """Update the navigation buttons in the document header.""" self.docViewer.docHeader.updateNavButtons(0, len(self._navHistory) - 1, self._currPos) return - def _truncateHistory(self, atPos): + def _truncateHistory(self, atPos: int) -> None: """Truncate the navigation history to the given position. Also enforces a maximum length of the navigation history to 20. """ nSkip = 1 if atPos > 19 else 0 - self._navHistory = self._navHistory[nSkip:atPos + 1] self._posHistory = self._posHistory[nSkip:atPos + 1] - self._currPos -= nSkip self._prevPos -= nSkip - return - def _dumpHistory(self): + def _dumpHistory(self) -> None: """Debug function to dump history to the logger. Since it is a for loop, it is skipped entirely if log level isn't DEBUG. """ - if logger.getEffectiveLevel() == logging.DEBUG: + if logger.getEffectiveLevel() == logging.DEBUG: # pragma: no cover for i, (h, p) in enumerate(zip(self._navHistory, self._posHistory)): logger.debug( "History %02d: %s %13s [x:%d]" % ( @@ -667,7 +639,7 @@ class GuiDocViewHistory: class GuiDocViewHeader(QWidget): - def __init__(self, docViewer): + def __init__(self, docViewer: GuiDocViewer) -> None: super().__init__(parent=docViewer) logger.debug("Create: GuiDocViewHeader") @@ -685,18 +657,18 @@ class GuiDocViewHeader(QWidget): self.setAutoFillBackground(True) # Title Label - self.theTitle = QLabel() - self.theTitle.setText("") - self.theTitle.setIndent(0) - self.theTitle.setMargin(0) - self.theTitle.setContentsMargins(0, 0, 0, 0) - self.theTitle.setAutoFillBackground(True) - self.theTitle.setAlignment(Qt.AlignHCenter | Qt.AlignTop) - self.theTitle.setFixedHeight(fPx) + self.docTitle = QLabel() + self.docTitle.setText("") + self.docTitle.setIndent(0) + self.docTitle.setMargin(0) + self.docTitle.setContentsMargins(0, 0, 0, 0) + self.docTitle.setAutoFillBackground(True) + self.docTitle.setAlignment(Qt.AlignHCenter | Qt.AlignTop) + self.docTitle.setFixedHeight(fPx) - lblFont = self.theTitle.font() + lblFont = self.docTitle.font() lblFont.setPointSizeF(0.9*SHARED.theme.fontPointSize) - self.theTitle.setFont(lblFont) + self.docTitle.setFont(lblFont) # Buttons self.backButton = QToolButton(self) @@ -740,7 +712,7 @@ class GuiDocViewHeader(QWidget): self.outerBox.setSpacing(hSp) self.outerBox.addWidget(self.backButton, 0) self.outerBox.addWidget(self.forwardButton, 0) - self.outerBox.addWidget(self.theTitle, 1) + self.outerBox.addWidget(self.docTitle, 1) self.outerBox.addWidget(self.refreshButton, 0) self.outerBox.addWidget(self.closeButton, 0) self.setLayout(self.outerBox) @@ -763,9 +735,8 @@ class GuiDocViewHeader(QWidget): # Methods ## - def updateTheme(self): - """Update theme elements. - """ + def updateTheme(self) -> None: + """Update theme elements.""" self.backButton.setIcon(SHARED.theme.getIcon("backward")) self.forwardButton.setIcon(SHARED.theme.getIcon("forward")) self.refreshButton.setIcon(SHARED.theme.getIcon("refresh")) @@ -785,32 +756,30 @@ class GuiDocViewHeader(QWidget): return - def matchColours(self): + def matchColours(self) -> None: """Update the colours of the widget to match those of the syntax theme rather than the main GUI. """ - thePalette = QPalette() - thePalette.setColor(QPalette.Window, QColor(*SHARED.theme.colBack)) - thePalette.setColor(QPalette.WindowText, QColor(*SHARED.theme.colText)) - thePalette.setColor(QPalette.Text, QColor(*SHARED.theme.colText)) - - self.setPalette(thePalette) - self.theTitle.setPalette(thePalette) - + palette = QPalette() + palette.setColor(QPalette.Window, QColor(*SHARED.theme.colBack)) + palette.setColor(QPalette.WindowText, QColor(*SHARED.theme.colText)) + palette.setColor(QPalette.Text, QColor(*SHARED.theme.colText)) + self.setPalette(palette) + self.docTitle.setPalette(palette) return - def setTitleFromHandle(self, tHandle): + def setTitleFromHandle(self, tHandle: str | None) -> None: """Sets the document title from the handle, or alternatively, set the whole document path. """ self._docHandle = tHandle if tHandle is None: - self.theTitle.setText("") + self.docTitle.setText("") self.backButton.setVisible(False) self.forwardButton.setVisible(False) self.closeButton.setVisible(False) self.refreshButton.setVisible(False) - return True + return pTree = SHARED.project.tree if CONFIG.showFullPath: @@ -821,42 +790,37 @@ class GuiDocViewHeader(QWidget): if nwItem is not None: tTitle.append(nwItem.itemName) sSep = " %s " % nwUnicode.U_RSAQUO - self.theTitle.setText(sSep.join(tTitle)) + self.docTitle.setText(sSep.join(tTitle)) else: - nwItem = pTree[tHandle] - if nwItem is None: - return False - self.theTitle.setText(nwItem.itemName) + if nwItem := pTree[tHandle]: + self.docTitle.setText(nwItem.itemName) self.backButton.setVisible(True) self.forwardButton.setVisible(True) self.closeButton.setVisible(True) self.refreshButton.setVisible(True) - return True + return - def updateNavButtons(self, firstIdx, lastIdx, currIdx): - """Enable and disable nav buttons based on index in history. - """ + def updateNavButtons(self, firstIdx: int, lastIdx: int, currIdx: int) -> None: + """Enable and disable nav buttons based on index in history.""" self.backButton.setEnabled(currIdx > firstIdx) self.forwardButton.setEnabled(currIdx < lastIdx) return ## - # Slots + # Private Slots ## @pyqtSlot() - def _closeDocument(self): - """Trigger the close editor/viewer on the main window. - """ + def _closeDocument(self) -> None: + """Trigger the close editor/viewer on the main window.""" self.mainGui.closeDocViewer() return @pyqtSlot() - def _refreshDocument(self): - """Reload the content of the document. - """ + def _refreshDocument(self) -> None: + """Reload the content of the document.""" if self.docViewer.docHandle == self.mainGui.docEditor.docHandle: self.mainGui.saveDocument() self.docViewer.reloadText() @@ -866,7 +830,7 @@ class GuiDocViewHeader(QWidget): # Events ## - def mousePressEvent(self, theEvent): + def mousePressEvent(self, event: QMouseEvent) -> None: """Capture a click on the title and ensure that the item is selected in the project tree. """ @@ -883,14 +847,13 @@ class GuiDocViewHeader(QWidget): class GuiDocViewFooter(QWidget): - def __init__(self, docViewer): + def __init__(self, docViewer: GuiDocViewer) -> None: super().__init__(parent=docViewer) logger.debug("Create: GuiDocViewFooter") self.docViewer = docViewer self.mainGui = docViewer.mainGui - self.viewMeta = docViewer.mainGui.viewMeta # Internal Variables self._docHandle = None @@ -908,19 +871,8 @@ class GuiDocViewFooter(QWidget): self.showHide.setToolButtonStyle(Qt.ToolButtonIconOnly) self.showHide.setIconSize(QSize(fPx, fPx)) self.showHide.setFixedSize(QSize(fPx, fPx)) - self.showHide.clicked.connect(self._doShowHide) - self.showHide.setToolTip(self.tr("Show/hide the references panel")) - - # Sticky Button - self.stickyRefs = QToolButton(self) - self.stickyRefs.setCheckable(True) - self.stickyRefs.setToolButtonStyle(Qt.ToolButtonIconOnly) - self.stickyRefs.setIconSize(QSize(fPx, fPx)) - self.stickyRefs.setFixedSize(QSize(fPx, fPx)) - self.stickyRefs.toggled.connect(self._doToggleSticky) - self.stickyRefs.setToolTip(self.tr( - "Activate to freeze the content of the references panel when changing document" - )) + self.showHide.clicked.connect(lambda: self.docViewer.togglePanelVisibility.emit()) + self.showHide.setToolTip(self.tr("Show/Hide Viewer Panel")) # Show Comments self.showComments = QToolButton(self) @@ -930,7 +882,7 @@ class GuiDocViewFooter(QWidget): self.showComments.setIconSize(QSize(fPx, fPx)) self.showComments.setFixedSize(QSize(fPx, fPx)) self.showComments.toggled.connect(self._doToggleComments) - self.showComments.setToolTip(self.tr("Show comments")) + self.showComments.setToolTip(self.tr("Show Comments")) # Show Synopsis self.showSynopsis = QToolButton(self) @@ -940,27 +892,9 @@ class GuiDocViewFooter(QWidget): self.showSynopsis.setIconSize(QSize(fPx, fPx)) self.showSynopsis.setFixedSize(QSize(fPx, fPx)) self.showSynopsis.toggled.connect(self._doToggleSynopsis) - self.showSynopsis.setToolTip(self.tr("Show synopsis comments")) + self.showSynopsis.setToolTip(self.tr("Show Synopsis Comments")) # Labels - self.lblRefs = QLabel(self.tr("References")) - self.lblRefs.setBuddy(self.showHide) - self.lblRefs.setIndent(0) - self.lblRefs.setMargin(0) - self.lblRefs.setContentsMargins(0, 0, 0, 0) - self.lblRefs.setAutoFillBackground(True) - self.lblRefs.setFixedHeight(fPx) - self.lblRefs.setAlignment(Qt.AlignLeft | Qt.AlignTop) - - self.lblSticky = QLabel(self.tr("Sticky")) - self.lblSticky.setBuddy(self.stickyRefs) - self.lblSticky.setIndent(0) - self.lblSticky.setMargin(0) - self.lblSticky.setContentsMargins(0, 0, 0, 0) - self.lblSticky.setAutoFillBackground(True) - self.lblSticky.setFixedHeight(fPx) - self.lblSticky.setAlignment(Qt.AlignLeft | Qt.AlignTop) - self.lblComments = QLabel(self.tr("Comments")) self.lblComments.setBuddy(self.showComments) self.lblComments.setIndent(0) @@ -981,8 +915,6 @@ class GuiDocViewFooter(QWidget): lblFont = self.font() lblFont.setPointSizeF(0.9*SHARED.theme.fontPointSize) - self.lblRefs.setFont(lblFont) - self.lblSticky.setFont(lblFont) self.lblComments.setFont(lblFont) self.lblSynopsis.setFont(lblFont) @@ -990,10 +922,6 @@ class GuiDocViewFooter(QWidget): self.outerBox = QHBoxLayout() self.outerBox.setSpacing(bSp) self.outerBox.addWidget(self.showHide, 0) - self.outerBox.addWidget(self.lblRefs, 0) - self.outerBox.addSpacing(hSp) - self.outerBox.addWidget(self.stickyRefs, 0) - self.outerBox.addWidget(self.lblSticky, 0) self.outerBox.addStretch(1) self.outerBox.addWidget(self.showComments, 0) self.outerBox.addWidget(self.lblComments, 0) @@ -1024,23 +952,19 @@ class GuiDocViewFooter(QWidget): """Update theme elements.""" # Icons fPx = int(0.9*SHARED.theme.fontPixelSize) - stickyIcon = SHARED.theme.getToggleIcon("sticky", (fPx, fPx)) bulletIcon = SHARED.theme.getToggleIcon("bullet", (fPx, fPx)) - self.showHide.setIcon(SHARED.theme.getIcon("reference")) - self.stickyRefs.setIcon(stickyIcon) + self.showHide.setIcon(SHARED.theme.getIcon("panel")) self.showComments.setIcon(bulletIcon) self.showSynopsis.setIcon(bulletIcon) # StyleSheets - buttonStyle = ( "QToolButton {{border: none; background: transparent;}} " "QToolButton:hover {{border: none; background: rgba({0},{1},{2},0.2);}}" ).format(*SHARED.theme.colText) self.showHide.setStyleSheet(buttonStyle) - self.stickyRefs.setStyleSheet(buttonStyle) self.showComments.setStyleSheet(buttonStyle) self.showSynopsis.setStyleSheet(buttonStyle) @@ -1048,138 +972,35 @@ class GuiDocViewFooter(QWidget): return - def matchColours(self): + def matchColours(self) -> None: """Update the colours of the widget to match those of the syntax theme rather than the main GUI. """ - thePalette = QPalette() - thePalette.setColor(QPalette.Window, QColor(*SHARED.theme.colBack)) - thePalette.setColor(QPalette.WindowText, QColor(*SHARED.theme.colText)) - thePalette.setColor(QPalette.Text, QColor(*SHARED.theme.colText)) - - self.setPalette(thePalette) - self.lblRefs.setPalette(thePalette) - self.lblSticky.setPalette(thePalette) - self.lblComments.setPalette(thePalette) - self.lblSynopsis.setPalette(thePalette) - - return - - ## - # Slots - ## - - @pyqtSlot() - def _doShowHide(self): - """Toggle the expand/collapse of the panel. - """ - isVisible = self.viewMeta.isVisible() - self.viewMeta.setVisible(not isVisible) - return - - @pyqtSlot(bool) - def _doToggleSticky(self, theState): - """Toggle the sticky flag for the reference panel. - """ - logger.debug("Reference sticky is %s", str(theState)) - self.docViewer.stickyRef = theState - if not theState and self.docViewer.docHandle is not None: - self.viewMeta.refreshReferences(self.docViewer.docHandle) - return - - @pyqtSlot(bool) - def _doToggleComments(self, theState): - """Toggle the view comment button and reload the document. - """ - CONFIG.viewComments = theState - self.docViewer.reloadText() - return - - @pyqtSlot(bool) - def _doToggleSynopsis(self, theState): - """Toggle the view synopsis button and reload the document. - """ - CONFIG.viewSynopsis = theState - self.docViewer.reloadText() - return - -# END Class GuiDocViewFooter - - -# =============================================================================================== # -# The Document Back-Reference Panel -# Placed in a separate QSplitter position in the main GUI window -# =============================================================================================== # - -class GuiDocViewDetails(QScrollArea): - - def __init__(self, mainGui): - super().__init__(parent=mainGui) - - logger.debug("Create: GuiDocViewDetails") - - self.mainGui = mainGui - - self.refList = QLabel("") - self.refList.setWordWrap(True) - self.refList.setAlignment(Qt.AlignTop) - self.refList.setScaledContents(True) - self.refList.linkActivated.connect(self._linkClicked) - - self.linkStyle = "style='color: rgb({0},{1},{2})'".format(*SHARED.theme.colLink) - - # Assemble - self.outerWidget = QWidget() - self.outerBox = QHBoxLayout() - self.outerBox.addWidget(self.refList, 1) - - self.outerWidget.setLayout(self.outerBox) - self.setWidget(self.outerWidget) - - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.setWidgetResizable(True) - self.setMinimumHeight(CONFIG.pxInt(50)) - self.setFrameStyle(QFrame.NoFrame) - - logger.debug("Ready: GuiDocViewDetails") - - return - - def refreshReferences(self, tHandle): - """Update the current list of document references from the - project index. - """ - if self.mainGui.docViewer.stickyRef: - return - - theRefs = SHARED.project.index.getBackReferenceList(tHandle) - theList = [] - for tHandle in theRefs: - tItem = SHARED.project.tree[tHandle] - if tItem is not None: - theList.append("%s" % ( - tHandle, theRefs[tHandle], self.linkStyle, tItem.itemName - )) - - self.refList.setText(", ".join(theList)) - + palette = QPalette() + palette.setColor(QPalette.Window, QColor(*SHARED.theme.colBack)) + palette.setColor(QPalette.WindowText, QColor(*SHARED.theme.colText)) + palette.setColor(QPalette.Text, QColor(*SHARED.theme.colText)) + self.setPalette(palette) + self.lblComments.setPalette(palette) + self.lblSynopsis.setPalette(palette) return ## # Private Slots ## - @pyqtSlot(str) - def _linkClicked(self, theLink): - """Capture the link-click and forward it to the document viewer - class for handling. - """ - logger.debug("Clicked link: '%s'", theLink) - if len(theLink) >= 13: - tHandle = theLink[:13] - tAnchor = theLink[13:] or None - self.mainGui.viewDocument(tHandle, tAnchor) + @pyqtSlot(bool) + def _doToggleComments(self, state: bool) -> None: + """Toggle the view comment button and reload the document.""" + CONFIG.viewComments = state + self.docViewer.reloadText() return -# END Class GuiDocViewDetails + @pyqtSlot(bool) + def _doToggleSynopsis(self, state: bool) -> None: + """Toggle the view synopsis button and reload the document.""" + CONFIG.viewSynopsis = state + self.docViewer.reloadText() + return + +# END Class GuiDocViewFooter diff --git a/novelwriter/gui/docviewerpanel.py b/novelwriter/gui/docviewerpanel.py new file mode 100644 index 00000000..f5078df9 --- /dev/null +++ b/novelwriter/gui/docviewerpanel.py @@ -0,0 +1,399 @@ +""" +novelWriter – GUI Document Viewer Panel +======================================= + +File History: +Created: 2023-11-14 [2.2rc1] GuiDocViewerPanel + +This file is a part of novelWriter +Copyright 2018–2023, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +import logging + +from enum import Enum + +from PyQt5.QtCore import QModelIndex, QSize, Qt, pyqtSignal, pyqtSlot +from PyQt5.QtWidgets import ( + QAbstractItemView, QFrame, QHeaderView, QTabWidget, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget +) + +from novelwriter import CONFIG, SHARED +from novelwriter.constants import nwHeaders, nwLabels, nwLists, trConst +from novelwriter.core.index import IndexHeading, IndexItem +from novelwriter.enum import nwDocMode, nwItemClass + +logger = logging.getLogger(__name__) + + +class GuiDocViewerPanel(QWidget): + + openDocumentRequest = pyqtSignal(str, Enum, str, bool) + loadDocumentTagRequest = pyqtSignal(str, Enum) + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + + logger.debug("Create: GuiDocViewerPanel") + + self._lastHandle = None + + self.tabBackRefs = _ViewPanelBackRefs(self) + + self.mainTabs = QTabWidget(self) + self.mainTabs.addTab(self.tabBackRefs, self.tr("Backreferences")) + + self.kwTabs: dict[str, _ViewPanelKeyWords] = {} + self.idTabs: dict[str, int] = {} + for itemClass in nwLists.USER_CLASSES: + cTab = _ViewPanelKeyWords(self, itemClass) + tabId = self.mainTabs.addTab(cTab, trConst(nwLabels.CLASS_NAME[itemClass])) + self.kwTabs[itemClass.name] = cTab + self.idTabs[itemClass.name] = tabId + + # Assemble + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.mainTabs) + self.outerBox.setContentsMargins(0, 0, 0, 0) + + self.setLayout(self.outerBox) + self.updateTheme() + + logger.debug("Ready: GuiDocViewerPanel") + + return + + ## + # Methods + ## + + def updateTheme(self) -> None: + """Update theme elements.""" + vPx = CONFIG.pxInt(4) + lPx = CONFIG.pxInt(2) + rPx = CONFIG.pxInt(14) + hCol = self.palette().highlight().color() + + styleSheet = ( + "QTabWidget::pane {border: 0;} " + "QTabWidget QTabBar::tab {" + f"border: 0; padding: {vPx}px {rPx}px {vPx}px {lPx}px;" + "} " + "QTabWidget QTabBar::tab:selected {" + f"color: rgb({hCol.red()}, {hCol.green()}, {hCol.blue()});" + "} " + ) + self.mainTabs.setStyleSheet(styleSheet) + self.updateHandle(self._lastHandle) + + return + + ## + # Public Slots + ## + + @pyqtSlot() + def indexWasCleared(self) -> None: + """Handle event when the index has been cleared of content.""" + self.tabBackRefs.clearContent() + for cTab in self.kwTabs.values(): + cTab.clearContent() + return + + @pyqtSlot() + def indexHasAppeared(self) -> None: + """Handle event when the index has appeared.""" + for key, name, tClass, iItem, hItem in SHARED.project.index.getTagsData(): + if tClass in self.kwTabs and iItem and hItem: + self.kwTabs[tClass].addUpdateEntry(key, name, iItem, hItem) + self._updateTabVisibility() + self.updateHandle(self._lastHandle) + return + + @pyqtSlot(str) + def projectItemChanged(self, tHandle: str) -> None: + """Update meta data for project item.""" + self.tabBackRefs.refreshDocument(tHandle) + for key in SHARED.project.index.getDocumentTags(tHandle): + name, tClass, iItem, hItem = SHARED.project.index.getSingleTag(key) + if tClass in self.kwTabs and iItem and hItem: + self.kwTabs[tClass].addUpdateEntry(key, name, iItem, hItem) + return + + @pyqtSlot(str) + def updateHandle(self, tHandle: str | None) -> None: + """Update the document handle.""" + self._lastHandle = tHandle + self.tabBackRefs.refreshContent(tHandle or None) + return + + @pyqtSlot(list, list) + def updateChangedTags(self, updated: list[str], deleted: list[str]) -> None: + """Forward tags changes to the lists.""" + for key in updated: + name, tClass, iItem, hItem = SHARED.project.index.getSingleTag(key) + if tClass in self.kwTabs and iItem and hItem: + self.kwTabs[tClass].addUpdateEntry(key, name, iItem, hItem) + for key in deleted: + for cTab in self.kwTabs.values(): + if cTab.removeEntry(key): + break + else: + logger.warning("Could not remove tag '%s' from view panel", key) + self._updateTabVisibility() + return + + ## + # Internal Functions + ## + + def _updateTabVisibility(self) -> None: + """Hide class tabs with no content.""" + for tClass, cTab in self.kwTabs.items(): + self.mainTabs.setTabVisible(self.idTabs[tClass], cTab.countEntries() > 0) + return + +# END Class GuiDocViewerPanel + + +class _ViewPanelBackRefs(QTreeWidget): + + C_DATA = 0 + C_DOC = 0 + C_EDIT = 1 + C_VIEW = 2 + C_TITLE = 3 + + D_HANDLE = Qt.ItemDataRole.UserRole + + def __init__(self, parent: GuiDocViewerPanel) -> None: + super().__init__(parent=parent) + + self._parent = parent + self._treeMap: dict[str, QTreeWidgetItem] = {} + + iPx = SHARED.theme.baseIconSize + cMg = CONFIG.pxInt(6) + + self.setHeaderLabels([self.tr("Document"), "", "", self.tr("First Heading")]) + self.setIndentation(0) + self.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.setIconSize(QSize(iPx, iPx)) + self.setFrameStyle(QFrame.Shape.NoFrame) + + # Set Header Sizes + treeHeader = self.header() + treeHeader.setStretchLastSection(True) + treeHeader.setSectionResizeMode(self.C_DOC, QHeaderView.ResizeMode.ResizeToContents) + treeHeader.setSectionResizeMode(self.C_EDIT, QHeaderView.ResizeMode.Fixed) + treeHeader.setSectionResizeMode(self.C_VIEW, QHeaderView.ResizeMode.Fixed) + treeHeader.setSectionResizeMode(self.C_TITLE, QHeaderView.ResizeMode.ResizeToContents) + treeHeader.resizeSection(self.C_EDIT, iPx + cMg) + treeHeader.resizeSection(self.C_VIEW, iPx + cMg) + + # Cache Icons Locally + self._editIcon = SHARED.theme.getIcon("edit") + self._viewIcon = SHARED.theme.getIcon("view") + + # Signals + self.clicked.connect(self._treeItemClicked) + + return + + def clearContent(self) -> None: + """Clear the widget.""" + self.clear() + self._treeMap = {} + return + + def refreshContent(self, dHandle: str | None) -> None: + """Update the content.""" + self.clearContent() + if dHandle: + refs = SHARED.project.index.getBackReferenceList(dHandle) + for tHandle, (sTitle, hItem) in refs.items(): + self._setTreeItemValues(tHandle, sTitle, hItem) + return + + def refreshDocument(self, tHandle: str) -> None: + """Refresh document meta data.""" + if iItem := SHARED.project.index.getItemData(tHandle): + for sTitle, hItem in iItem.items(): + if f"{tHandle}:{sTitle}" in self._treeMap: + self._setTreeItemValues(tHandle, sTitle, hItem) + return + + ## + # Private Slots + ## + + @pyqtSlot("QModelIndex") + def _treeItemClicked(self, index: QModelIndex) -> None: + """Emit document open signal on user click.""" + tHandle = index.siblingAtColumn(self.C_DATA).data(self.D_HANDLE) + if index.column() == self.C_EDIT: + self._parent.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "", True) + elif index.column() == self.C_VIEW: + self._parent.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", True) + return + + ## + # Internal Functions + ## + + def _setTreeItemValues(self, tHandle: str, sTitle: str, hItem: IndexHeading) -> None: + """Add or update a tree item.""" + if nwItem := SHARED.project.tree[tHandle]: + docIcon = SHARED.theme.getItemIcon( + nwItem.itemType, nwItem.itemClass, + nwItem.itemLayout, nwItem.mainHeading + ) + iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0) if nwItem.isDocumentLayout() else 5 + hDec = SHARED.theme.getHeaderDecorationNarrow(iLevel) + + tKey = f"{tHandle}:{sTitle}" + trItem = self._treeMap[tKey] if tKey in self._treeMap else QTreeWidgetItem() + + trItem.setIcon(self.C_DOC, docIcon) + trItem.setText(self.C_DOC, nwItem.itemName) + trItem.setIcon(self.C_EDIT, self._editIcon) + trItem.setIcon(self.C_VIEW, self._viewIcon) + trItem.setText(self.C_TITLE, hItem.title) + trItem.setData(self.C_TITLE, Qt.ItemDataRole.DecorationRole, hDec) + trItem.setData(self.C_DATA, self.D_HANDLE, tHandle) + + if tKey not in self._treeMap: + self.addTopLevelItem(trItem) + self._treeMap[tKey] = trItem + + return + +# END Class _ViewPanelBackRefs + + +class _ViewPanelKeyWords(QTreeWidget): + + C_DATA = 0 + C_NAME = 0 + C_EDIT = 1 + C_VIEW = 2 + C_DOC = 3 + C_TITLE = 4 + + D_TAG = Qt.ItemDataRole.UserRole + + def __init__(self, parent: GuiDocViewerPanel, itemClass: nwItemClass) -> None: + super().__init__(parent=parent) + + self._parent = parent + self._treeMap: dict[str, QTreeWidgetItem] = {} + + iPx = SHARED.theme.baseIconSize + cMg = CONFIG.pxInt(6) + + self.setHeaderLabels([self.tr("Tag"), "", "", self.tr("Document"), self.tr("Heading")]) + self.setIndentation(0) + self.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.setIconSize(QSize(iPx, iPx)) + self.setFrameStyle(QFrame.Shape.NoFrame) + self.setSortingEnabled(True) + self.sortByColumn(self.C_NAME, Qt.SortOrder.AscendingOrder) + + # Set Header Sizes + treeHeader = self.header() + treeHeader.setStretchLastSection(True) + treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.ResizeToContents) + treeHeader.setSectionResizeMode(self.C_EDIT, QHeaderView.ResizeMode.Fixed) + treeHeader.setSectionResizeMode(self.C_VIEW, QHeaderView.ResizeMode.Fixed) + treeHeader.setSectionResizeMode(self.C_DOC, QHeaderView.ResizeMode.ResizeToContents) + treeHeader.resizeSection(self.C_EDIT, iPx + cMg) + treeHeader.resizeSection(self.C_VIEW, iPx + cMg) + + # Cache Icons Locally + self._classIcon = SHARED.theme.getIcon(nwLabels.CLASS_ICON[itemClass]) + self._editIcon = SHARED.theme.getIcon("edit") + self._viewIcon = SHARED.theme.getIcon("view") + + # Signals + self.clicked.connect(self._treeItemClicked) + + return + + def countEntries(self) -> int: + """Return the number of items in the list.""" + return self.topLevelItemCount() + + def clearContent(self) -> None: + """Clear the list.""" + self._treeMap = {} + self.clear() + return + + def addUpdateEntry(self, tag: str, name: str, iItem: IndexItem, hItem: IndexHeading) -> None: + """Add a new entry, or update an existing one.""" + nwItem = iItem.item + docIcon = SHARED.theme.getItemIcon( + nwItem.itemType, nwItem.itemClass, + nwItem.itemLayout, nwItem.mainHeading + ) + iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0) if nwItem.isDocumentLayout() else 5 + hDec = SHARED.theme.getHeaderDecorationNarrow(iLevel) + + # This can not use a get call to the dictionary as that creates + # some weird issue with Qt, so we need to do this with an if + trItem = self._treeMap[tag] if tag in self._treeMap else QTreeWidgetItem() + + trItem.setText(self.C_NAME, name) + trItem.setIcon(self.C_NAME, self._classIcon) + trItem.setIcon(self.C_EDIT, self._editIcon) + trItem.setIcon(self.C_VIEW, self._viewIcon) + trItem.setIcon(self.C_DOC, docIcon) + trItem.setText(self.C_DOC, nwItem.itemName) + trItem.setText(self.C_TITLE, hItem.title) + trItem.setData(self.C_TITLE, Qt.ItemDataRole.DecorationRole, hDec) + trItem.setData(self.C_DATA, self.D_TAG, tag) + + if tag not in self._treeMap: + self.addTopLevelItem(trItem) + self._treeMap[tag] = trItem + + return + + def removeEntry(self, tag: str) -> bool: + """Remove a tag from the list.""" + if tag in self._treeMap: + self.takeTopLevelItem(self.indexOfTopLevelItem(self._treeMap[tag])) + self._treeMap.pop(tag, None) + return True + return False + + ## + # Private Slots + ## + + @pyqtSlot("QModelIndex") + def _treeItemClicked(self, index: QModelIndex) -> None: + """Emit follow tag signal on user click.""" + tag = index.siblingAtColumn(self.C_DATA).data(self.D_TAG) + if index.column() == self.C_EDIT: + self._parent.loadDocumentTagRequest.emit(tag, nwDocMode.EDIT) + elif index.column() == self.C_VIEW: + self._parent.loadDocumentTagRequest.emit(tag, nwDocMode.VIEW) + return + +# END Class _ViewPanelKeyWords diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index 596a422b..95b2b612 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -377,13 +377,17 @@ class GuiOutlineTree(QTreeWidget): fH2 = self.font() fH2.setBold(True) + iType = nwItemType.FILE + iClass = nwItemClass.NO_CLASS + iLayout = nwItemLayout.DOCUMENT + self._hFonts = [self.font(), fH1, fH2, self.font(), self.font()] self._dIcon = { - "H0": SHARED.theme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, "H0"), - "H1": SHARED.theme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, "H1"), - "H2": SHARED.theme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, "H2"), - "H3": SHARED.theme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, "H3"), - "H4": SHARED.theme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, "H4"), + "H0": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H0"), + "H1": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H1"), + "H2": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H2"), + "H3": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H3"), + "H4": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H4"), } # Internals @@ -549,7 +553,7 @@ class GuiOutlineTree(QTreeWidget): """Load the state of the main tree header, that is, column order and column width. """ - # Load whatever we saved last time, regardless of wether it + # Load whatever we saved last time, regardless of whether it # contains the correct names or number of columns. colState = SHARED.project.options.getValue("GuiOutline", "columnState", {}) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 5b39d78d..9e60c69a 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -142,7 +142,6 @@ class GuiProjectView(QWidget): # Function Mappings self.emptyTrash = self.projTree.emptyTrash self.requestDeleteItem = self.projTree.requestDeleteItem - self.setTreeItemValues = self.projTree.setTreeItemValues self.propagateCount = self.projTree.propagateCount self.getSelectedHandle = self.projTree.getSelectedHandle self.setSelectedHandle = self.projTree.setSelectedHandle @@ -210,6 +209,12 @@ class GuiProjectView(QWidget): # Public Slots ## + @pyqtSlot(str) + def updateItemValues(self, tHandle: str) -> None: + """Update tree item""" + self.projTree.setTreeItemValues(tHandle) + return + @pyqtSlot(str, int, int, int) def updateCounts(self, tHandle: str, cCount: int, wCount: int, pCount: int) -> None: """Slot for updating the word count of a specific item.""" @@ -431,7 +436,7 @@ class GuiProjectToolBar(QWidget): return ## - # Slots + # Private Slots ## @pyqtSlot(str) diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 6c4d7e76..4ac8b714 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -133,6 +133,7 @@ class GuiTheme: self.getToggleIcon = self.iconCache.getToggleIcon self.loadDecoration = self.iconCache.loadDecoration self.getHeaderDecoration = self.iconCache.getHeaderDecoration + self.getHeaderDecorationNarrow = self.iconCache.getHeaderDecorationNarrow # Extract Other Info self.guiDPI = qApp.primaryScreen().logicalDotsPerInchX() @@ -464,8 +465,8 @@ class GuiIcons: # General Button Icons "add", "backward", "bookmark", "browse", "checked", "close", "cross", "down", "edit", - "export", "forward", "maximise", "menu", "minimise", "noncheckable", "reference", - "refresh", "remove", "revert", "search_replace", "search", "settings", "unchecked", "up", + "export", "forward", "maximise", "menu", "minimise", "noncheckable", "panel", "refresh", + "remove", "revert", "search_replace", "search", "settings", "unchecked", "up", "view", # Switches "sticky-on", "sticky-off", @@ -473,6 +474,8 @@ class GuiIcons: # Decorations "deco_doc_h0", "deco_doc_h1", "deco_doc_h2", "deco_doc_h3", "deco_doc_h4", "deco_doc_more", + "deco_doc_h0_n", "deco_doc_h1_n", "deco_doc_h2_n", "deco_doc_h3_n", "deco_doc_h4_n", + "deco_doc_nt_n", } TOGGLE_ICON_KEYS = { @@ -493,6 +496,7 @@ class GuiIcons: self._qIcons: dict[str, QIcon] = {} self._themeMap: dict[str, Path] = {} self._headerDec: list[QPixmap] = [] + self._headerDecNarrow: list[QPixmap] = [] # Icon Theme Path self._confName = "icons.conf" @@ -580,6 +584,7 @@ class GuiIcons: self._qIcons[iconKey] = qIcon self._headerDec = [] + self._headerDecNarrow = [] return True @@ -680,6 +685,20 @@ class GuiIcons: ] return self._headerDec[minmax(hLevel, 0, 4)] + def getHeaderDecorationNarrow(self, hLevel: int) -> QPixmap: + """Get the narrow decoration for a specific header level.""" + if not self._headerDecNarrow: + iPx = self.mainTheme.baseIconSize + self._headerDecNarrow = [ + self.loadDecoration("deco_doc_h0_n", h=iPx), + self.loadDecoration("deco_doc_h1_n", h=iPx), + self.loadDecoration("deco_doc_h2_n", h=iPx), + self.loadDecoration("deco_doc_h3_n", h=iPx), + self.loadDecoration("deco_doc_h4_n", h=iPx), + self.loadDecoration("deco_doc_nt_n", h=iPx), + ] + return self._headerDecNarrow[minmax(hLevel, 0, 5)] + ## # Internal Functions ## diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index c672a1f2..099cb422 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -44,10 +44,11 @@ from novelwriter.gui.outline import GuiOutlineView from novelwriter.gui.mainmenu import GuiMainMenu from novelwriter.gui.projtree import GuiProjectView from novelwriter.gui.doceditor import GuiDocEditor -from novelwriter.gui.docviewer import GuiDocViewDetails, GuiDocViewer +from novelwriter.gui.docviewer import GuiDocViewer from novelwriter.gui.noveltree import GuiNovelView from novelwriter.gui.statusbar import GuiMainStatus from novelwriter.gui.itemdetails import GuiItemDetails +from novelwriter.gui.docviewerpanel import GuiDocViewerPanel from novelwriter.dialogs.about import GuiAbout from novelwriter.dialogs.updates import GuiUpdates from novelwriter.dialogs.projload import GuiProjectLoad @@ -133,16 +134,16 @@ class GuiMain(QMainWindow): hWd = CONFIG.pxInt(4) # Main GUI Elements - self.mainStatus = GuiMainStatus(self) - self.projView = GuiProjectView(self) - self.novelView = GuiNovelView(self) - self.docEditor = GuiDocEditor(self) - self.viewMeta = GuiDocViewDetails(self) - self.docViewer = GuiDocViewer(self) - self.itemDetails = GuiItemDetails(self) - self.outlineView = GuiOutlineView(self) - self.mainMenu = GuiMainMenu(self) - self.sideBar = GuiSideBar(self) + self.mainStatus = GuiMainStatus(self) + self.projView = GuiProjectView(self) + self.novelView = GuiNovelView(self) + self.docEditor = GuiDocEditor(self) + self.docViewer = GuiDocViewer(self) + self.docViewerPanel = GuiDocViewerPanel(self) + self.itemDetails = GuiItemDetails(self) + self.outlineView = GuiOutlineView(self) + self.mainMenu = GuiMainMenu(self) + self.sideBar = GuiSideBar(self) # Project Tree Stack self.projStack = QStackedWidget(self) @@ -162,7 +163,7 @@ class GuiMain(QMainWindow): # Splitter : Document Viewer / Document Meta self.splitView = QSplitter(Qt.Vertical, self) self.splitView.addWidget(self.docViewer) - self.splitView.addWidget(self.viewMeta) + self.splitView.addWidget(self.docViewerPanel) self.splitView.setHandleWidth(hWd) self.splitView.setOpaqueResize(False) self.splitView.setSizes(CONFIG.viewPanePos) @@ -190,12 +191,12 @@ class GuiMain(QMainWindow): self.mainStack.currentChanged.connect(self._mainStackChanged) # Indices of Splitter Widgets - self.idxTree = self.splitMain.indexOf(self.treePane) - self.idxMain = self.splitMain.indexOf(self.splitDocs) - self.idxEditor = self.splitDocs.indexOf(self.docEditor) - self.idxViewer = self.splitDocs.indexOf(self.splitView) - self.idxViewDoc = self.splitView.indexOf(self.docViewer) - self.idxViewMeta = self.splitView.indexOf(self.viewMeta) + self.idxTree = self.splitMain.indexOf(self.treePane) + self.idxMain = self.splitMain.indexOf(self.splitDocs) + self.idxEditor = self.splitDocs.indexOf(self.docEditor) + self.idxViewer = self.splitDocs.indexOf(self.splitView) + self.idxViewDoc = self.splitView.indexOf(self.docViewer) + self.idxViewDocPanel = self.splitView.indexOf(self.docViewerPanel) # Indices of Stack Widgets self.idxEditorView = self.mainStack.indexOf(self.splitMain) @@ -209,7 +210,7 @@ class GuiMain(QMainWindow): self.splitDocs.setCollapsible(self.idxEditor, False) self.splitDocs.setCollapsible(self.idxViewer, False) self.splitView.setCollapsible(self.idxViewDoc, False) - self.splitView.setCollapsible(self.idxViewMeta, False) + self.splitView.setCollapsible(self.idxViewDocPanel, False) self.splitMain.setStretchFactor(self.idxTree, 0) self.splitMain.setStretchFactor(self.idxMain, 1) @@ -242,6 +243,12 @@ class GuiMain(QMainWindow): SHARED.projectStatusChanged.connect(self.mainStatus.updateProjectStatus) SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage) SHARED.spellLanguageChanged.connect(self.mainStatus.setLanguage) + SHARED.indexChangedTags.connect(self.docViewerPanel.updateChangedTags) + SHARED.indexScannedText.connect(self.docViewerPanel.projectItemChanged) + SHARED.indexScannedText.connect(self.projView.updateItemValues) + SHARED.indexScannedText.connect(self.itemDetails.updateViewBox) + SHARED.indexCleared.connect(self.docViewerPanel.indexWasCleared) + SHARED.indexAvailable.connect(self.docViewerPanel.indexHasAppeared) self.mainMenu.requestDocAction.connect(self._passDocumentAction) self.mainMenu.requestDocInsert.connect(self._passDocumentInsert) @@ -257,6 +264,7 @@ class GuiMain(QMainWindow): self.projView.treeItemChanged.connect(self.docEditor.updateDocInfo) self.projView.treeItemChanged.connect(self.docViewer.updateDocInfo) self.projView.treeItemChanged.connect(self.itemDetails.updateViewBox) + self.projView.treeItemChanged.connect(self.docViewerPanel.projectItemChanged) self.projView.rootFolderChanged.connect(self.outlineView.updateRootItem) self.projView.rootFolderChanged.connect(self.novelView.updateRootItem) self.projView.rootFolderChanged.connect(self.projView.updateRootItem) @@ -276,7 +284,12 @@ class GuiMain(QMainWindow): self.docEditor.closeDocumentRequest.connect(self.closeDocEditor) self.docEditor.toggleFocusModeRequest.connect(self.toggleFocusMode) + self.docViewer.documentLoaded.connect(self.docViewerPanel.updateHandle) self.docViewer.loadDocumentTagRequest.connect(self._followTag) + self.docViewer.togglePanelVisibility.connect(self._toggleViewerPanelVisibility) + + self.docViewerPanel.loadDocumentTagRequest.connect(self._followTag) + self.docViewerPanel.openDocumentRequest.connect(self._openDocument) self.outlineView.loadDocumentTagRequest.connect(self._followTag) self.outlineView.openDocumentRequest.connect(self._openDocument) @@ -681,7 +694,7 @@ class GuiMain(QMainWindow): vPos[0] = int(bPos[1]/2) vPos[1] = bPos[1] - vPos[0] self.splitDocs.setSizes(vPos) - self.viewMeta.setVisible(CONFIG.showRefPanel) + self.docViewerPanel.setVisible(CONFIG.showViewerPanel) if sTitle: self.docViewer.navigateTo(f"#{sTitle}") @@ -782,11 +795,9 @@ class GuiMain(QMainWindow): if not SHARED.hasProject: logger.error("No project open") return False - if tHandle is None and (self.docEditor.anyFocus() or self.isFocusMode): tHandle = self.docEditor.docHandle self.projView.renameTreeItem(tHandle) - return True def rebuildTrees(self) -> None: @@ -877,6 +888,7 @@ class GuiMain(QMainWindow): SHARED.theme.loadTheme() self.docEditor.updateTheme() self.docViewer.updateTheme() + self.docViewerPanel.updateTheme() self.sideBar.updateTheme() self.projView.updateTheme() self.novelView.updateTheme() @@ -1076,10 +1088,10 @@ class GuiMain(QMainWindow): if not self.isFocusMode: CONFIG.setMainPanePos(self.splitMain.sizes()) CONFIG.setOutlinePanePos(self.outlineView.splitSizes()) - if self.viewMeta.isVisible(): + if self.docViewerPanel.isVisible(): CONFIG.setViewPanePos(self.splitView.sizes()) - CONFIG.showRefPanel = self.viewMeta.isVisible() + CONFIG.showViewerPanel = self.docViewerPanel.isVisible() if self.windowState() & Qt.WindowFullScreen != Qt.WindowFullScreen: # Ignore window size if in full screen mode CONFIG.setMainWinSize(self.width(), self.height()) @@ -1267,6 +1279,13 @@ class GuiMain(QMainWindow): self.docEditor.insertText(content) return + @pyqtSlot() + def _toggleViewerPanelVisibility(self): + """Toggle the visibility of the document viewer panel.""" + CONFIG.showViewerPanel = not CONFIG.showViewerPanel + self.docViewerPanel.setVisible(CONFIG.showViewerPanel) + return + @pyqtSlot() def _timeTick(self) -> None: """Process time tick of the main timer.""" diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 8ee3099a..9bad600a 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -52,6 +52,10 @@ class SharedData(QObject): projectStatusChanged = pyqtSignal(bool) projectStatusMessage = pyqtSignal(str) spellLanguageChanged = pyqtSignal(str, str) + indexScannedText = pyqtSignal(str) + indexChangedTags = pyqtSignal(list, list) + indexCleared = pyqtSignal() + indexAvailable = pyqtSignal() def __init__(self) -> None: super().__init__() @@ -171,7 +175,7 @@ class SharedData(QObject): return def updateSpellCheckLanguage(self, reload: bool = False) -> None: - """Update the active spell check langauge from settings.""" + """Update the active spell check language from settings.""" from novelwriter import CONFIG language = self.project.data.spellLang or CONFIG.spellLanguage if language != self.spelling.spellLanguage or reload: @@ -210,6 +214,24 @@ class SharedData(QObject): QThreadPool.globalInstance().start(runnable, priority=priority) return + ## + # Signal Proxy + ## + + def indexSignalProxy(self, data: dict) -> None: + """Emit signals on behalf of the index.""" + event = data.get("event") + logger.debug("Received '%s' event from the index", event) + if event == "updateTags": + self.indexChangedTags.emit(data.get("updated", []), data.get("deleted", [])) + elif event == "scanText": + self.indexScannedText.emit(data.get("handle", "")) + elif event == "clearIndex": + self.indexCleared.emit() + elif event == "buildIndex": + self.indexAvailable.emit() + return + ## # Alert Boxes ## diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf index 739a6378..5007dd54 100644 --- a/tests/reference/baseConfig_novelwriter.conf +++ b/tests/reference/baseConfig_novelwriter.conf @@ -68,7 +68,7 @@ stopwhenidle = True useridletime = 300 [State] -showrefpanel = True +showviewerpanel = True showedittoolbar = False useshortcodes = False viewcomments = True diff --git a/tests/reference/guiPreferences_novelwriter.conf b/tests/reference/guiPreferences_novelwriter.conf index 002dc463..9275b82e 100644 --- a/tests/reference/guiPreferences_novelwriter.conf +++ b/tests/reference/guiPreferences_novelwriter.conf @@ -68,7 +68,7 @@ stopwhenidle = True useridletime = 300 [State] -showrefpanel = True +showviewerpanel = True showedittoolbar = False useshortcodes = False viewcomments = True diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 773b0e79..6354b39c 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -231,6 +231,7 @@ def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd): assert isinstance(cItem, NWItem) assert index.rootChangedSince(C.hNovelRoot, 0) is False + assert index.rootChangedSince(None, 0) is False assert index.indexChangedSince(0) is False assert index.scanText(cHandle, ( @@ -562,11 +563,11 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): )) # The novel structure should contain the pointer to the novel file header - theKeys = [] + keys = [] for aKey, _, _, _ in index.novelStructure(): - theKeys.append(aKey) + keys.append(aKey) - assert theKeys == [ + assert keys == [ f"{C.hTitlePage}:T0001", f"{C.hChapterDoc}:T0001", f"{C.hSceneDoc}:T0001", @@ -576,22 +577,22 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): # Check that excluded files can be skipped project.tree[nHandle].setActive(False) # type: ignore - theKeys = [] + keys = [] for aKey, _, _, _ in index.novelStructure(skipExcl=False): - theKeys.append(aKey) + keys.append(aKey) - assert theKeys == [ + assert keys == [ f"{C.hTitlePage}:T0001", f"{C.hChapterDoc}:T0001", f"{C.hSceneDoc}:T0001", f"{nHandle}:T0001", ] - theKeys = [] + keys = [] for aKey, _, _, _ in index.novelStructure(skipExcl=True): - theKeys.append(aKey) + keys.append(aKey) - assert theKeys == [ + assert keys == [ f"{C.hTitlePage}:T0001", f"{C.hChapterDoc}:T0001", f"{C.hSceneDoc}:T0001", @@ -606,23 +607,24 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): # getItemData + getHandleHeaderCount # ================================== - theItem = index.getItemData(nHandle) - assert isinstance(theItem, IndexItem) - assert theItem.headings() == ["T0001"] + item = index.getItemData(nHandle) + assert isinstance(item, IndexItem) + assert item.headings() == ["T0001"] assert index.getHandleHeaderCount(nHandle) == 1 + assert index.getHandleHeaderCount("foo") == 0 # getReferences # ============= # Look up an invalid handle - theRefs = index.getReferences("Not a handle") - assert theRefs["@pov"] == [] - assert theRefs["@char"] == [] + refs = index.getReferences("Not a handle") + assert refs["@pov"] == [] + assert refs["@char"] == [] # The novel file should now refer to Jane as @pov and @char - theRefs = index.getReferences(nHandle) - assert theRefs["@pov"] == ["Jane"] - assert theRefs["@char"] == ["Jane"] + refs = index.getReferences(nHandle) + assert refs["@pov"] == ["Jane"] + assert refs["@char"] == ["Jane"] # getBackReferenceList # ==================== @@ -634,8 +636,8 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): assert index.getBackReferenceList(C.hTitlePage) == {} # The character file should have a record of the reference from the novel file - theRefs = index.getBackReferenceList(cHandle) - assert theRefs == {nHandle: "T0001"} + refs = index.getBackReferenceList(cHandle) + assert refs[nHandle][0] == "T0001" # getTagSource # ============ @@ -643,6 +645,32 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): assert index.getTagSource("Jane") == (cHandle, "T0001") assert index.getTagSource("John") == (None, "T0000") + # getDocumentTags + # =============== + assert index.getDocumentTags(cHandle) == ["jane"] + assert index.getDocumentTags(None) == [] + + # getClassTags + # ============ + assert index.getClassTags(nwItemClass.CHARACTER) == ["Jane"] + + # getTagsData + # =========== + assert list(index.getTagsData()) == [( + "jane", "Jane", "CHARACTER", + index.getItemData(cHandle), + index.getItemHeader(cHandle, "T0001") + )] + + # getSingleTag + # ============ + assert index.getSingleTag("jane") == ( + "Jane", "CHARACTER", + index.getItemData(cHandle), + index.getItemHeader(cHandle, "T0001") + ) + assert index.getSingleTag("foobar") == ("", "", None, None) + # getCounts # ========= # For whole text and sections @@ -1176,9 +1204,10 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd): itemIndex.unpackData({C.hInvalid: {}}) assert itemIndex._items == {} - # Known keys can be added, even witout data + # Known keys can be added, even without data itemIndex.unpackData({nHandle: {}}) assert nHandle in itemIndex + assert itemIndex[nHandle].handle == nHandle # type: ignore # Title tags must be valid with pytest.raises(ValueError): diff --git a/tests/test_core/test_core_projectxml.py b/tests/test_core/test_core_projectxml.py index 3448e653..a6c6e5e1 100644 --- a/tests/test_core/test_core_projectxml.py +++ b/tests/test_core/test_core_projectxml.py @@ -84,7 +84,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath): assert xmlReader.read(data, content) is False assert xmlReader.state == XMLReadState.NOT_NWX_FILE - # Add a valid novelwriter file without a file version + # Add a valid novelWriter file without a file version writeFile(xmlFile, "") assert xmlReader.read(data, content) is False assert xmlReader.state == XMLReadState.UNKNOWN_VERSION diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 008ad053..22a67be4 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -1194,15 +1194,15 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): # Empty Block nwGUI.docEditor.setCursorLine(2) - assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN + assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL # Not On Tag nwGUI.docEditor.setCursorLine(1) - assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN + assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL # On Tag Keyword nwGUI.docEditor.setCursorPosition(15) - assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN + assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL # On Known Tag, No Follow nwGUI.docEditor.setCursorPosition(22) @@ -1230,7 +1230,7 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert "0000000000012" not in SHARED.project.tree nwGUI.docEditor.setCursorPosition(47) - assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN + assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL # qtbot.stop() diff --git a/tests/test_gui/test_gui_docviewer.py b/tests/test_gui/test_gui_docviewer.py index cc8bc995..b05e35b1 100644 --- a/tests/test_gui/test_gui_docviewer.py +++ b/tests/test_gui/test_gui_docviewer.py @@ -23,13 +23,14 @@ import pytest from mocked import causeException -from PyQt5.QtCore import Qt, QUrl from PyQt5.QtGui import QTextCursor -from PyQt5.QtWidgets import qApp, QAction +from PyQt5.QtCore import Qt, QUrl +from PyQt5.QtWidgets import QMenu, qApp, QAction from novelwriter import CONFIG, SHARED from novelwriter.enum import nwDocAction from novelwriter.core.tohtml import ToHtml +from novelwriter.gui.docviewer import GuiDocViewer @pytest.mark.gui @@ -37,153 +38,178 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum): """Test the document viewer.""" # Open project assert nwGUI.openProject(prjLipsum) - - # Rebuild the index - nwGUI.mainMenu.aRebuildIndex.activate(QAction.Trigger) - assert SHARED.project.index._tagsIndex._tags != {} - assert SHARED.project.index._itemIndex._items != {} + docViewer: GuiDocViewer = nwGUI.docViewer # Select a document in the project tree nwGUI.projView.setSelectedHandle("88243afbe5ed8") + nwGUI.openDocument("88243afbe5ed8") + + # Can only open a document + assert docViewer.loadText("b3643d0f92e32") is False # Middle-click the selected item - theItem = nwGUI.projView.projTree._getTreeItem("88243afbe5ed8") - theRect = nwGUI.projView.projTree.visualItemRect(theItem) - qtbot.mouseClick(nwGUI.projView.projTree.viewport(), Qt.MidButton, pos=theRect.center()) - assert nwGUI.docViewer.docHandle == "88243afbe5ed8" + item = nwGUI.projView.projTree._getTreeItem("88243afbe5ed8") + rect = nwGUI.projView.projTree.visualItemRect(item) + qtbot.mouseClick(nwGUI.projView.projTree.viewport(), Qt.MidButton, pos=rect.center()) + assert docViewer.docHandle == "88243afbe5ed8" + + # Clear selection + nwGUI.projView.projTree.clearSelection() + assert nwGUI.projView.projTree.getSelectedHandle() is None + + # Re-select via header click + docViewer.docHeader.mousePressEvent(None) # type: ignore + assert nwGUI.projView.projTree.getSelectedHandle() == "88243afbe5ed8" # Reload the text - origText = nwGUI.docViewer.toPlainText() - nwGUI.docViewer.setPlainText("Oops, all gone!") - nwGUI.docViewer.docHeader._refreshDocument() - assert nwGUI.docViewer.toPlainText() == origText + origText = docViewer.toPlainText() + docViewer.setPlainText("Oops, all gone!") + docViewer.docHeader._refreshDocument() + assert docViewer.toPlainText() == origText # Select word - theCursor = nwGUI.docViewer.textCursor() - theCursor.setPosition(100) - nwGUI.docViewer.setTextCursor(theCursor) - nwGUI.docViewer._makeSelection(QTextCursor.WordUnderCursor) + cursor = docViewer.textCursor() + cursor.setPosition(100) + docViewer.setTextCursor(cursor) + docViewer._makeSelection(QTextCursor.WordUnderCursor) qClip = qApp.clipboard() qClip.clear() # Cut - assert nwGUI.docViewer.docAction(nwDocAction.CUT) is True + assert docViewer.docAction(nwDocAction.CUT) is True assert qClip.text() == "laoreet" qClip.clear() # Copy - assert nwGUI.docViewer.docAction(nwDocAction.COPY) is True + assert docViewer.docAction(nwDocAction.COPY) is True assert qClip.text() == "laoreet" qClip.clear() # Select Paragraph - assert nwGUI.docViewer.docAction(nwDocAction.SEL_PARA) is True - theCursor = nwGUI.docViewer.textCursor() - assert theCursor.selectedText() == ( + assert docViewer.docAction(nwDocAction.SEL_PARA) is True + cursor = docViewer.textCursor() + assert cursor.selectedText() == ( "Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, " "eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et " "mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. " "Etiam finibus nisi vel mi molestie consectetur." ) + cursor.clearSelection() + docViewer.setTextCursor(cursor) + + docViewer._makePosSelection( + QTextCursor.SelectionType.BlockUnderCursor, docViewer.cursorRect().center() + ) + cursor = docViewer.textCursor() + assert cursor.selectedText() == ( + "Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, " + "eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et " + "mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. " + "Etiam finibus nisi vel mi molestie consectetur." + ) + cursor.clearSelection() + docViewer.setTextCursor(cursor) # Select All - assert nwGUI.docViewer.docAction(nwDocAction.SEL_ALL) is True - theCursor = nwGUI.docViewer.textCursor() - assert len(theCursor.selectedText()) == 3061 + assert docViewer.docAction(nwDocAction.SEL_ALL) is True + cursor = docViewer.textCursor() + assert len(cursor.selectedText()) == 3061 # Other actions - assert nwGUI.docViewer.docAction(nwDocAction.NO_ACTION) is False + assert docViewer.docAction(nwDocAction.NO_ACTION) is False # Close document - nwGUI.docViewer.docHeader._closeDocument() - assert nwGUI.docViewer.docHandle is None + docViewer.docHeader._closeDocument() + assert docViewer.docHandle is None # Action on no document - assert nwGUI.docViewer.docAction(nwDocAction.COPY) is False + assert docViewer.docAction(nwDocAction.COPY) is False # Open again via menu assert nwGUI.projView.setSelectedHandle("88243afbe5ed8") nwGUI.mainMenu.aViewDoc.activate(QAction.Trigger) + # Open context menu + menuOpened = False + + def mockExec(*a): + nonlocal menuOpened + menuOpened = True + + cursor = docViewer.textCursor() + cursor.setPosition(27) + docViewer.setTextCursor(cursor) + docViewer._makeSelection(QTextCursor.WordUnderCursor) + with monkeypatch.context() as mp: + mp.setattr(QMenu, "exec_", mockExec) + docViewer._openContextMenu(docViewer.cursorRect().center()) + assert menuOpened + # Select "Bod" link - theCursor = nwGUI.docViewer.textCursor() - theCursor.setPosition(27) - nwGUI.docViewer.setTextCursor(theCursor) - nwGUI.docViewer._makeSelection(QTextCursor.WordUnderCursor) - theRect = nwGUI.docViewer.cursorRect() - # qtbot.mouseClick(nwGUI.docViewer.viewport(), Qt.LeftButton, pos=theRect.center(), delay=100) - nwGUI.docViewer._linkClicked(QUrl("#char=Bod")) - assert nwGUI.docViewer.docHandle == "4c4f28287af27" + cursor = docViewer.textCursor() + cursor.setPosition(27) + docViewer.setTextCursor(cursor) + docViewer._makeSelection(QTextCursor.WordUnderCursor) + rect = docViewer.cursorRect() + docViewer._linkClicked(QUrl("#char=Bod")) + assert docViewer.docHandle == "4c4f28287af27" # Click mouse nav buttons - qtbot.mouseClick(nwGUI.docViewer.viewport(), Qt.BackButton, pos=theRect.center(), delay=100) - assert nwGUI.docViewer.docHandle == "88243afbe5ed8" - qtbot.mouseClick(nwGUI.docViewer.viewport(), Qt.ForwardButton, pos=theRect.center(), delay=100) - assert nwGUI.docViewer.docHandle == "4c4f28287af27" + qtbot.mouseClick(docViewer.viewport(), Qt.BackButton, pos=rect.center(), delay=100) + assert docViewer.docHandle == "88243afbe5ed8" + qtbot.mouseClick(docViewer.viewport(), Qt.ForwardButton, pos=rect.center(), delay=100) + assert docViewer.docHandle == "4c4f28287af27" + qtbot.mouseClick(docViewer.viewport(), Qt.LeftButton, pos=rect.center(), delay=100) + assert docViewer.docHandle == "4c4f28287af27" # Scroll bar default on empty document - nwGUI.docViewer.clear() - assert nwGUI.docViewer.scrollPosition == 0 - nwGUI.docViewer.reloadText() + docViewer.clear() + assert docViewer.scrollPosition == 0 + docViewer.reloadText() + + # Flip some settings + CONFIG.doJustify = True + CONFIG.hideVScroll = True + CONFIG.hideHScroll = True + docViewer.initViewer() + assert docViewer.verticalScrollBar().isVisible() is False + assert docViewer.horizontalScrollBar().isVisible() is False # Change document title nwItem = SHARED.project.tree["4c4f28287af27"] - nwItem.setName("Test Title") - assert nwItem.itemName == "Test Title" - nwGUI.docViewer.updateDocInfo("4c4f28287af27") - assert nwGUI.docViewer.docHeader.theTitle.text() == "Characters › Test Title" + nwItem.setName("Test Title") # type: ignore + assert nwItem.itemName == "Test Title" # type: ignore + docViewer.updateDocInfo("4c4f28287af27") + assert docViewer.docHeader.docTitle.text() == "Characters \u203a Test Title" # Title without full path CONFIG.showFullPath = False - nwGUI.docViewer.updateDocInfo("4c4f28287af27") - assert nwGUI.docViewer.docHeader.theTitle.text() == "Test Title" + docViewer.updateDocInfo("4c4f28287af27") + assert docViewer.docHeader.docTitle.text() == "Test Title" CONFIG.showFullPath = True - # Document footer show/hide references - viewState = nwGUI.viewMeta.isVisible() - nwGUI.docViewer.docFooter._doShowHide() - assert nwGUI.viewMeta.isVisible() is not viewState - nwGUI.docViewer.docFooter._doShowHide() - assert nwGUI.viewMeta.isVisible() is viewState - - # Document footer sticky - viewState = nwGUI.docViewer.stickyRef - nwGUI.docViewer.docFooter._doToggleSticky(not viewState) - assert nwGUI.docViewer.stickyRef is not viewState - nwGUI.docViewer.docFooter._doToggleSticky(viewState) - assert nwGUI.docViewer.stickyRef is viewState - # Document footer show/hide synopsis assert nwGUI.viewDocument("f96ec11c6a3da") is True - assert len(nwGUI.docViewer.toPlainText()) == 4315 - nwGUI.docViewer.docFooter._doToggleSynopsis(False) - assert len(nwGUI.docViewer.toPlainText()) == 4099 + assert len(docViewer.toPlainText()) == 4315 + docViewer.docFooter._doToggleSynopsis(False) + assert len(docViewer.toPlainText()) == 4099 # Document footer show/hide comments assert nwGUI.viewDocument("846352075de7d") is True - assert len(nwGUI.docViewer.toPlainText()) == 675 - nwGUI.docViewer.docFooter._doToggleComments(False) - assert len(nwGUI.docViewer.toPlainText()) == 635 + assert len(docViewer.toPlainText()) == 675 + docViewer.docFooter._doToggleComments(False) + assert len(docViewer.toPlainText()) == 635 # Crash the HTML rendering with monkeypatch.context() as mp: mp.setattr(ToHtml, "doConvert", causeException) - assert nwGUI.docViewer.loadText("846352075de7d") is False - assert nwGUI.docViewer.toPlainText() == "An error occurred while generating the preview." + assert docViewer.loadText("846352075de7d") is False + assert docViewer.toPlainText() == "An error occurred while generating the preview." - # Check reference panel (issue #1378) - assert nwGUI.viewDocument("4c4f28287af27") is True - assert nwGUI.docViewer.toPlainText().startswith("Nobody Owens") - - nwGUI.viewMeta._linkClicked("fb609cd8319dc") - assert nwGUI.docViewer.toPlainText().startswith("Chapter One") - - nwGUI.viewMeta._linkClicked("88243afbe5ed8#T0001") - assert nwGUI.docViewer.toPlainText().startswith("Scene One") - - nwGUI.viewMeta._linkClicked("88243afbe5ed8#ABCD") - assert nwGUI.docViewer.toPlainText().startswith("Scene One") + # Call the update theme function + # This only checks that t doesn't fail, functionality tested elsewhere + docViewer.updateTheme() # qtbot.stop() diff --git a/tests/test_gui/test_gui_docviewerpanel.py b/tests/test_gui/test_gui_docviewerpanel.py new file mode 100644 index 00000000..e9f96ada --- /dev/null +++ b/tests/test_gui/test_gui_docviewerpanel.py @@ -0,0 +1,193 @@ +""" +novelWriter – Main GUI Viewer Panel Class Tester +================================================ + +This file is a part of novelWriter +Copyright 2018–2023, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import pytest + +from tools import C, buildTestProject + +from novelwriter import SHARED +from novelwriter.constants import nwLists +from novelwriter.dialogs.editlabel import GuiEditLabel + + +@pytest.mark.gui +def testGuiViewerPanel_BackRefs(qtbot, monkeypatch, nwGUI, projPath, mockRnd): + """Test the document viewer backreference panel.""" + monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) + + buildTestProject(nwGUI, projPath) + projTree = nwGUI.projView.projTree + projTree._getTreeItem(C.hChapterDir).setExpanded(True) + viewPanel = nwGUI.docViewerPanel + tabBackRefs = viewPanel.tabBackRefs + + nwGUI.openDocument(C.hSceneDoc) + nwGUI.viewDocument(C.hSceneDoc) + + # Hide/Show + nwGUI._toggleViewerPanelVisibility() + assert viewPanel.isVisible() is False + nwGUI._toggleViewerPanelVisibility() + assert viewPanel.isVisible() is True + + # Initial State + assert tabBackRefs.topLevelItemCount() == 0 + + # Add Two Tags + hJane = "0000000000010" + nwGUI.docEditor.setPlainText("### New Scene\n\n@char: Jane, John\n\n") + nwGUI.saveDocument() + cursor = nwGUI.docEditor.textCursor() + cursor.setPosition(22) + nwGUI.docEditor._processTag(cursor, create=True) + cursor.setPosition(28) + nwGUI.docEditor._processTag(cursor, create=True) + nwGUI.viewDocument(hJane) + assert tabBackRefs.topLevelItemCount() == 1 + + # Check Backreference + item = tabBackRefs.topLevelItem(0) + assert item.text(tabBackRefs.C_DOC) == "New Scene" + assert item.text(tabBackRefs.C_TITLE) == "New Scene" + + # Update Title + nwGUI.docEditor.setPlainText("### Scene One\n\n@char: Jane, John\n\n") + nwGUI.saveDocument() + item = tabBackRefs.topLevelItem(0) + assert item.text(tabBackRefs.C_DOC) == "New Scene" + assert item.text(tabBackRefs.C_TITLE) == "Scene One" + + # Update Label + SHARED.project.tree[C.hSceneDoc].setName("First Scene") # type: ignore + projTree.renameTreeItem(C.hSceneDoc) + item = tabBackRefs.topLevelItem(0) + assert item.text(tabBackRefs.C_DOC) == "First Scene" + assert item.text(tabBackRefs.C_TITLE) == "Scene One" + + # Clear Index + SHARED.project.index.clearIndex() + assert tabBackRefs.topLevelItemCount() == 0 + + # Rebuild Index + SHARED.project.index.rebuildIndex() + assert tabBackRefs.topLevelItemCount() == 1 + + # Click the Edit Button + nwGUI.openDocument(C.hChapterDoc) + assert nwGUI.docEditor.docHandle == C.hChapterDoc + tabBackRefs._treeItemClicked(tabBackRefs.model().index(0, tabBackRefs.C_EDIT)) + assert nwGUI.docEditor.docHandle == C.hSceneDoc + + # Click the View Button + assert nwGUI.docViewer.docHandle == hJane + tabBackRefs._treeItemClicked(tabBackRefs.model().index(0, tabBackRefs.C_VIEW)) + assert nwGUI.docViewer.docHandle == C.hSceneDoc + + # qtbot.stop() + +# END Test testGuiViewerPanel_BackRefs + + +@pytest.mark.gui +def testGuiViewerPanel_Tags(qtbot, monkeypatch, caplog, nwGUI, projPath, mockRnd): + """Test the document viewer tags panels.""" + monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) + + buildTestProject(nwGUI, projPath) + projTree = nwGUI.projView.projTree + projTree._getTreeItem(C.hChapterDir).setExpanded(True) + viewPanel = nwGUI.docViewerPanel + + nwGUI.openDocument(C.hSceneDoc) + nwGUI.viewDocument(C.hSceneDoc) + + assert len(viewPanel.kwTabs) == len(nwLists.USER_CLASSES) + assert len(viewPanel.idTabs) == len(nwLists.USER_CLASSES) + + # Add Two Tags + hJane = "0000000000010" + hJohn = "0000000000011" + nwGUI.docEditor.setPlainText("### New Scene\n\n@char: Jane, John\n\n") + nwGUI.saveDocument() + cursor = nwGUI.docEditor.textCursor() + cursor.setPosition(22) + nwGUI.docEditor._processTag(cursor, create=True) + cursor.setPosition(28) + nwGUI.docEditor._processTag(cursor, create=True) + + # Check Panel Tab Visibility + assert viewPanel.mainTabs.isTabVisible(viewPanel.idTabs["CHARACTER"]) is True + assert viewPanel.mainTabs.isTabVisible(viewPanel.idTabs["PLOT"]) is False + assert viewPanel.mainTabs.isTabVisible(viewPanel.idTabs["WORLD"]) is False + assert viewPanel.mainTabs.isTabVisible(viewPanel.idTabs["TIMELINE"]) is False + assert viewPanel.mainTabs.isTabVisible(viewPanel.idTabs["OBJECT"]) is False + assert viewPanel.mainTabs.isTabVisible(viewPanel.idTabs["ENTITY"]) is False + assert viewPanel.mainTabs.isTabVisible(viewPanel.idTabs["CUSTOM"]) is False + + # Check Character Tab + charTab = viewPanel.kwTabs["CHARACTER"] + viewPanel.mainTabs.setCurrentIndex(viewPanel.idTabs["CHARACTER"]) + assert charTab.topLevelItemCount() == 2 + item = charTab.topLevelItem(0) + assert item.text(charTab.C_NAME) == "Jane" + assert item.text(charTab.C_DOC) == "Jane" + assert item.text(charTab.C_TITLE) == "Jane" + item = charTab.topLevelItem(1) + assert item.text(charTab.C_NAME) == "John" + assert item.text(charTab.C_DOC) == "John" + assert item.text(charTab.C_TITLE) == "John" + + # Edit Jane + nwGUI.openDocument(hJane) + nwGUI.docEditor.setPlainText("# Jane Smith\n\n@tag: Janey\n\n") + nwGUI.saveDocument() + SHARED.project.tree[hJane].setName("Awesome Jane") # type: ignore + projTree.renameTreeItem(hJane) + item = charTab.topLevelItem(0) + assert item.text(charTab.C_NAME) == "Janey" + assert item.text(charTab.C_DOC) == "Awesome Jane" + assert item.text(charTab.C_TITLE) == "Jane Smith" + + # Clear Index + SHARED.project.index.clearIndex() + assert charTab.topLevelItemCount() == 0 + + # Rebuild Index + SHARED.project.index.rebuildIndex() + assert charTab.topLevelItemCount() == 2 + + # Remove Non-Existing Tag + caplog.clear() + viewPanel.updateChangedTags(["foo"], ["bar"]) + assert charTab.topLevelItemCount() == 2 + assert "Could not remove tag" in caplog.text + + # View/Edit John + assert nwGUI.docEditor.docHandle == hJane + charTab._treeItemClicked(charTab.model().index(1, charTab.C_EDIT)) + assert nwGUI.docEditor.docHandle == hJohn + assert nwGUI.docViewer.docHandle == C.hSceneDoc + charTab._treeItemClicked(charTab.model().index(1, charTab.C_VIEW)) + assert nwGUI.docViewer.docHandle == hJohn + + # qtbot.stop() + +# END Test testGuiViewerPanel_Tags diff --git a/tests/test_gui/test_gui_noveltree.py b/tests/test_gui/test_gui_noveltree.py index 4ada522b..e01c0dd9 100644 --- a/tests/test_gui/test_gui_noveltree.py +++ b/tests/test_gui/test_gui_noveltree.py @@ -37,8 +37,7 @@ from novelwriter.dialogs.editlabel import GuiEditLabel @pytest.mark.gui def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): - """Test navigating the novel tree. - """ + """Test navigating the novel tree.""" monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) buildTestProject(nwGUI, projPath)