diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index 0adec011..44317317 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -17,6 +17,7 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ [Map] add = typ_plus.svg +add_document = typ_document-add.svg alert_error = typ_delete-full.svg alert_info = typ_lightbulb-full.svg alert_question = typ_directions-full.svg @@ -43,6 +44,7 @@ cls_timeline = typ_calendar.svg cls_trash = typ_trash.svg cls_world = typ_location.svg cross = typ_times.svg +document = typ_document.svg down = typ_chevron-down.svg edit = typ_pencil.svg export = typ_export.svg @@ -83,6 +85,7 @@ search_regex = nw_search-regex.svg search_replace = mixed_search-replace.svg search_word = nw_search-word.svg settings = typ_cog.svg +star = typ_star.svg status_idle = typ_media-pause-grey.svg status_lang = typ_globe-grey.svg status_lines = typ_th-list-grey.svg diff --git a/novelwriter/assets/icons/typicons_dark/typ_document-add.svg b/novelwriter/assets/icons/typicons_dark/typ_document-add.svg new file mode 100644 index 00000000..68f6b53d --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_document-add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_document.svg b/novelwriter/assets/icons/typicons_dark/typ_document.svg new file mode 100644 index 00000000..20121f32 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_document.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index 69273d2f..7579bb70 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -17,6 +17,7 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ [Map] add = typ_plus.svg +add_document = typ_document-add.svg alert_error = typ_delete-full.svg alert_info = typ_lightbulb-full.svg alert_question = typ_directions-full.svg @@ -43,6 +44,7 @@ cls_timeline = typ_calendar.svg cls_trash = typ_trash.svg cls_world = typ_location.svg cross = typ_times.svg +document = typ_document.svg down = typ_chevron-down.svg edit = typ_pencil.svg export = typ_export.svg @@ -83,6 +85,7 @@ search_regex = nw_search-regex.svg search_replace = mixed_search-replace.svg search_word = nw_search-word.svg settings = typ_cog.svg +star = typ_star.svg status_idle = typ_media-pause-grey.svg status_lang = typ_globe-grey.svg status_lines = typ_th-list-grey.svg diff --git a/novelwriter/assets/icons/typicons_light/typ_document-add.svg b/novelwriter/assets/icons/typicons_light/typ_document-add.svg new file mode 100644 index 00000000..4e181685 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_document-add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_document.svg b/novelwriter/assets/icons/typicons_light/typ_document.svg new file mode 100644 index 00000000..3d42b98b --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_document.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/images/novelwriter-text-dark.svg b/novelwriter/assets/images/novelwriter-text-dark.svg new file mode 100644 index 00000000..c80b2b8d --- /dev/null +++ b/novelwriter/assets/images/novelwriter-text-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/images/novelwriter-text-light.svg b/novelwriter/assets/images/novelwriter-text-light.svg new file mode 100644 index 00000000..8af7fa12 --- /dev/null +++ b/novelwriter/assets/images/novelwriter-text-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/images/welcome-dark.jpg b/novelwriter/assets/images/welcome-dark.jpg new file mode 100644 index 00000000..d8fd1c5f Binary files /dev/null and b/novelwriter/assets/images/welcome-dark.jpg differ diff --git a/novelwriter/assets/images/welcome-light.jpg b/novelwriter/assets/images/welcome-light.jpg new file mode 100644 index 00000000..70cf548c Binary files /dev/null and b/novelwriter/assets/images/welcome-light.jpg differ diff --git a/novelwriter/assets/images/wizard-back.jpg b/novelwriter/assets/images/wizard-back.jpg deleted file mode 100644 index 8db614c8..00000000 Binary files a/novelwriter/assets/images/wizard-back.jpg and /dev/null differ diff --git a/novelwriter/config.py b/novelwriter/config.py index debe960b..9036fbf4 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -110,8 +110,8 @@ class Config: # Size Settings self._mainWinSize = [1200, 650] # Last size of the main GUI window + self._welcomeSize = [800, 500] # Last size of the welcome window self._prefsWinSize = [700, 615] # Last size of the Preferences dialog - self._projLoadCols = [280, 60, 160] # Last columns widths of the Project Load dialog self._mainPanePos = [300, 800] # Last position of the main window splitter self._viewPanePos = [500, 150] # Last position of the document viewer splitter self._outlnPanePos = [500, 150] # Last position of the outline panel splitter @@ -250,12 +250,12 @@ class Config: return [int(x*self.guiScale) for x in self._mainWinSize] @property - def preferencesWinSize(self) -> list[int]: - return [int(x*self.guiScale) for x in self._prefsWinSize] + def welcomeWinSize(self) -> list[int]: + return [int(x*self.guiScale) for x in self._welcomeSize] @property - def projLoadColWidths(self) -> list[int]: - return [int(x*self.guiScale) for x in self._projLoadCols] + def preferencesWinSize(self) -> list[int]: + return [int(x*self.guiScale) for x in self._prefsWinSize] @property def mainPanePos(self) -> list[int]: @@ -306,17 +306,18 @@ class Config: self._mainWinSize[1] = height return + def setWelcomeWinSize(self, width: int, height: int) -> None: + """Set the size of the Preferences dialog window.""" + self._welcomeSize[0] = int(width/self.guiScale) + self._welcomeSize[1] = int(height/self.guiScale) + return + def setPreferencesWinSize(self, width: int, height: int) -> None: """Set the size of the Preferences dialog window.""" self._prefsWinSize[0] = int(width/self.guiScale) self._prefsWinSize[1] = int(height/self.guiScale) return - def setProjLoadColWidths(self, widths: list[int]) -> None: - """Set the column widths of the Load Project dialog.""" - self._projLoadCols = [int(x/self.guiScale) for x in widths] - return - def setMainPanePos(self, pos: list[int]) -> None: """Set the position of the main GUI splitter.""" self._mainPanePos = [int(x/self.guiScale) for x in pos] @@ -410,10 +411,10 @@ class Config: """Compile and return error messages from the initialisation of the Config class, and clear the error buffer. """ - errMessage = "
".join(self._errData) + message = "
".join(self._errData) self._hasError = False self._errData = [] - return errMessage + return message def listLanguages(self, lngSet: int) -> list[tuple[str, str]]: """List localisation files in the i18n folder. The default GUI @@ -545,8 +546,8 @@ class Config: # Sizes sec = "Sizes" self._mainWinSize = conf.rdIntList(sec, "mainwindow", self._mainWinSize) + self._welcomeSize = conf.rdIntList(sec, "welcome", self._welcomeSize) self._prefsWinSize = conf.rdIntList(sec, "preferences", self._prefsWinSize) - self._projLoadCols = conf.rdIntList(sec, "projloadcols", self._projLoadCols) self._mainPanePos = conf.rdIntList(sec, "mainpane", self._mainPanePos) self._viewPanePos = conf.rdIntList(sec, "viewpane", self._viewPanePos) self._outlnPanePos = conf.rdIntList(sec, "outlinepane", self._outlnPanePos) @@ -635,7 +636,7 @@ class Config: conf = NWConfigParser() conf["Meta"] = { - "timestamp": formatTimeStamp(time()), + "timestamp": formatTimeStamp(time()), } conf["Main"] = { @@ -651,12 +652,12 @@ class Config: } conf["Sizes"] = { - "mainwindow": self._packList(self._mainWinSize), - "preferences": self._packList(self._prefsWinSize), - "projloadcols": self._packList(self._projLoadCols), - "mainpane": self._packList(self._mainPanePos), - "viewpane": self._packList(self._viewPanePos), - "outlinepane": self._packList(self._outlnPanePos), + "mainwindow": self._packList(self._mainWinSize), + "welcome": self._packList(self._welcomeSize), + "preferences": self._packList(self._prefsWinSize), + "mainpane": self._packList(self._mainPanePos), + "viewpane": self._packList(self._viewPanePos), + "outlinepane": self._packList(self._outlnPanePos), } conf["Project"] = { @@ -774,22 +775,20 @@ class RecentProjects: self._data = {} cacheFile = self._conf.dataPath(nwFiles.RECENT_FILE) - if not cacheFile.is_file(): - return True - - try: - with open(cacheFile, mode="r", encoding="utf-8") as inFile: - theData = json.load(inFile) - for projPath, theEntry in theData.items(): - self._data[projPath] = { - "title": theEntry.get("title", ""), - "words": theEntry.get("words", 0), - "time": theEntry.get("time", 0), - } - except Exception: - logger.error("Could not load recent project cache") - logException() - return False + if cacheFile.is_file(): + try: + with open(cacheFile, mode="r", encoding="utf-8") as inFile: + data = json.load(inFile) + for path, entry in data.items(): + self._data[path] = { + "title": entry.get("title", ""), + "words": entry.get("words", 0), + "time": entry.get("time", 0), + } + except Exception: + logger.error("Could not load recent project cache") + logException() + return False return True diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 8829a512..f4d3d54b 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -30,6 +30,7 @@ import shutil import logging from typing import Iterable +from pathlib import Path from functools import partial from PyQt5.QtCore import QCoreApplication @@ -39,6 +40,7 @@ from novelwriter.common import minmax, simplified from novelwriter.constants import nwItemClass from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject +from novelwriter.core.storage import NWStorageCreate logger = logging.getLogger(__name__) @@ -310,55 +312,69 @@ class ProjectBuilder: """ def __init__(self) -> None: + self._path = None self.tr = partial(QCoreApplication.translate, "NWProject") return + @property + def projPath(self) -> Path | None: + """The path of the newly created project.""" + return self._path + ## # Methods ## def buildProject(self, data: dict) -> bool: - """Build a project from a data dictionary of specifications - provided by the wizard. - """ - if not isinstance(data, dict): - logger.error("Invalid call to newProject function") - return False + """Build or copy a project from a data dictionary.""" + if isinstance(data, dict): + path = data.get("path", None) or None + if isinstance(path, (str, Path)): + self._path = Path(path).resolve() + if data.get("sample", False): + return self._extractSampleProject(self._path) + elif data.get("template"): # pragma: no cover + # Not implemented yet + return True + else: + return self._buildAndPopulate(self._path, data) + SHARED.error("A project path is required.") + return False - popMinimal = data.get("popMinimal", True) - popCustom = data.get("popCustom", False) - popSample = data.get("popSample", False) - - # Check if we're extracting the sample project. This is handled - # differently as it isn't actually a new project, so we forward - # this to another function and return here. - if popSample: - return self._extractSampleProject(data) - - projPath = data.get("projPath", None) - if projPath is None: - logger.error("No project path set for the new project") - return False + ## + # Internal Functions + ## + def _buildAndPopulate(self, path: Path, data: dict) -> bool: + """Build a blank project from a data dictionary.""" project = NWProject() - if not project.storage.createNewProject(projPath): + status = project.storage.createNewProject(path) + if status == NWStorageCreate.NOT_EMPTY: + SHARED.error(self.tr( + "A project already exists in that location. " + "Please choose another folder." + )) return False + elif status == NWStorageCreate.OS_ERROR: + SHARED.error(self.tr( + "An error occurred while trying to create the project." + ), exc=project.storage.exc) + return False + + self._path = project.storage.storagePath lblNewProject = self.tr("New Project") - lblNewChapter = self.tr("New Chapter") - lblNewScene = self.tr("New Scene") lblTitlePage = self.tr("Title Page") lblByAuthors = self.tr("By") # Settings - projName = data.get("projName", lblNewProject) - projTitle = data.get("projTitle", lblNewProject) - projAuthor = data.get("projAuthor", "") - projLang = data.get("projLang", "en_GB") + projName = data.get("name", lblNewProject) + projAuthor = data.get("author", "") + projLang = data.get("language", "en_GB") project.data.setUuid(None) project.data.setName(projName) - project.data.setTitle(projTitle) + project.data.setTitle(projName) project.data.setAuthor(projAuthor) project.data.setLanguage(projLang) project.setDefaultStatusImport() @@ -376,116 +392,85 @@ class ProjectBuilder: aDoc = project.storage.getDocument(hTitlePage) aDoc.writeDocument(titlePage) - if popMinimal: - # Creating a minimal project with a few root folders and a - # single chapter with a single scene. - hChapter = project.newFile(lblNewChapter, hNovelRoot) - aDoc = project.storage.getDocument(hChapter) - aDoc.writeDocument(f"## {lblNewChapter}\n\n") + # Create a project structure based on selected root folders + # and a number of chapters and scenes selected in the + # wizard's custom page. - if hChapter: - hScene = project.newFile(lblNewScene, hChapter) - aDoc = project.storage.getDocument(hScene) - aDoc.writeDocument(f"### {lblNewScene}\n\n") + # Create chapters and scenes + numChapters = data.get("chapters", 0) + numScenes = data.get("scenes", 0) - project.newRoot(nwItemClass.PLOT) - project.newRoot(nwItemClass.CHARACTER) - project.newRoot(nwItemClass.WORLD) - project.newRoot(nwItemClass.ARCHIVE) + chSynop = self.tr("Summary of the chapter.") + scSynop = self.tr("Summary of the scene.") + bfNote = self.tr("A short description.") - project.saveProject() - project.closeProject() + # Create chapters + if numChapters > 0: + for ch in range(numChapters): + chTitle = self.tr("Chapter {0}").format(f"{ch+1:d}") + cHandle = project.newFile(chTitle, hNovelRoot) + aDoc = project.storage.getDocument(cHandle) + aDoc.writeDocument(f"## {chTitle}\n\n%Synopsis: {chSynop}\n\n") - elif popCustom: - # Create a project structure based on selected root folders - # and a number of chapters and scenes selected in the - # wizard's custom page. + # Create chapter scenes + if numScenes > 0 and cHandle: + for sc in range(numScenes): + scTitle = self.tr("Scene {0}").format(f"{ch+1:d}.{sc+1:d}") + sHandle = project.newFile(scTitle, cHandle) + aDoc = project.storage.getDocument(sHandle) + aDoc.writeDocument(f"### {scTitle}\n\n%Synopsis: {scSynop}\n\n") - # Create chapters and scenes - numChapters = data.get("numChapters", 0) - numScenes = data.get("numScenes", 0) + # Create scenes (no chapters) + elif numScenes > 0: + for sc in range(numScenes): + scTitle = self.tr("Scene {0}").format(f"{sc+1:d}") + sHandle = project.newFile(scTitle, hNovelRoot) + aDoc = project.storage.getDocument(sHandle) + aDoc.writeDocument(f"### {scTitle}\n\n%Synopsis: {scSynop}\n\n") - chSynop = self.tr("Summary of the chapter.") - scSynop = self.tr("Summary of the scene.") - bfNote = self.tr("A short description.") + # Create notes folders + noteTitles = { + nwItemClass.PLOT: self.tr("Main Plot"), + nwItemClass.CHARACTER: self.tr("Protagonist"), + nwItemClass.WORLD: self.tr("Main Location"), + } - # Create chapters - if numChapters > 0: - for ch in range(numChapters): - chTitle = self.tr("Chapter {0}").format(f"{ch+1:d}") - cHandle = project.newFile(chTitle, hNovelRoot) - aDoc = project.storage.getDocument(cHandle) - aDoc.writeDocument(f"## {chTitle}\n\n% Synopsis: {chSynop}\n\n") + addNotes = data.get("notes", False) + for newRoot in data.get("roots", []): + if newRoot in nwItemClass: + rHandle = project.newRoot(newRoot) + if addNotes: + aHandle = project.newFile(noteTitles[newRoot], rHandle) + ntTag = simplified(noteTitles[newRoot]).replace(" ", "") + aDoc = project.storage.getDocument(aHandle) + aDoc.writeDocument( + f"# {noteTitles[newRoot]}\n\n" + f"@tag: {ntTag}\n\n" + f"%Short: {bfNote}\n\n" + ) - # Create chapter scenes - if numScenes > 0 and cHandle: - for sc in range(numScenes): - scTitle = self.tr("Scene {0}").format(f"{ch+1:d}.{sc+1:d}") - sHandle = project.newFile(scTitle, cHandle) - aDoc = project.storage.getDocument(sHandle) - aDoc.writeDocument(f"### {scTitle}\n\n% Synopsis: {scSynop}\n\n") + # Also add the archive and trash folders + project.newRoot(nwItemClass.ARCHIVE) + project.trashFolder() - # Create scenes (no chapters) - elif numScenes > 0: - for sc in range(numScenes): - scTitle = self.tr("Scene {0}").format(f"{sc+1:d}") - sHandle = project.newFile(scTitle, hNovelRoot) - aDoc = project.storage.getDocument(sHandle) - aDoc.writeDocument(f"### {scTitle}\n\n% Synopsis: {scSynop}\n\n") - - # Create notes folders - noteTitles = { - nwItemClass.PLOT: self.tr("Main Plot"), - nwItemClass.CHARACTER: self.tr("Protagonist"), - nwItemClass.WORLD: self.tr("Main Location"), - } - - addNotes = data.get("addNotes", False) - for newRoot in data.get("addRoots", []): - if newRoot in nwItemClass: - rHandle = project.newRoot(newRoot) - if addNotes: - aHandle = project.newFile(noteTitles[newRoot], rHandle) - ntTag = simplified(noteTitles[newRoot]).replace(" ", "") - aDoc = project.storage.getDocument(aHandle) - aDoc.writeDocument( - f"# {noteTitles[newRoot]}\n\n" - f"@tag: {ntTag}\n\n" - f"% Short: {bfNote}\n\n" - ) - - # Also add the archive and trash folders - project.newRoot(nwItemClass.ARCHIVE) - project.trashFolder() - - project.saveProject() - project.closeProject() + project.saveProject() + project.closeProject() return True - ## - # Internal Functions - ## - - def _extractSampleProject(self, data: dict) -> bool: + def _extractSampleProject(self, path: Path) -> bool: """Make a copy of the sample project by extracting the sample.zip file to the new path. """ - projPath = data.get("projPath", None) - if projPath is None: - logger.error("No project path set for the example project") - return False - pkgSample = CONFIG.assetPath("sample.zip") if pkgSample.is_file(): try: - shutil.unpack_archive(pkgSample, projPath) + shutil.unpack_archive(pkgSample, path) except Exception as exc: SHARED.error(self.tr( "Failed to create a new example project." ), exc=exc) return False - else: SHARED.error(self.tr( "Failed to create a new example project. " diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index a219cfc1..90364dfe 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -42,7 +42,7 @@ logger = logging.getLogger(__name__) NWEnum = TypeVar("NWEnum", bound=Enum) -VALID_MAP = { +VALID_MAP: dict[str, set[str]] = { "GuiWritingStats": { "winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2", "widthCol3", "sortCol", "sortOrder", "incNovel", "incNotes", diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py index dd707a3b..0d60b0a8 100644 --- a/novelwriter/core/storage.py +++ b/novelwriter/core/storage.py @@ -57,6 +57,15 @@ class NWStorageOpen(Enum): # END Enum NWStorageOpen +class NWStorageCreate(Enum): + + NOT_EMPTY = 0 + OS_ERROR = 1 + READY = 2 + +# END Enum NWStorageCreate + + class NWStorage: """Core: Project Storage Class @@ -133,12 +142,12 @@ class NWStorage: """Check if the storage location is open.""" return self._ready and self._runtimePath is not None - def createNewProject(self, path: str | Path) -> bool: + def createNewProject(self, path: str | Path) -> NWStorageCreate: """Create a new project at the given location.""" inPath = Path(path).resolve() if inPath.is_dir() and len(list(inPath.iterdir())) > 0: logger.error("Folder is not empty: %s", inPath) - return False + return NWStorageCreate.NOT_EMPTY self._storagePath = inPath self._runtimePath = inPath @@ -156,11 +165,11 @@ class NWStorage: self._exception = exc logger.error("Failed to create project folders", exc_info=exc) self.clear() - return False + return NWStorageCreate.OS_ERROR self._ready = True - return True + return NWStorageCreate.READY def initProjectStorage(self, path: str | Path, clearLock: bool = False) -> NWStorageOpen: """Initialise a novelWriter project location.""" diff --git a/novelwriter/dialogs/projload.py b/novelwriter/dialogs/projload.py deleted file mode 100644 index 4d48c19d..00000000 --- a/novelwriter/dialogs/projload.py +++ /dev/null @@ -1,294 +0,0 @@ -""" -novelWriter – GUI Open Project -============================== - -File History: -Created: 2020-02-26 [0.4.5] GuiProjectLoad - -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 typing import TYPE_CHECKING -from pathlib import Path -from datetime import datetime - -from PyQt5.QtGui import QCloseEvent, QKeySequence -from PyQt5.QtCore import Qt, QSize, pyqtSlot -from PyQt5.QtWidgets import ( - QDialog, QHBoxLayout, QVBoxLayout, QGridLayout, QPushButton, QTreeWidget, - QAbstractItemView, QTreeWidgetItem, QDialogButtonBox, QLabel, QShortcut, - QFileDialog, QLineEdit -) - -from novelwriter import CONFIG, SHARED -from novelwriter.common import formatInt -from novelwriter.constants import nwFiles - -if TYPE_CHECKING: # pragma: no cover - from novelwriter.guimain import GuiMain - -logger = logging.getLogger(__name__) - - -class GuiProjectLoad(QDialog): - - NONE_STATE = 0 - NEW_STATE = 1 - OPEN_STATE = 2 - - C_NAME = 0 - C_COUNT = 1 - C_TIME = 2 - - D_PATH = Qt.ItemDataRole.UserRole - - def __init__(self, mainGui: GuiMain) -> None: - super().__init__(parent=mainGui) - - logger.debug("Create: GuiProjectLoad") - self.setObjectName("GuiProjectLoad") - - self.openState = self.NONE_STATE - self.openPath = None - - sPx = CONFIG.pxInt(16) - nPx = CONFIG.pxInt(96) - iPx = SHARED.theme.baseIconSize - - self.outerBox = QVBoxLayout() - self.innerBox = QHBoxLayout() - self.outerBox.setSpacing(sPx) - self.innerBox.setSpacing(sPx) - - self.setWindowTitle(self.tr("Open Project")) - self.setMinimumWidth(CONFIG.pxInt(650)) - self.setMinimumHeight(CONFIG.pxInt(400)) - - self.nwIcon = QLabel() - self.nwIcon.setPixmap(SHARED.theme.getPixmap("novelwriter", (nPx, nPx))) - self.innerBox.addWidget(self.nwIcon, 0, Qt.AlignTop) - - self.projectForm = QGridLayout() - self.projectForm.setContentsMargins(0, 0, 0, 0) - - self.listBox = QTreeWidget() - self.listBox.setSelectionMode(QAbstractItemView.SingleSelection) - self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop) - self.listBox.setColumnCount(3) - self.listBox.setHeaderLabels([ - self.tr("Working Title"), - self.tr("Words"), - self.tr("Last Opened"), - ]) - self.listBox.setRootIsDecorated(False) - self.listBox.itemSelectionChanged.connect(self._doSelectRecent) - self.listBox.itemDoubleClicked.connect(self._doOpenRecent) - self.listBox.setIconSize(QSize(iPx, iPx)) - - treeHead = self.listBox.headerItem() - if treeHead: - treeHead.setTextAlignment(self.C_COUNT, Qt.AlignRight) - treeHead.setTextAlignment(self.C_TIME, Qt.AlignRight) - - self.lblRecent = QLabel("%s" % self.tr("Recently Opened Projects")) - self.lblPath = QLabel("%s" % self.tr("Path")) - self.selPath = QLineEdit("") - self.selPath.setReadOnly(True) - - self.browseButton = QPushButton(SHARED.theme.getIcon("browse"), "", self) - self.browseButton.clicked.connect(self._doBrowse) - - self.projectForm.addWidget(self.lblRecent, 0, 0, 1, 3) - self.projectForm.addWidget(self.listBox, 1, 0, 1, 3) - self.projectForm.addWidget(self.lblPath, 2, 0, 1, 1) - self.projectForm.addWidget(self.selPath, 2, 1, 1, 1) - self.projectForm.addWidget(self.browseButton, 2, 2, 1, 1) - self.projectForm.setColumnStretch(0, 0) - self.projectForm.setColumnStretch(1, 1) - self.projectForm.setColumnStretch(2, 0) - self.projectForm.setVerticalSpacing(CONFIG.pxInt(4)) - self.projectForm.setHorizontalSpacing(CONFIG.pxInt(8)) - - self.innerBox.addLayout(self.projectForm) - - self.buttonBox = QDialogButtonBox(QDialogButtonBox.Open | QDialogButtonBox.Cancel) - self.buttonBox.accepted.connect(self._doOpenRecent) - self.buttonBox.rejected.connect(self._doCancel) - - self.newButton = self.buttonBox.addButton(self.tr("New"), QDialogButtonBox.ActionRole) - self.newButton.clicked.connect(self._doNewProject) - - self.delButton = self.buttonBox.addButton(self.tr("Remove"), QDialogButtonBox.ActionRole) - self.delButton.clicked.connect(self._doDeleteRecent) - - self.outerBox.addLayout(self.innerBox) - self.outerBox.addWidget(self.buttonBox) - self.setLayout(self.outerBox) - - self._populateList() - self._doSelectRecent() - - keyDelete = QShortcut(self.listBox) - keyDelete.setKey(QKeySequence(Qt.Key_Delete)) - keyDelete.activated.connect(self._doDeleteRecent) - - logger.debug("Ready: GuiProjectLoad") - - return - - def __del__(self) -> None: # pragma: no cover - logger.debug("Delete: GuiProjectLoad") - return - - ## - # Private Slots - ## - - @pyqtSlot() - def _doOpenRecent(self) -> None: - """Close the dialog window with a recent project selected.""" - self._saveSettings() - - self.openPath = None - self.openState = self.NONE_STATE - - selItems = self.listBox.selectedItems() - if selItems: - self.openPath = selItems[0].data(self.C_NAME, self.D_PATH) - self.openState = self.OPEN_STATE - self.accept() - - return - - @pyqtSlot() - def _doSelectRecent(self) -> None: - """Update path when a recent item has been selected.""" - selList = self.listBox.selectedItems() - if selList: - self.selPath.setText(selList[0].data(self.C_NAME, self.D_PATH)) - return - - @pyqtSlot() - def _doBrowse(self) -> None: - """Browse for a folder path.""" - extFilter = [ - self.tr("novelWriter Project File ({0})").format(nwFiles.PROJ_FILE), - self.tr("All files ({0})").format("*"), - ] - projFile, _ = QFileDialog.getOpenFileName( - self, self.tr("Open Project"), "", filter=";;".join(extFilter) - ) - if projFile: - thePath = Path(projFile).absolute() - self.selPath.setText(str(thePath)) - self.openPath = thePath - self.openState = self.OPEN_STATE - self.accept() - - return - - @pyqtSlot() - def _doCancel(self) -> None: - """Close the dialog window without doing anything.""" - self.openPath = None - self.openState = self.NONE_STATE - self.close() - return - - @pyqtSlot() - def _doNewProject(self) -> None: - """Create a new project.""" - self._saveSettings() - self.openPath = None - self.openState = self.NEW_STATE - self.accept() - return - - @pyqtSlot() - def _doDeleteRecent(self) -> None: - """Remove an entry from the recent projects list.""" - selList = self.listBox.selectedItems() - if selList: - projName = selList[0].text(self.C_NAME) - msgYes = SHARED.question(self.tr( - "Remove '{0}' from the recent projects list? " - "The project files will not be deleted." - ).format(projName)) - if msgYes: - CONFIG.recentProjects.remove( - selList[0].data(self.C_NAME, self.D_PATH) - ) - self._populateList() - - return - - ## - # Events - ## - - def closeEvent(self, event: QCloseEvent) -> None: - """Capture the user closing the dialog and save settings.""" - self._saveSettings() - event.accept() - return - - ## - # Internal Functions - ## - - def _saveSettings(self) -> None: - """Save the changes made to the dialog.""" - colWidths = [0, 0, 0] - colWidths[self.C_NAME] = self.listBox.columnWidth(self.C_NAME) - colWidths[self.C_COUNT] = self.listBox.columnWidth(self.C_COUNT) - colWidths[self.C_TIME] = self.listBox.columnWidth(self.C_TIME) - CONFIG.setProjLoadColWidths(colWidths) - return - - def _populateList(self) -> None: - """Populate the list box with recent project data.""" - self.listBox.clear() - dataList = CONFIG.recentProjects.listEntries() - sortList = sorted(dataList, key=lambda x: x[3], reverse=True) - nwxIcon = SHARED.theme.getIcon("proj_nwx") - for path, title, words, time in sortList: - newItem = QTreeWidgetItem([""]*4) - newItem.setIcon(self.C_NAME, nwxIcon) - newItem.setText(self.C_NAME, title) - newItem.setData(self.C_NAME, self.D_PATH, path) - newItem.setText(self.C_COUNT, formatInt(words)) - newItem.setText(self.C_TIME, datetime.fromtimestamp(time).strftime("%x %X")) - newItem.setTextAlignment(self.C_NAME, Qt.AlignLeft | Qt.AlignVCenter) - newItem.setTextAlignment(self.C_COUNT, Qt.AlignRight | Qt.AlignVCenter) - newItem.setTextAlignment(self.C_TIME, Qt.AlignRight | Qt.AlignVCenter) - newItem.setFont(self.C_TIME, SHARED.theme.guiFontFixed) - self.listBox.addTopLevelItem(newItem) - - self.listBox.setCurrentItem(self.listBox.topLevelItem(0)) - - projColWidth = CONFIG.projLoadColWidths - if len(projColWidth) == 3: - self.listBox.setColumnWidth(self.C_NAME, projColWidth[self.C_NAME]) - self.listBox.setColumnWidth(self.C_COUNT, projColWidth[self.C_COUNT]) - self.listBox.setColumnWidth(self.C_TIME, projColWidth[self.C_TIME]) - - return - -# END Class GuiProjectLoad diff --git a/novelwriter/extensions/configlayout.py b/novelwriter/extensions/configlayout.py index 3d23fede..82abaf84 100644 --- a/novelwriter/extensions/configlayout.py +++ b/novelwriter/extensions/configlayout.py @@ -33,6 +33,8 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG FONT_SCALE = 0.9 +RIGHT_TOP = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop +LEFT_TOP = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop class NConfigLayout(QGridLayout): @@ -103,31 +105,31 @@ class NConfigLayout(QGridLayout): labelBox.addWidget(qHelp) labelBox.setSpacing(0) labelBox.addStretch(1) - self.addLayout(labelBox, self._nextRow, 0, 1, 1, Qt.AlignLeft | Qt.AlignTop) + self.addLayout(labelBox, self._nextRow, 0, 1, 1, LEFT_TOP) else: - self.addWidget(qLabel, self._nextRow, 0, 1, 1, Qt.AlignLeft | Qt.AlignTop) + self.addWidget(qLabel, self._nextRow, 0, 1, 1, LEFT_TOP) if isinstance(unit, str): controlBox = QHBoxLayout() controlBox.addWidget(widget, 0, Qt.AlignVCenter) controlBox.addWidget(QLabel(unit), 0, Qt.AlignVCenter) controlBox.setSpacing(wSp) - self.addLayout(controlBox, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop) + self.addLayout(controlBox, self._nextRow, 1, 1, 1, RIGHT_TOP) elif isinstance(button, QAbstractButton): controlBox = QHBoxLayout() controlBox.addWidget(widget, 0, Qt.AlignVCenter) controlBox.addWidget(button, 0, Qt.AlignVCenter) controlBox.setSpacing(wSp) - self.addLayout(controlBox, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop) + self.addLayout(controlBox, self._nextRow, 1, 1, 1, RIGHT_TOP) else: if isinstance(widget, QLineEdit): qLayout = QHBoxLayout() qLayout.addWidget(widget) - self.addLayout(qLayout, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop) + self.addLayout(qLayout, self._nextRow, 1, 1, 1, RIGHT_TOP) else: - self.addWidget(widget, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop) + self.addWidget(widget, self._nextRow, 1, 1, 1, RIGHT_TOP) self.setRowStretch(self._nextRow, 0) self.setRowStretch(self._nextRow+1, 1) @@ -145,14 +147,14 @@ class NSimpleLayout(QGridLayout): column layout. """ - def __init__(self) -> None: + def __init__(self, stretcColumn: int = 0) -> None: super().__init__() self._nextRow = 0 wSp = CONFIG.pxInt(8) self.setHorizontalSpacing(wSp) self.setVerticalSpacing(wSp) - self.setColumnStretch(0, 1) + self.setColumnStretch(stretcColumn, 1) return @@ -176,14 +178,14 @@ class NSimpleLayout(QGridLayout): wSp = CONFIG.pxInt(8) qLabel = QLabel(label) qLabel.setIndent(wSp) - self.addWidget(qLabel, self._nextRow, 0, 1, 1, Qt.AlignLeft | Qt.AlignTop) + self.addWidget(qLabel, self._nextRow, 0, 1, 1, LEFT_TOP) if isinstance(widget, QLineEdit): qLayout = QHBoxLayout() qLayout.addWidget(widget) - self.addLayout(qLayout, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop) + self.addLayout(qLayout, self._nextRow, 1, 1, 1, RIGHT_TOP) else: - self.addWidget(widget, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop) + self.addWidget(widget, self._nextRow, 1, 1, 1, RIGHT_TOP) qLabel.setBuddy(widget) diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 3e5812b9..3ba6c5ca 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -132,14 +132,10 @@ class GuiMainMenu(QMenuBar): # Project self.projMenu = self.addMenu(self.tr("&Project")) - # Project > New Project - self.aNewProject = self.projMenu.addAction(self.tr("New Project")) - self.aNewProject.triggered.connect(lambda: self.mainGui.newProject(None)) - - # Project > Open Project - self.aOpenProject = self.projMenu.addAction(self.tr("Open Project")) + # Project > Create or Open Project + self.aOpenProject = self.projMenu.addAction(self.tr("Create or Open Project")) self.aOpenProject.setShortcut("Ctrl+Shift+O") - self.aOpenProject.triggered.connect(lambda: self.mainGui.showProjectLoadDialog()) + self.aOpenProject.triggered.connect(self.mainGui.showWelcomeDialog) # Project > Save Project self.aSaveProject = self.projMenu.addAction(self.tr("Save Project")) diff --git a/novelwriter/gui/sidebar.py b/novelwriter/gui/sidebar.py index 11f61c27..1026b974 100644 --- a/novelwriter/gui/sidebar.py +++ b/novelwriter/gui/sidebar.py @@ -167,8 +167,7 @@ class _PopRightMenu(QMenu): def event(self, event: QEvent) -> bool: """Overload the show event and move the menu popup location.""" if event.type() == QEvent.Show: - parent = self.parent() - if isinstance(parent, QWidget): + if isinstance(parent := self.parent(), QWidget): offset = QPoint(parent.width(), parent.height() - self.height()) self.move(parent.mapToGlobal(offset)) return super(_PopRightMenu, self).event(event) diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index a5d731bf..c2d8b7b9 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -67,6 +67,7 @@ class GuiTheme: self.themeLicense = "" self.themeLicenseUrl = "" self.themeIcons = "" + self.isLightTheme = True # GUI self.statNone = [120, 120, 120] @@ -112,7 +113,7 @@ class GuiTheme: self._setGuiFont() # Load Themes - self._guiPalette = QPalette() + self._guiPalette = QPalette() self._themeList: list[tuple[str, str]] = [] self._syntaxList: list[tuple[str, str]] = [] self._availThemes: dict[str, Path] = {} @@ -254,9 +255,9 @@ class GuiTheme: backLNess = backCol.lightnessF() textLNess = textCol.lightnessF() - + self.isLightTheme = backLNess > textLNess if self.helpText == [0, 0, 0]: - if backLNess > textLNess: + if self.isLightTheme: helpLCol = textLNess + 0.35*(backLNess - textLNess) else: helpLCol = backLNess + 0.65*(textLNess - backLNess) @@ -443,7 +444,7 @@ class GuiIcons: missing, a blank icon is returned and a warning issued. """ - ICON_KEYS = { + ICON_KEYS: set[str] = { # Project and GUI Icons "novelwriter", "alert_error", "alert_info", "alert_question", "alert_warn", "build_excluded", "build_filtered", "build_included", "proj_chapter", "proj_details", @@ -464,9 +465,10 @@ class GuiIcons: "fmt_subscript", "fmt_superscript", "fmt_underline", # General Button Icons - "add", "backward", "bookmark", "browse", "checked", "close", "cross", "down", "edit", - "export", "forward", "maximise", "menu", "minimise", "noncheckable", "panel", "refresh", - "remove", "revert", "search_replace", "search", "settings", "unchecked", "up", "view", + "add", "add_document", "backward", "bookmark", "browse", "checked", "close", "cross", + "document", "down", "edit", "export", "forward", "maximise", "menu", "minimise", + "noncheckable", "panel", "refresh", "remove", "revert", "search_replace", "search", + "settings", "star", "unchecked", "up", "view", # Switches "sticky-on", "sticky-off", @@ -478,13 +480,14 @@ class GuiIcons: "deco_doc_nt_n", } - TOGGLE_ICON_KEYS = { + TOGGLE_ICON_KEYS: dict[str, tuple[str, str]] = { "sticky": ("sticky-on", "sticky-off"), "bullet": ("bullet-on", "bullet-off"), } - IMAGE_MAP = { - "wiz-back": "wizard-back.jpg", + IMAGE_MAP: dict[str, tuple[str, str]] = { + "welcome": ("welcome-light.jpg", "welcome-dark.jpg"), + "nw-text": ("novelwriter-text-light.svg", "novelwriter-text-dark.svg"), } def __init__(self, mainTheme: GuiTheme) -> None: @@ -598,7 +601,8 @@ class GuiIcons: if name in self._themeMap: imgPath = self._themeMap[name] elif name in self.IMAGE_MAP: - imgPath = CONFIG.assetPath("images") / self.IMAGE_MAP[name] + idx = 0 if self.mainTheme.isLightTheme else 1 + imgPath = CONFIG.assetPath("images") / self.IMAGE_MAP[name][idx] else: logger.error("Decoration with name '%s' does not exist", name) return QPixmap() @@ -608,12 +612,13 @@ class GuiIcons: return QPixmap() pixmap = QPixmap(str(imgPath)) + tMode = Qt.TransformationMode.SmoothTransformation if w is not None and h is not None: - return pixmap.scaled(w, h, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) + return pixmap.scaled(w, h, Qt.AspectRatioMode.IgnoreAspectRatio, tMode) elif w is None and h is not None: - return pixmap.scaledToHeight(h, Qt.SmoothTransformation) + return pixmap.scaledToHeight(h, tMode) elif w is not None and h is None: - return pixmap.scaledToWidth(w, Qt.SmoothTransformation) + return pixmap.scaledToWidth(w, tMode) return pixmap @@ -632,8 +637,8 @@ class GuiIcons: pOne = self.getPixmap(self.TOGGLE_ICON_KEYS[name][0], size) pTwo = self.getPixmap(self.TOGGLE_ICON_KEYS[name][1], size) icon = QIcon() - icon.addPixmap(pOne, QIcon.Normal, QIcon.On) - icon.addPixmap(pTwo, QIcon.Normal, QIcon.Off) + icon.addPixmap(pOne, QIcon.Mode.Normal, QIcon.State.On) + icon.addPixmap(pTwo, QIcon.Mode.Normal, QIcon.State.Off) return icon return QIcon() @@ -641,7 +646,7 @@ class GuiIcons: """Return an icon from the icon buffer as a QPixmap. If it doesn't exist, return an empty QPixmap. """ - return self.getIcon(name).pixmap(size[0], size[1], QIcon.Normal) + return self.getIcon(name).pixmap(size[0], size[1], QIcon.Mode.Normal) def getItemIcon(self, tType: nwItemType, tClass: nwItemClass, tLayout: nwItemLayout, hLevel: str = "H0") -> QIcon: diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index c9b10dec..d7a42247 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -30,11 +30,11 @@ from time import time from pathlib import Path from datetime import datetime -from PyQt5.QtCore import Qt, QTimer, pyqtSlot from PyQt5.QtGui import QCloseEvent, QCursor, QIcon +from PyQt5.QtCore import Qt, QTimer, pyqtSlot from PyQt5.QtWidgets import ( - QDialog, QFileDialog, QHBoxLayout, QMainWindow, QMessageBox, QShortcut, - QSplitter, QStackedWidget, QVBoxLayout, QWidget, qApp + QFileDialog, QHBoxLayout, QMainWindow, QMessageBox, QShortcut, QSplitter, + QStackedWidget, QVBoxLayout, QWidget, qApp ) from novelwriter import CONFIG, SHARED, __hexversion__ @@ -51,22 +51,19 @@ 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 from novelwriter.dialogs.wordlist import GuiWordList from novelwriter.dialogs.preferences import GuiPreferences from novelwriter.dialogs.projdetails import GuiProjectDetails from novelwriter.dialogs.projsettings import GuiProjectSettings +from novelwriter.tools.welcome import GuiWelcome from novelwriter.tools.manuscript import GuiManuscript -from novelwriter.tools.projwizard import GuiProjectWizard from novelwriter.tools.dictionaries import GuiDictionaries from novelwriter.tools.writingstats import GuiWritingStats -from novelwriter.core.coretools import ProjectBuilder from novelwriter.enum import ( - nwDocAction, nwDocInsert, nwDocMode, nwItemType, nwItemClass, nwWidget, nwView + nwDocAction, nwDocInsert, nwDocMode, nwItemType, nwWidget, nwView ) from novelwriter.common import hexToInt -from novelwriter.constants import nwFiles logger = logging.getLogger(__name__) @@ -359,7 +356,7 @@ class GuiMain(QMainWindow): self.openProject(cmdOpen) if not SHARED.hasProject: - self.showProjectLoadDialog() + self.showWelcomeDialog() # Determine whether release notes need to be shown or not if hexToInt(CONFIG.lastNotes) < hexToInt(__hexversion__): @@ -372,42 +369,6 @@ class GuiMain(QMainWindow): # Project Actions ## - def newProject(self, projData: dict | None = None) -> bool: - """Create a new project via the new project wizard.""" - if SHARED.hasProject: - if not self.closeProject(): - SHARED.error(self.tr( - "Cannot create a new project when another project is open." - )) - return False - - if projData is None: - projData = self.showNewProjectDialog() - - if projData is None: - return False - - projPath = projData.get("projPath", None) - if projPath is None or projData is None: - logger.error("No projData or projPath set") - return False - - if (Path(projPath) / nwFiles.PROJ_FILE).is_file(): - SHARED.error(self.tr( - "A project already exists in that location. " - "Please choose another folder." - )) - return False - - logger.info("Creating new project") - nwProject = ProjectBuilder() - if nwProject.buildProject(projData): - self.openProject(projPath) - else: - return False - - return True - def closeProject(self, isYes: bool = False) -> bool: """Close the project if one is open. isYes is passed on from the close application event so the user doesn't get prompted twice @@ -837,33 +798,14 @@ class GuiMain(QMainWindow): # Main Dialogs ## - def showProjectLoadDialog(self) -> None: - """Open the projects dialog for selecting either existing - projects from a cache of recently opened projects, or provide a - browse button for projects not yet cached. Selecting to create a - new project is forwarded to the new project wizard. - """ - dlgProj = GuiProjectLoad(self) - dlgProj.exec_() - - if dlgProj.result() == QDialog.Accepted: - if dlgProj.openState == GuiProjectLoad.OPEN_STATE: - self.openProject(dlgProj.openPath) - elif dlgProj.openState == GuiProjectLoad.NEW_STATE: - self.newProject() - + @pyqtSlot() + def showWelcomeDialog(self) -> None: + """Open the welcome dialog.""" + dialog = GuiWelcome(self) + dialog.openProjectRequest.connect(self._openProject) + dialog.exec_() return - def showNewProjectDialog(self) -> dict | None: - """Open the wizard and assemble a project options dict.""" - newProj = GuiProjectWizard(self) - newProj.exec_() - - if newProj.result() == QDialog.Accepted: - return self._assembleProjectWizardData(newProj) - - return None - @pyqtSlot() def showPreferencesDialog(self) -> None: """Open the preferences dialog.""" @@ -1204,6 +1146,12 @@ class GuiMain(QMainWindow): self.viewDocument(tHandle=tHandle, sTitle=sTitle) return + @pyqtSlot(Path) + def _openProject(self, path: Path) -> None: + """Handle an open project request.""" + self.openProject(path) + return + @pyqtSlot(str, nwDocMode, str, bool) def _openDocument(self, tHandle: str, mode: nwDocMode, sTitle: str, setFocus: bool) -> None: """Handle an open document request.""" @@ -1458,44 +1406,6 @@ class GuiMain(QMainWindow): self.setWindowTitle(winTitle) return - def _assembleProjectWizardData(self, newProj: GuiProjectWizard) -> dict: - """Extract the user choices from the New Project Wizard and - store them in a dictionary. - """ - projData = { - "projName": newProj.field("projName"), - "projTitle": newProj.field("projTitle"), - "projAuthor": newProj.field("projAuthor"), - "projPath": newProj.field("projPath"), - "popSample": newProj.field("popSample"), - "popMinimal": newProj.field("popMinimal"), - "popCustom": newProj.field("popCustom"), - "addRoots": [], - "addNotes": False, - "numChapters": 0, - "numScenes": 0, - } - if newProj.field("popCustom"): - addRoots = [] - if newProj.field("addPlot"): - addRoots.append(nwItemClass.PLOT) - if newProj.field("addChar"): - addRoots.append(nwItemClass.CHARACTER) - if newProj.field("addWorld"): - addRoots.append(nwItemClass.WORLD) - projData["addRoots"] = addRoots - projData["addNotes"] = newProj.field("addNotes") - projData["numChapters"] = newProj.field("numChapters") - projData["numScenes"] = newProj.field("numScenes") - - try: - langIdx = newProj.field("projLang") - projData["projLang"] = CONFIG.listLanguages(CONFIG.LANG_PROJ)[langIdx][0] - except Exception: - projData["projLang"] = "en_GB" - - return projData - def _getTagSource(self, tag: str) -> tuple[str | None, str | None]: """Handle the index lookup of a tag and display an alert if the tag cannot be found. diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 7df04ec6..df8ca498 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -31,8 +31,9 @@ from typing import TYPE_CHECKING from pathlib import Path from PyQt5.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal -from PyQt5.QtWidgets import QMessageBox, QWidget +from PyQt5.QtWidgets import QFileDialog, QMessageBox, QWidget +from novelwriter.constants import nwFiles from novelwriter.core.spellcheck import NWSpellEnchant if TYPE_CHECKING: # pragma: no cover @@ -215,6 +216,20 @@ class SharedData(QObject): QThreadPool.globalInstance().start(runnable, priority=priority) return + def getProjectPath(self, parent: QWidget, path: str | Path | None = None, + allowZip: bool = False) -> Path | None: + """Open the file dialog and select a novelWriter project file.""" + ext = [ + self.tr("novelWriter Project File ({0})").format(nwFiles.PROJ_FILE), + self.tr("All files ({0})").format("*"), + ] + if allowZip: + ext.insert(1, self.tr("Zip Archives ({0})").format("*.zip")) + projFile, _ = QFileDialog.getOpenFileName( + parent, self.tr("Open Project"), str(path or ""), filter=";;".join(ext) + ) + return Path(projFile) if projFile else None + ## # Signal Proxy ## diff --git a/novelwriter/tools/projwizard.py b/novelwriter/tools/projwizard.py deleted file mode 100644 index b819435f..00000000 --- a/novelwriter/tools/projwizard.py +++ /dev/null @@ -1,478 +0,0 @@ -""" -novelWriter – GUI New Project Wizard -==================================== - -File History: -Created: 2020-07-11 [0.10.1] GuiProjectWizard - -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 os -import logging - -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( - QComboBox, QFileDialog, QFormLayout, QGridLayout, QHBoxLayout, QLabel, - QLineEdit, QPushButton, QRadioButton, QSpinBox, QVBoxLayout, QWizard, - QWizardPage -) - -from novelwriter import CONFIG, SHARED -from novelwriter.common import makeFileNameSafe -from novelwriter.extensions.switch import NSwitch - -logger = logging.getLogger(__name__) - -PAGE_INTRO = 0 -PAGE_STORE = 1 -PAGE_POP = 2 -PAGE_CUSTOM = 3 -PAGE_FINAL = 4 - - -class GuiProjectWizard(QWizard): - - def __init__(self, mainGui): - super().__init__(parent=mainGui) - - logger.debug("Create: GuiProjectWizard") - self.setObjectName("GuiProjectWizard") - - self.mainGui = mainGui - - self.sideImage = SHARED.theme.loadDecoration( - "wiz-back", None, CONFIG.pxInt(370) - ) - self.setWizardStyle(QWizard.ModernStyle) - self.setPixmap(QWizard.WatermarkPixmap, self.sideImage) - - self.introPage = ProjWizardIntroPage(self) - self.storagePage = ProjWizardFolderPage(self) - self.popPage = ProjWizardPopulatePage(self) - self.customPage = ProjWizardCustomPage(self) - self.finalPage = ProjWizardFinalPage(self) - - self.setPage(PAGE_INTRO, self.introPage) - self.setPage(PAGE_STORE, self.storagePage) - self.setPage(PAGE_POP, self.popPage) - self.setPage(PAGE_CUSTOM, self.customPage) - self.setPage(PAGE_FINAL, self.finalPage) - - self.setOption(QWizard.NoBackButtonOnStartPage, True) - - logger.debug("Ready: GuiProjectWizard") - - return - - def __del__(self) -> None: # pragma: no cover - logger.debug("Delete: GuiProjectWizard") - return - -# END Class GuiProjectWizard - - -class ProjWizardIntroPage(QWizardPage): - - def __init__(self, theWizard): - super().__init__() - - self.setTitle(self.tr("Create New Project")) - self.theText = QLabel(self.tr( - "Provide at least a project name. The project name should not " - "be changed beyond this point as it is used for generating file " - "names for for instance backups. The other fields are optional " - "and can be changed at any time in Project Settings." - )) - self.theText.setWordWrap(True) - - self.imgCredit = QLabel(self.tr("Side image by {0}, {1}").format( - "Peter Mitterhofer", "CC BY-SA 4.0" - )) - lblFont = self.imgCredit.font() - lblFont.setPointSizeF(0.6*SHARED.theme.fontPointSize) - self.imgCredit.setFont(lblFont) - - xW = CONFIG.pxInt(300) - vS = CONFIG.pxInt(12) - fS = CONFIG.pxInt(4) - - # The Page Form - self.projName = QLineEdit() - self.projName.setMaxLength(200) - self.projName.setFixedWidth(xW) - self.projName.setPlaceholderText(self.tr("Required")) - - self.projTitle = QLineEdit() - self.projTitle.setMaxLength(200) - self.projTitle.setFixedWidth(xW) - self.projTitle.setPlaceholderText(self.tr("Optional")) - - self.projAuthor = QLineEdit() - self.projAuthor.setMaxLength(200) - self.projAuthor.setFixedWidth(xW) - self.projAuthor.setPlaceholderText(self.tr("Optional")) - - self.projLang = QComboBox(self) - self.projLang.setMaximumWidth(xW) - for tag, language in CONFIG.listLanguages(CONFIG.LANG_PROJ): - self.projLang.addItem(language, tag) - - langIdx = self.projLang.findData(CONFIG.guiLocale) - if langIdx == -1: - langIdx = self.projLang.findData("en_GB") - if langIdx != -1: - self.projLang.setCurrentIndex(langIdx) - - self.mainForm = QFormLayout() - self.mainForm.addRow(self.tr("Project Name"), self.projName) - self.mainForm.addRow(self.tr("Novel Title"), self.projTitle) - self.mainForm.addRow(self.tr("Author(s)"), self.projAuthor) - self.mainForm.addRow(self.tr("Language"), self.projLang) - self.mainForm.setVerticalSpacing(fS) - - self.registerField("projName*", self.projName) - self.registerField("projTitle", self.projTitle) - self.registerField("projAuthor", self.projAuthor) - self.registerField("projLang", self.projLang) - - # Assemble - self.outerBox = QVBoxLayout() - self.outerBox.setSpacing(vS) - self.outerBox.addWidget(self.theText) - self.outerBox.addLayout(self.mainForm) - self.outerBox.addStretch(1) - self.outerBox.addWidget(self.imgCredit) - self.setLayout(self.outerBox) - - return - -# END Class ProjWizardIntroPage - - -class ProjWizardFolderPage(QWizardPage): - - def __init__(self, theWizard): - super().__init__() - - self.setTitle(self.tr("Select Project Folder")) - self.theText = QLabel(self.tr( - "Select a location to store the project. A new project folder " - "will be created in the selected location." - )) - self.theText.setWordWrap(True) - - xW = CONFIG.pxInt(300) - vS = CONFIG.pxInt(12) - fS = CONFIG.pxInt(8) - - self.projPath = QLineEdit("") - self.projPath.setFixedWidth(xW) - self.projPath.setPlaceholderText(self.tr("Required")) - - self.browseButton = QPushButton("...") - self.browseButton.setMaximumWidth(int(2.5*SHARED.theme.getTextWidth("..."))) - self.browseButton.clicked.connect(self._doBrowse) - - self.errLabel = QLabel("") - self.errLabel.setWordWrap(True) - - self.mainForm = QHBoxLayout() - self.mainForm.addWidget(QLabel(self.tr("Project Path")), 0) - self.mainForm.addWidget(self.projPath, 1) - self.mainForm.addWidget(self.browseButton, 0) - self.mainForm.setSpacing(fS) - - self.registerField("projPath*", self.projPath) - - # Assemble - self.outerBox = QVBoxLayout() - self.outerBox.setSpacing(vS) - self.outerBox.addWidget(self.theText) - self.outerBox.addLayout(self.mainForm) - self.outerBox.addWidget(self.errLabel) - self.outerBox.addStretch(1) - self.setLayout(self.outerBox) - - return - - def isComplete(self): - """Check that the selected path isn't already being used. - """ - self.errLabel.setText("") - if not super().isComplete(): - return False - - setPath = os.path.abspath(os.path.expanduser(self.projPath.text())) - parPath = os.path.dirname(setPath) - logger.debug("Path is: %s", setPath) - if parPath and not os.path.isdir(parPath): - self.errLabel.setText(self.tr( - "Error: A project folder cannot be created using this path." - )) - return False - - if os.path.exists(setPath): - self.errLabel.setText(self.tr( - "Error: The selected path already exists." - )) - return False - - return True - - ## - # Slots - ## - - def _doBrowse(self): - """Select a project folder. - """ - lastPath = CONFIG.lastPath() - projDir = QFileDialog.getExistingDirectory( - self, self.tr("Select Project Folder"), str(lastPath), options=QFileDialog.ShowDirsOnly - ) - if projDir: - projName = self.field("projName") - if projName is not None: - fullDir = os.path.join(os.path.abspath(projDir), makeFileNameSafe(projName)) - self.projPath.setText(fullDir) - else: - self.projPath.setText("") - - return - -# END Class ProjWizardFolderPage - - -class ProjWizardPopulatePage(QWizardPage): - - def __init__(self, theWizard): - super().__init__() - - self.setTitle(self.tr("Populate Project")) - self.theText = QLabel(self.tr( - "Choose how to pre-fill the project. Either with a minimal set of " - "starter items, an example project explaining and showing many of " - "the features, or show further custom options on the next page." - )) - self.theText.setWordWrap(True) - - vS = CONFIG.pxInt(12) - fS = CONFIG.pxInt(4) - - self.popMinimal = QRadioButton(self.tr("Fill the project with a minimal set of items")) - self.popSample = QRadioButton(self.tr("Fill the project with example files")) - self.popCustom = QRadioButton(self.tr("Show detailed options for filling the project")) - self.popMinimal.setChecked(True) - - self.popBox = QVBoxLayout() - self.popBox.setSpacing(fS) - self.popBox.addWidget(self.popMinimal) - self.popBox.addWidget(self.popSample) - self.popBox.addWidget(self.popCustom) - - self.registerField("popMinimal", self.popMinimal) - self.registerField("popSample", self.popSample) - self.registerField("popCustom", self.popCustom) - - # Assemble - self.outerBox = QVBoxLayout() - self.outerBox.setSpacing(vS) - self.outerBox.addWidget(self.theText) - self.outerBox.addLayout(self.popBox) - self.outerBox.addStretch(1) - self.setLayout(self.outerBox) - - return - - def nextId(self): - """Overload the nextID function to skip further pages if custom - is not selected. - """ - if self.popCustom.isChecked(): - return PAGE_CUSTOM - else: - return PAGE_FINAL - -# END Class ProjWizardPopulatePage - - -class ProjWizardCustomPage(QWizardPage): - - def __init__(self, theWizard): - super().__init__() - - self.setTitle(self.tr("Custom Project Options")) - self.theText = QLabel(self.tr( - "Select which additional elements to populate the project with. " - "You can skip making chapters and add only scenes by setting the " - "number of chapters to 0." - )) - self.theText.setWordWrap(True) - - cM = CONFIG.pxInt(12) - mH = CONFIG.pxInt(26) - fS = CONFIG.pxInt(4) - - # Root Folders - self.addPlot = NSwitch() - self.addPlot.setChecked(True) - self.addPlot.clicked.connect(self._syncSwitches) - - self.addChar = NSwitch() - self.addChar.setChecked(True) - self.addChar.clicked.connect(self._syncSwitches) - - self.addWorld = NSwitch() - self.addWorld.setChecked(False) - self.addWorld.clicked.connect(self._syncSwitches) - - self.addNotes = NSwitch() - self.addNotes.setChecked(False) - - # Generate Content - self.numChapters = QSpinBox() - self.numChapters.setRange(0, 100) - self.numChapters.setValue(5) - - self.numScenes = QSpinBox() - self.numScenes.setRange(0, 200) - self.numScenes.setValue(5) - - # Grid Form - self.addBox = QGridLayout() - self.addBox.addWidget(QLabel(self.tr("Add a folder for plot notes")), 0, 0) - self.addBox.addWidget(QLabel(self.tr("Add a folder for character notes")), 1, 0) - self.addBox.addWidget(QLabel(self.tr("Add a folder for location notes")), 2, 0) - self.addBox.addWidget(QLabel(self.tr("Add example notes to the above")), 3, 0) - self.addBox.addWidget(QLabel(self.tr("Add chapters to the novel folder")), 4, 0) - self.addBox.addWidget(QLabel(self.tr("Add scenes to each chapter")), 5, 0) - self.addBox.addWidget(self.addPlot, 0, 1, 1, 1, Qt.AlignRight) - self.addBox.addWidget(self.addChar, 1, 1, 1, 1, Qt.AlignRight) - self.addBox.addWidget(self.addWorld, 2, 1, 1, 1, Qt.AlignRight) - self.addBox.addWidget(self.addNotes, 3, 1, 1, 1, Qt.AlignRight) - self.addBox.addWidget(self.numChapters, 4, 1, 1, 1, Qt.AlignRight) - self.addBox.addWidget(self.numScenes, 5, 1, 1, 1, Qt.AlignRight) - self.addBox.setVerticalSpacing(fS) - self.addBox.setHorizontalSpacing(cM) - self.addBox.setContentsMargins(cM, 0, cM, 0) - self.addBox.setColumnStretch(2, 1) - for i in range(6): - self.addBox.setRowMinimumHeight(i, mH) - - # Wizard Fields - self.registerField("addPlot", self.addPlot) - self.registerField("addChar", self.addChar) - self.registerField("addWorld", self.addWorld) - self.registerField("addNotes", self.addNotes) - self.registerField("numChapters", self.numChapters) - self.registerField("numScenes", self.numScenes) - - # Assemble - self.outerBox = QVBoxLayout() - self.outerBox.setSpacing(cM) - self.outerBox.addWidget(self.theText) - self.outerBox.addLayout(self.addBox) - self.outerBox.addStretch(1) - self.setLayout(self.outerBox) - - return - - ## - # Internal Functions - ## - - def _syncSwitches(self): - """Check if the add notes option should also be switched off. - """ - addPlot = self.addPlot.isChecked() - addChar = self.addChar.isChecked() - addWorld = self.addWorld.isChecked() - if not (addPlot or addChar or addWorld): - self.addNotes.setChecked(False) - return - -# END Class ProjWizardCustomPage - - -class ProjWizardFinalPage(QWizardPage): - - def __init__(self, theWizard): - super().__init__() - - self.setTitle(self.tr("Summary")) - self.theText = QLabel("") - self.theText.setWordWrap(True) - - # Assemble - self.outerBox = QVBoxLayout() - self.outerBox.setSpacing(CONFIG.pxInt(12)) - self.outerBox.addWidget(self.theText) - self.outerBox.addStretch(1) - self.setLayout(self.outerBox) - - return - - def initializePage(self): - """Update the summary information on the final page. - """ - super().initializePage() - - sumList = [] - sumList.append(self.tr("Project Name: {0}").format(self.field("projName"))) - sumList.append(self.tr("Project Path: {0}").format(self.field("projPath"))) - - if self.field("popMinimal"): - sumList.append(self.tr("Fill the project with a minimal set of items")) - elif self.field("popSample"): - sumList.append(self.tr("Fill the project with example files")) - elif self.field("popCustom"): - if self.field("addPlot"): - sumList.append(self.tr("Add a folder for plot notes")) - if self.field("addChar"): - sumList.append(self.tr("Add a folder for character notes")) - if self.field("addWorld"): - sumList.append(self.tr("Add a folder for location notes")) - if self.field("addNotes"): - sumList.append(self.tr("Add example notes to the above")) - if self.field("numChapters") > 0: - sumList.append(self.tr("Add {0} chapters to the novel folder").format( - self.field("numChapters") - )) - if self.field("numScenes") > 0: - sumList.append(self.tr("Add {0} scenes to each chapter").format( - self.field("numScenes") - )) - else: - if self.field("numScenes") > 0: - sumList.append(self.tr("Add {0} scenes").format( - self.field("numScenes") - )) - - self.theText.setText( - "

%s

 • %s

%s

" % ( - self.tr("You have selected the following:"), - "
 • ".join(sumList), - self.tr("Press '{0}' to create the new project.").format( - self.tr("Done") if CONFIG.osDarwin else self.tr("Finish") - ) - ) - ) - return - -# END Class ProjWizardFinalPage diff --git a/novelwriter/tools/welcome.py b/novelwriter/tools/welcome.py new file mode 100644 index 00000000..0d9c38f1 --- /dev/null +++ b/novelwriter/tools/welcome.py @@ -0,0 +1,777 @@ +""" +novelWriter – GUI Welcome Dialog +================================ + +File History: +Created: 2023-12-14 [2.3b1] GuiWelcome + +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 typing import TYPE_CHECKING +from pathlib import Path +from datetime import datetime + +from PyQt5.QtGui import QCloseEvent, QColor, QFont, QPaintEvent, QPainter, QPen +from PyQt5.QtCore import ( + QAbstractListModel, QEvent, QModelIndex, QObject, QPoint, QSize, Qt, + pyqtSignal, pyqtSlot +) +from PyQt5.QtWidgets import ( + QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, + QHBoxLayout, QLabel, QLineEdit, QListView, QMenu, QPushButton, QScrollArea, QShortcut, + QSpinBox, QStackedWidget, QStyle, QStyleOptionViewItem, QStyledItemDelegate, + QToolButton, QVBoxLayout, QWidget, qApp +) + +from novelwriter import CONFIG, SHARED, __version__, __date__ +from novelwriter.enum import nwItemClass +from novelwriter.common import formatInt, makeFileNameSafe +from novelwriter.constants import nwUnicode +from novelwriter.core.coretools import ProjectBuilder +from novelwriter.extensions.switch import NSwitch + +if TYPE_CHECKING: # pragma: no cover + from novelwriter.guimain import GuiMain + +logger = logging.getLogger(__name__) + + +class GuiWelcome(QDialog): + + openProjectRequest = pyqtSignal(Path) + + def __init__(self, mainGui: GuiMain) -> None: + super().__init__(parent=mainGui) + + logger.debug("Create: GuiWelcome") + self.setObjectName("GuiWelcome") + + self.setWindowTitle(self.tr("Welcome")) + self.setMinimumWidth(CONFIG.pxInt(700)) + self.setMinimumHeight(CONFIG.pxInt(400)) + + hA = CONFIG.pxInt(8) + hB = CONFIG.pxInt(16) + hC = CONFIG.pxInt(24) + hD = CONFIG.pxInt(36) + hE = CONFIG.pxInt(48) + hF = CONFIG.pxInt(96) + + self.resize(*CONFIG.welcomeWinSize) + + # Elements + # ======== + + self.bgImage = SHARED.theme.loadDecoration("welcome") + self.nwImage = SHARED.theme.loadDecoration("nw-text", h=hD) + self.bgColor = QColor(255, 255, 255) if SHARED.theme.isLightTheme else QColor(54, 54, 54) + + self.nwLogo = QLabel() + self.nwLogo.setPixmap(SHARED.theme.getPixmap("novelwriter", (hF, hF))) + + self.nwLabel = QLabel("novelWriter") + self.nwLabel.setPixmap(self.nwImage) + + self.nwInfo = QLabel(self.tr("Version {0} {1} Released on {2}").format( + __version__, nwUnicode.U_ENDASH, datetime.strptime(__date__, "%Y-%m-%d").strftime("%x") + )) + + self.tabOpen = _OpenProjectPage(self) + self.tabOpen.openProjectRequest.connect(self._openProjectPath) + + self.tabNew = _NewProjectPage(self) + self.tabNew.cancelNewProject.connect(self._showOpenProjectPage) + self.tabNew.openProjectRequest.connect(self._openProjectPath) + + self.mainStack = QStackedWidget() + self.mainStack.addWidget(self.tabOpen) + self.mainStack.addWidget(self.tabNew) + + # Buttons + # ======= + + self.btnBox = QDialogButtonBox(QDialogButtonBox.Open | QDialogButtonBox.Cancel, self) + self.btnBox.accepted.connect(self.tabOpen.openSelectedItem) + self.btnBox.rejected.connect(self.close) + + self.newButton = self.btnBox.addButton(self.tr("New Project"), QDialogButtonBox.ActionRole) + self.newButton.setIcon(SHARED.theme.getIcon("add")) + self.newButton.clicked.connect(self._showNewProjectPage) + + self.browseButton = self.btnBox.addButton(self.tr("Browse"), QDialogButtonBox.ActionRole) + self.browseButton.setIcon(SHARED.theme.getIcon("browse")) + self.browseButton.clicked.connect(self._browseForProject) + + # Assemble + # ======== + + self.innerBox = QVBoxLayout() + self.innerBox.addSpacing(hB) + self.innerBox.addWidget(self.nwLabel) + self.innerBox.addWidget(self.nwInfo) + self.innerBox.addSpacing(hA) + self.innerBox.addWidget(self.mainStack) + self.innerBox.addSpacing(hA) + self.innerBox.addWidget(self.btnBox) + + topRight = Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight + + self.outerBox = QHBoxLayout() + self.outerBox.addWidget(self.nwLogo, 3, topRight) + self.outerBox.addLayout(self.innerBox, 7) + self.outerBox.setContentsMargins(hF, hE, hC, hE) + + self.setLayout(self.outerBox) + self.setSizeGripEnabled(True) + + logger.debug("Ready: GuiWelcome") + + return + + def __del__(self) -> None: # pragma: no cover + logger.debug("Delete: GuiWelcome") + return + + ## + # Events + ## + + def paintEvent(self, event: QPaintEvent) -> None: + """Overload the paint event to draw the background image.""" + hWin = self.height() + hPix = min(hWin, 700) + tMode = Qt.TransformationMode.SmoothTransformation + painter = QPainter(self) + painter.fillRect(self.rect(), self.bgColor) + painter.drawPixmap(0, hWin - hPix, self.bgImage.scaledToHeight(hPix, tMode)) + super().paintEvent(event) + return + + def closeEvent(self, event: QCloseEvent) -> None: + """Capture the user closing the window and save settings.""" + self._saveSettings() + event.accept() + self.deleteLater() + return + + ## + # Private Slots + ## + + @pyqtSlot() + def _showNewProjectPage(self) -> None: + """Show the create new project page.""" + self.mainStack.setCurrentWidget(self.tabNew) + return + + @pyqtSlot() + def _showOpenProjectPage(self) -> None: + """Show the open exiting project page.""" + self.mainStack.setCurrentWidget(self.tabOpen) + return + + @pyqtSlot() + def _browseForProject(self) -> None: + """Browse for a project to open.""" + if path := SHARED.getProjectPath(self, path=CONFIG.lastPath(), allowZip=False): + CONFIG.setLastPath(path) + self._openProjectPath(path) + return + + @pyqtSlot(Path) + def _openProjectPath(self, path: Path) -> None: + """Emit a project open signal.""" + if isinstance(path, Path): + self.openProjectRequest.emit(path) + self.close() + return + + ## + # Internal Functions + ## + + def _saveSettings(self) -> None: + """Save the user GUI settings.""" + logger.debug("Saving State: GuiWelcome") + CONFIG.setWelcomeWinSize(self.width(), self.height()) + return + +# END Class GuiWelcome + + +class _OpenProjectPage(QWidget): + + openProjectRequest = pyqtSignal(Path) + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + + # List View + self.listModel = _ProjectListModel(self) + self.itemDelegate = _ProjectListItem(self) + + self.listWidget = QListView(self) + self.listWidget.setItemDelegate(self.itemDelegate) + self.listWidget.setModel(self.listModel) + self.listWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.listWidget.clicked.connect(self._projectClicked) + self.listWidget.doubleClicked.connect(self._projectDoubleClicked) + self.listWidget.customContextMenuRequested.connect(self._openContextMenu) + + # Info / Tool + self.selectedPath = QLineEdit(self) + self.selectedPath.setReadOnly(True) + + self.keyDelete = QShortcut(self) + self.keyDelete.setKey(Qt.Key.Key_Delete) + self.keyDelete.activated.connect(self._deleteSelectedItem) + + # Assemble + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.listWidget) + self.outerBox.addWidget(self.selectedPath) + self.outerBox.setContentsMargins(0, 0, 0, 0) + + self.setLayout(self.outerBox) + + self.listWidget.setCurrentIndex(self.listModel.index(0)) + self._projectClicked(self.listModel.index(0)) + + baseCol = self.palette().base().color() + self.setStyleSheet(( + "QListView {{border: none; background: rgba({r},{g},{b},0.65);}} " + "QLineEdit {{border: none; background: rgba({r},{g},{b},0.65); padding: {m}px;}} " + ).format(r=baseCol.red(), g=baseCol.green(), b=baseCol.blue(), m=CONFIG.pxInt(4))) + + return + + ## + # Public Slots + ## + + @pyqtSlot() + def openSelectedItem(self) -> None: + """Open the currently selected project item.""" + if (selection := self.listWidget.selectedIndexes()) and (index := selection[0]).isValid(): + self.openProjectRequest.emit(Path(str(index.data()[1]))) + return + + ## + # Private Slots + ## + + @pyqtSlot(QModelIndex) + def _projectClicked(self, index: QModelIndex) -> None: + """Process single click on project item.""" + if index.isValid(): + path = self.tr("Path") + self.selectedPath.setText(f"{path}: {index.data()[1]}") + return + + @pyqtSlot(QModelIndex) + def _projectDoubleClicked(self, index: QModelIndex) -> None: + """Process double click on project item.""" + if index.isValid(): + self.openProjectRequest.emit(Path(str(index.data()[1]))) + return + + @pyqtSlot() + def _deleteSelectedItem(self) -> None: + """Delete the currently selected project item.""" + if (selection := self.listWidget.selectedIndexes()) and (index := selection[0]).isValid(): + text = self.tr( + "Remove '{0}' from the recent projects list? " + "The project files will not be deleted." + ).format(index.data()[0]) + if SHARED.question(text): + self.listModel.removeEntry(index) + return + + @pyqtSlot("QPoint") + def _openContextMenu(self, pos: QPoint) -> None: + """Open the custom context menu.""" + ctxMenu = QMenu(self) + ctxMenu.setObjectName("ContextMenu") # Used for testing + action = ctxMenu.addAction(self.tr("Open Project")) + action.triggered.connect(self.openSelectedItem) + action = ctxMenu.addAction(self.tr("Remove Project")) + action.triggered.connect(self._deleteSelectedItem) + ctxMenu.exec_(self.mapToGlobal(pos)) + ctxMenu.deleteLater() + return + +# END Class _OpenProjectPage + + +class _ProjectListItem(QStyledItemDelegate): + + __slots__ = ("_pPx", "_hPx", "_tFont", "_dFont", "_dPen", "_icon") + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + + fPx = SHARED.theme.fontPixelSize + fPt = SHARED.theme.fontPointSize + tPx = round(1.2 * fPx) + mPx = CONFIG.pxInt(4) + iPx = tPx + fPx + + self._pPx = (mPx//2, 3*mPx//2, iPx + mPx, mPx, mPx + tPx) # Painter coordinates + self._hPx = 2*mPx + tPx + fPx # Fixed height + + self._tFont = qApp.font() + self._tFont.setPointSizeF(1.2*fPt) + self._tFont.setWeight(QFont.Weight.Bold) + + self._dFont = qApp.font() + self._dFont.setPointSizeF(fPt) + self._dPen = QPen(QColor(*SHARED.theme.helpText)) + + self._icon = SHARED.theme.getPixmap("proj_nwx", (iPx, iPx)) + + return + + def paint(self, painter: QPainter, opt: QStyleOptionViewItem, index: QModelIndex) -> None: + """Paint a project entry on the canvas.""" + rect = opt.rect + title, _, details = index.data() + tFlag = Qt.TextFlag.TextSingleLine + ix, iy, x, y1, y2 = self._pPx + + painter.save() + if opt.state & QStyle.StateFlag.State_Selected == QStyle.StateFlag.State_Selected: + painter.setOpacity(0.5) + painter.fillRect(rect, qApp.palette().highlight()) + painter.setOpacity(1.0) + + painter.drawPixmap(ix, rect.top() + iy, self._icon) + painter.setFont(self._tFont) + painter.drawText(rect.adjusted(x, y1, 0, 0), tFlag, title) + painter.setFont(self._dFont) + painter.setPen(self._dPen) + painter.drawText(rect.adjusted(x, y2, 0, 0), tFlag, details) + painter.restore() + + return + + def sizeHint(self, opt: QStyleOptionViewItem, index: QModelIndex) -> QSize: + """Set the size hint to fixed height.""" + return QSize(opt.rect.width(), self._hPx) + +# END Class _ProjectListItem + + +class _ProjectListModel(QAbstractListModel): + + def __init__(self, parent: QObject) -> None: + super().__init__(parent=parent) + data = [] + words = self.tr("Word Count") + opened = self.tr("Last Opened") + records = sorted(CONFIG.recentProjects.listEntries(), key=lambda x: x[3], reverse=True) + for path, title, count, time in records: + when = datetime.fromtimestamp(time).strftime("%x") + data.append((title, path, f"{opened}: {when}, {words}: {formatInt(count)}")) + self._data = data + return + + def rowCount(self, parent: QModelIndex | None = None) -> int: + """Return the size of the model.""" + return len(self._data) + + def data(self, index: QModelIndex, role: int = 0) -> tuple[str, str, str]: + """Return data for an individual item.""" + try: + return self._data[index.row()] if index.isValid() else ("", "", "") + except IndexError: + return "", "", "" + + def removeEntry(self, index: QModelIndex) -> bool: + """Remove an entry in the model.""" + if index.isValid() and (path := index.data()[1]): + try: + self.beginRemoveRows(index.parent(), index.row(), index.row()) + self._data.pop(index.row()) + self.endRemoveRows() + except IndexError: + return False + CONFIG.recentProjects.remove(path) + return True + return False + +# END Class _ProjectListModel + + +class _NewProjectPage(QWidget): + + cancelNewProject = pyqtSignal() + openProjectRequest = pyqtSignal(Path) + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + + # Main Form + # ========= + + self.projectForm = _NewProjectForm(self) + + self.scrollArea = QScrollArea(self) + self.scrollArea.setWidget(self.projectForm) + self.scrollArea.setWidgetResizable(True) + self.scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Controls + # ======== + + self.cancelButton = QPushButton(self.tr("Go Back"), self) + self.cancelButton.setIcon(SHARED.theme.getIcon("backward")) + self.cancelButton.clicked.connect(lambda: self.cancelNewProject.emit()) + + self.createButton = QPushButton(self.tr("Create Project"), self) + self.createButton.setIcon(SHARED.theme.getIcon("star")) + self.createButton.clicked.connect(self._createNewProject) + + self.buttonBox = QHBoxLayout() + self.buttonBox.addStretch(1) + self.buttonBox.addWidget(self.cancelButton, 0) + self.buttonBox.addWidget(self.createButton, 0) + + # Assemble + # ======== + + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.scrollArea) + self.outerBox.addLayout(self.buttonBox) + self.outerBox.setContentsMargins(0, 0, 0, 0) + + self.setLayout(self.outerBox) + + # Styles + # ====== + + baseCol = self.palette().base().color() + self.setStyleSheet(( + "QScrollArea {{border: none; background: rgba({r},{g},{b},0.65);}} " + "_NewProjectForm {{border: none; background: rgba({r},{g},{b},0.65);}} " + ).format(r=baseCol.red(), g=baseCol.green(), b=baseCol.blue())) + + return + + ## + # Private Slots + ## + + @pyqtSlot() + def _createNewProject(self) -> None: + """Create a new project from the data in the form.""" + data = self.projectForm.getProjectData() + if not data.get("name"): + SHARED.error(self.tr("A project name is required.")) + return + builder = ProjectBuilder() + if builder.buildProject(data) and (path := builder.projPath): + self.openProjectRequest.emit(path) + return + +# END Class _NewProjectPage + + +class _NewProjectForm(QWidget): + + FILL_BLANK = 0 + FILL_SAMPLE = 1 + FILL_COPY = 2 + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + + self._basePath = CONFIG.lastPath() + self._fillMode = self.FILL_BLANK + self._copyPath = None + + # Project Settings + # ================ + + # Project Name + self.projName = QLineEdit() + self.projName.setMaxLength(200) + self.projName.setPlaceholderText(self.tr("Required")) + self.projName.textChanged.connect(self._updateProjPath) + + # Author(s) + self.projAuthor = QLineEdit() + self.projAuthor.setMaxLength(200) + self.projAuthor.setPlaceholderText(self.tr("Optional")) + + # Project Language + self.projLang = QComboBox(self) + for tag, language in CONFIG.listLanguages(CONFIG.LANG_PROJ): + self.projLang.addItem(language, tag) + + langIdx = self.projLang.findData(CONFIG.guiLocale) + if langIdx != -1: + self.projLang.setCurrentIndex(langIdx) + + # Project Path + self.projPath = QLineEdit(self) + self.projPath.setReadOnly(True) + + self.browsePath = QToolButton(self) + self.browsePath.setIcon(SHARED.theme.getIcon("browse")) + self.browsePath.clicked.connect(self._doBrowse) + + self.pathBox = QHBoxLayout() + self.pathBox.addWidget(self.projPath) + self.pathBox.addWidget(self.browsePath) + + # Fill Project + self.projFill = QLineEdit(self) + self.projFill.setReadOnly(True) + + self.browseFill = QToolButton(self) + self.browseFill.setIcon(SHARED.theme.getIcon("add_document")) + + self.fillMenu = _PopLeftDirectionMenu(self.browseFill) + + self.fillBlank = self.fillMenu.addAction(self.tr("Create a fresh project")) + self.fillBlank.setIcon(SHARED.theme.getIcon("document")) + self.fillBlank.triggered.connect(self._setFillBlank) + + self.fillSample = self.fillMenu.addAction(self.tr("Create an example project")) + self.fillSample.setIcon(SHARED.theme.getIcon("add_document")) + self.fillSample.triggered.connect(self._setFillSample) + + self.fillCopy = self.fillMenu.addAction(self.tr("Copy an existing project")) + self.fillCopy.setIcon(SHARED.theme.getIcon("browse")) + self.fillCopy.triggered.connect(self._setFillCopy) + + self.browseFill.setMenu(self.fillMenu) + self.browseFill.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + + self.fillBox = QHBoxLayout() + self.fillBox.addWidget(self.projFill) + self.fillBox.addWidget(self.browseFill) + + # Project Form + self.projectForm = QFormLayout() + self.projectForm.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.projectForm.addRow(self.tr("Project Name"), self.projName) + self.projectForm.addRow(self.tr("Author"), self.projAuthor) + self.projectForm.addRow(self.tr("Language"), self.projLang) + self.projectForm.addRow(self.tr("Project Path"), self.pathBox) + self.projectForm.addRow(self.tr("Prefill Project"), self.fillBox) + + # Chapters and Scenes + # =================== + + self.numChapters = QSpinBox() + self.numChapters.setRange(0, 200) + self.numChapters.setValue(5) + self.numChapters.setToolTip(self.tr("Set to 0 to only add scenes")) + + self.chapterBox = QHBoxLayout() + self.chapterBox.addWidget(QLabel(self.tr("Add"))) + self.chapterBox.addWidget(self.numChapters) + self.chapterBox.addWidget(QLabel(self.tr("chapter documents"))) + self.chapterBox.addStretch(1) + + self.numScenes = QSpinBox() + self.numScenes.setRange(0, 200) + self.numScenes.setValue(5) + + self.sceneBox = QHBoxLayout() + self.sceneBox.addWidget(QLabel(self.tr("Add"))) + self.sceneBox.addWidget(self.numScenes) + self.sceneBox.addWidget(QLabel(self.tr("scene documents (to each chapter)"))) + self.sceneBox.addStretch(1) + + self.novelForm = QVBoxLayout() + self.novelForm.addLayout(self.chapterBox) + self.novelForm.addLayout(self.sceneBox) + + # Project Notes + # ============= + + self.addPlot = NSwitch(self) + self.addPlot.setChecked(True) + self.addPlot.clicked.connect(self._syncSwitches) + + self.addChar = NSwitch(self) + self.addChar.setChecked(True) + self.addChar.clicked.connect(self._syncSwitches) + + self.addWorld = NSwitch(self) + self.addWorld.setChecked(False) + self.addWorld.clicked.connect(self._syncSwitches) + + self.addNotes = NSwitch() + self.addNotes.setChecked(False) + + self.notesForm = QFormLayout() + self.notesForm.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.notesForm.addRow(self.tr("Add a folder for plot notes"), self.addPlot) + self.notesForm.addRow(self.tr("Add a folder for character notes"), self.addChar) + self.notesForm.addRow(self.tr("Add a folder for location notes"), self.addWorld) + self.notesForm.addRow(self.tr("Add example notes to the above"), self.addNotes) + + # Assemble + # ======== + + self.formBox = QVBoxLayout() + self.formBox.addWidget(QLabel("{0}".format(self.tr("Create New Project")))) + self.formBox.addLayout(self.projectForm) + self.formBox.addSpacing(16) + self.formBox.addWidget(QLabel("{0}".format(self.tr("Chapters and Scenes")))) + self.formBox.addLayout(self.novelForm) + self.formBox.addSpacing(16) + self.formBox.addWidget(QLabel("{0}".format(self.tr("Project Notes")))) + self.formBox.addLayout(self.notesForm) + self.formBox.addStretch(1) + + self.setLayout(self.formBox) + + self._updateProjPath() + self._updateFillInfo() + + return + + def getProjectData(self) -> dict: + """Collect form data and return it as a dictionary.""" + roots = [] + if self.addPlot.isChecked(): + roots.append(nwItemClass.PLOT) + if self.addChar.isChecked(): + roots.append(nwItemClass.CHARACTER) + if self.addWorld.isChecked(): + roots.append(nwItemClass.WORLD) + return { + "name": self.projName.text().strip(), + "author": self.projAuthor.text().strip(), + "language": self.projLang.currentData(), + "path": self.projPath.text(), + "blank": self._fillMode == self.FILL_BLANK, + "sample": self._fillMode == self.FILL_SAMPLE, + "template": self._copyPath if self._fillMode == self.FILL_COPY else None, + "chapters": self.numChapters.value(), + "scenes": self.numScenes.value(), + "roots": roots, + "notes": self.addNotes.isChecked(), + } + + ## + # Private Slots + ## + + @pyqtSlot() + def _doBrowse(self) -> None: + """Select a project folder.""" + if projDir := QFileDialog.getExistingDirectory( + self, self.tr("Select Project Folder"), + str(self._basePath), options=QFileDialog.ShowDirsOnly + ): + self._basePath = Path(projDir) + self._updateProjPath() + CONFIG.setLastPath(self._basePath) + return + + @pyqtSlot() + def _updateProjPath(self) -> None: + """Update the path box to show the full project path.""" + projName = makeFileNameSafe(self.projName.text().strip()) + self.projPath.setText(str(self._basePath / projName)) + return + + @pyqtSlot() + def _syncSwitches(self): + """Check if the add notes option should also be switched off.""" + addPlot = self.addPlot.isChecked() + addChar = self.addChar.isChecked() + addWorld = self.addWorld.isChecked() + if not (addPlot or addChar or addWorld): + self.addNotes.setChecked(False) + return + + @pyqtSlot() + def _setFillBlank(self) -> None: + """Set fill mode to blank project.""" + self._fillMode = self.FILL_BLANK + self._updateFillInfo() + return + + @pyqtSlot() + def _setFillSample(self) -> None: + """Set fill mode to sample project.""" + self._fillMode = self.FILL_SAMPLE + self._updateFillInfo() + return + + @pyqtSlot() + def _setFillCopy(self) -> None: + """Set fill mode to copy project.""" + if copyPath := SHARED.getProjectPath(self, allowZip=True): + self._fillMode = self.FILL_COPY + self._copyPath = copyPath + self._updateFillInfo() + return + + ## + # Internal Functions + ## + + def _updateFillInfo(self) -> None: + """Update the text of the project fill box.""" + text = "" + if self._fillMode == self.FILL_BLANK: + text = self.tr("Fresh Project") + elif self._fillMode == self.FILL_SAMPLE: + text = self.tr("Example Project") + elif self._fillMode == self.FILL_COPY: + text = self.tr("Template: {0}").format(str(self._copyPath)) + + self.projFill.setText(text) + self.projFill.setToolTip(text) + self.projFill.setCursorPosition(0) + + isBlank = self._fillMode == self.FILL_BLANK + self.numChapters.setEnabled(isBlank) + self.numScenes.setEnabled(isBlank) + self.addPlot.setEnabled(isBlank) + self.addChar.setEnabled(isBlank) + self.addWorld.setEnabled(isBlank) + self.addNotes.setEnabled(isBlank) + + return + +# END Class _NewProjectForm + + +class _PopLeftDirectionMenu(QMenu): + + def event(self, event: QEvent) -> bool: + """Overload the show event and move the menu popup location.""" + if event.type() == QEvent.Show: + if isinstance(parent := self.parent(), QWidget): + offset = QPoint(parent.width() - self.width(), parent.height()) + self.move(parent.mapToGlobal(offset)) + return super(_PopLeftDirectionMenu, self).event(event) + +# END Class _PopLeftDirectionMenu diff --git a/tests/conftest.py b/tests/conftest.py index be3c243f..4a87c3c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -160,7 +160,6 @@ def nwGUI(qtbot, monkeypatch, functionFixture): nwGUI = main(["--testmode", f"--config={_TMP_CONF}", f"--data={_TMP_CONF}"]) qtbot.addWidget(nwGUI) resetConfigVars() - SHARED._alert = None nwGUI.docEditor.initEditor() nwGUI.show() diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf index 5007dd54..6f064701 100644 --- a/tests/reference/baseConfig_novelwriter.conf +++ b/tests/reference/baseConfig_novelwriter.conf @@ -1,5 +1,5 @@ [Meta] -timestamp = 2023-08-08 19:01:25 +timestamp = 2023-12-29 14:11:20 [Main] theme = default @@ -14,8 +14,8 @@ lastpath = [Sizes] mainwindow = 1200, 650 +welcome = 800, 500 preferences = 700, 615 -projloadcols = 280, 60, 160 mainpane = 300, 800 viewpane = 500, 150 outlinepane = 500, 150 diff --git a/tests/reference/coreTools_NewMinimal_nwProject.nwx b/tests/reference/coreTools_NewMinimal_nwProject.nwx deleted file mode 100644 index a38b56b0..00000000 --- a/tests/reference/coreTools_NewMinimal_nwProject.nwx +++ /dev/null @@ -1,66 +0,0 @@ - - - - New Project - New Project - - - - yes - en_GB - None - - None - None - None - None - - - - New - Note - Draft - Finished - - - New - Minor - Major - Main - - - - - - Novel - - - - Title Page - - - - New Chapter - - - - New Scene - - - - Plot - - - - Characters - - - - Locations - - - - Archive - - - diff --git a/tests/reference/coreTools_NewCustomA_nwProject.nwx b/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx similarity index 97% rename from tests/reference/coreTools_NewCustomA_nwProject.nwx rename to tests/reference/coreTools_ProjectBuilderA_nwProject.nwx index 3abeea1a..02f11a29 100644 --- a/tests/reference/coreTools_NewCustomA_nwProject.nwx +++ b/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx @@ -1,8 +1,8 @@ - + - Test Custom - Test Novel + Test Project A + Test Project A Jane Doe diff --git a/tests/reference/coreTools_NewCustomB_nwProject.nwx b/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx similarity index 96% rename from tests/reference/coreTools_NewCustomB_nwProject.nwx rename to tests/reference/coreTools_ProjectBuilderB_nwProject.nwx index a433a3b1..0df054a8 100644 --- a/tests/reference/coreTools_NewCustomB_nwProject.nwx +++ b/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx @@ -1,8 +1,8 @@ - + - Test Custom - Test Novel + Test Project B + Test Project B Jane Doe diff --git a/tests/reference/guiPreferences_novelwriter.conf b/tests/reference/guiPreferences_novelwriter.conf index 9275b82e..b291bf4a 100644 --- a/tests/reference/guiPreferences_novelwriter.conf +++ b/tests/reference/guiPreferences_novelwriter.conf @@ -1,5 +1,5 @@ [Meta] -timestamp = 2023-08-02 14:53:40 +timestamp = 2023-12-29 14:09:13 [Main] theme = default @@ -14,8 +14,8 @@ lastpath = [Sizes] mainwindow = 1200, 650 -preferences = 765, 614 -projloadcols = 280, 60, 160 +welcome = 800, 500 +preferences = 713, 614 mainpane = 300, 800 viewpane = 500, 150 outlinepane = 500, 150 diff --git a/tests/test_base/test_base_config.py b/tests/test_base/test_base_config.py index d8938d45..037f820b 100644 --- a/tests/test_base/test_base_config.py +++ b/tests/test_base/test_base_config.py @@ -278,6 +278,19 @@ def testBaseConfig_SettersGetters(fncPath): tstConf.setMainWinSize(1200, 650) + # Welcome Window Size + tstConf.guiScale = 2.0 + tstConf.setWelcomeWinSize(70, 70) + assert tstConf.welcomeWinSize == [70, 70] + assert tstConf._welcomeSize == [35, 35] + + tstConf.guiScale = 1.0 + tstConf.setWelcomeWinSize(70, 70) + assert tstConf.welcomeWinSize == [70, 70] + assert tstConf._welcomeSize == [70, 70] + + tstConf.setWelcomeWinSize(800, 500) + # Preferences Size tstConf.guiScale = 2.0 tstConf.setPreferencesWinSize(70, 70) @@ -291,19 +304,6 @@ def testBaseConfig_SettersGetters(fncPath): tstConf.setPreferencesWinSize(700, 615) - # Project Settings Tree Columns - tstConf.guiScale = 2.0 - tstConf.setProjLoadColWidths([10, 20, 30]) - assert tstConf.projLoadColWidths == [10, 20, 30] - assert tstConf._projLoadCols == [5, 10, 15] - - tstConf.guiScale = 1.0 - tstConf.setProjLoadColWidths([10, 20, 30]) - assert tstConf.projLoadColWidths == [10, 20, 30] - assert tstConf._projLoadCols == [10, 20, 30] - - tstConf.setProjLoadColWidths([200, 60, 140]) - # Main Pane Splitter tstConf.guiScale = 2.0 tstConf.setMainPanePos([200, 700]) diff --git a/tests/test_base/test_base_shared.py b/tests/test_base/test_base_shared.py index 0f105284..8b67844e 100644 --- a/tests/test_base/test_base_shared.py +++ b/tests/test_base/test_base_shared.py @@ -25,7 +25,7 @@ import pytest from tools import buildTestProject from mocked import MockGuiMain, MockTheme -from PyQt5.QtWidgets import QMessageBox +from PyQt5.QtWidgets import QFileDialog, QMessageBox, QWidget from novelwriter.shared import SharedData from novelwriter.core.project import NWProject @@ -66,7 +66,7 @@ def testBaseSharedData_Init(): @pytest.mark.base -def testBaseSharedData_Projects(fncPath, caplog): +def testBaseSharedData_Projects(monkeypatch, caplog, fncPath): """Test SharedData handling of projects.""" project = NWProject() buildTestProject(project, fncPath) @@ -118,6 +118,11 @@ def testBaseSharedData_Projects(fncPath, caplog): assert shared.hasProject is False assert isinstance(shared.projectLock, list) + # Test browsing for projects + with monkeypatch.context() as mp: + mp.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (a[2], "")) + assert shared.getProjectPath(QWidget(), fncPath, allowZip=True) == fncPath + # END Test testBaseSharedData_Projects diff --git a/tests/test_core/test_core_coretools.py b/tests/test_core/test_core_coretools.py index c52b9ad5..d65a337e 100644 --- a/tests/test_core/test_core_coretools.py +++ b/tests/test_core/test_core_coretools.py @@ -39,32 +39,32 @@ from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter, Pr @pytest.mark.core def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ipsumText): """Test the DocMerger utility.""" - theProject = NWProject() + project = NWProject() mockRnd.reset() - buildTestProject(theProject, fncPath) + buildTestProject(project, fncPath) # Create Files to Merge # ===================== - hChapter1 = theProject.newFile("Chapter 1", C.hNovelRoot) - hSceneOne11 = theProject.newFile("Scene 1.1", hChapter1) # type: ignore - hSceneOne12 = theProject.newFile("Scene 1.2", hChapter1) # type: ignore - hSceneOne13 = theProject.newFile("Scene 1.3", hChapter1) # type: ignore + hChapter1 = project.newFile("Chapter 1", C.hNovelRoot) + hSceneOne11 = project.newFile("Scene 1.1", hChapter1) # type: ignore + hSceneOne12 = project.newFile("Scene 1.2", hChapter1) # type: ignore + hSceneOne13 = project.newFile("Scene 1.3", hChapter1) # type: ignore docText1 = "\n\n".join(ipsumText[0:2]) + "\n\n" docText2 = "\n\n".join(ipsumText[1:3]) + "\n\n" docText3 = "\n\n".join(ipsumText[2:4]) + "\n\n" docText4 = "\n\n".join(ipsumText[3:5]) + "\n\n" - theProject.writeNewFile(hChapter1, 2, True, docText1) # type: ignore - theProject.writeNewFile(hSceneOne11, 3, True, docText2) # type: ignore - theProject.writeNewFile(hSceneOne12, 3, True, docText3) # type: ignore - theProject.writeNewFile(hSceneOne13, 3, True, docText4) # type: ignore + project.writeNewFile(hChapter1, 2, True, docText1) # type: ignore + project.writeNewFile(hSceneOne11, 3, True, docText2) # type: ignore + project.writeNewFile(hSceneOne12, 3, True, docText3) # type: ignore + project.writeNewFile(hSceneOne13, 3, True, docText4) # type: ignore # Basic Checks # ============ - docMerger = DocMerger(theProject) + docMerger = DocMerger(project) # No writing without a target set assert docMerger.writeTargetDoc() is False @@ -127,14 +127,14 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip @pytest.mark.core def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText): """Test the DocSplitter utility.""" - theProject = NWProject() + project = NWProject() mockRnd.reset() - buildTestProject(theProject, fncPath) + buildTestProject(project, fncPath) # Create File to Split # ==================== - hSplitDoc = theProject.newFile("Split Doc", C.hNovelRoot) + hSplitDoc = project.newFile("Split Doc", C.hNovelRoot) docData = [ "# Part One", ipsumText[0], @@ -163,16 +163,16 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText) docText = "\n\n".join(docData) docRaw = docText.splitlines() - assert theProject.storage.getDocument(hSplitDoc).writeDocument(docText) is True - theProject.tree[hSplitDoc].setStatus(C.sFinished) # type: ignore - theProject.tree[hSplitDoc].setImport(C.iMain) # type: ignore + assert project.storage.getDocument(hSplitDoc).writeDocument(docText) is True + project.tree[hSplitDoc].setStatus(C.sFinished) # type: ignore + project.tree[hSplitDoc].setImport(C.iMain) # type: ignore - docSplitter = DocSplitter(theProject, hSplitDoc) # type: ignore + docSplitter = DocSplitter(project, hSplitDoc) # type: ignore assert docSplitter._srcItem.isFileType() # type: ignore assert docSplitter.getError() == "" # Run the split algorithm - docSplitter.splitDocument(splitData, docRaw) + docSplitter.splitDocument(splitData, docRaw) # type: ignore for i, (lineNo, hLevel, hLabel) in enumerate(splitData): assert docSplitter._rawData[i] == (docRaw[lineNo:lineNo+4], hLevel, hLabel) @@ -248,17 +248,17 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText) # Check that status and importance has been preserved for rHandle in resDocHandle: - assert theProject.tree[rHandle].itemStatus == C.sFinished # type: ignore - assert theProject.tree[rHandle].itemImport == C.iMain # type: ignore + assert project.tree[rHandle].itemStatus == C.sFinished # type: ignore + assert project.tree[rHandle].itemImport == C.iMain # type: ignore # Check handling of improper initialisation - docSplitter = DocSplitter(theProject, C.hInvalid) + docSplitter = DocSplitter(project, C.hInvalid) assert docSplitter._srcHandle is None assert docSplitter._srcItem is None assert docSplitter.newParentFolder(C.hNovelRoot, "Split Folder") is None assert list(docSplitter.writeDocuments(False)) == [] - theProject.saveProject() + project.saveProject() # END Test testCoreTools_DocSplitter @@ -266,20 +266,20 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText) @pytest.mark.core def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd): """Test the DocDuplicator utility.""" - theProject = NWProject() + project = NWProject() mockRnd.reset() - buildTestProject(theProject, fncPath) + buildTestProject(project, fncPath) - dup = DocDuplicator(theProject) + dup = DocDuplicator(project) ttText = "#! New Novel\n\n>> By Jane Doe <<\n" chText = "## New Chapter\n\n" scText = "### New Scene\n\n" # Check document content - assert theProject.storage.getDocument(C.hTitlePage).readDocument() == ttText - assert theProject.storage.getDocument(C.hChapterDoc).readDocument() == chText - assert theProject.storage.getDocument(C.hSceneDoc).readDocument() == scText + assert project.storage.getDocument(C.hTitlePage).readDocument() == ttText + assert project.storage.getDocument(C.hChapterDoc).readDocument() == chText + assert project.storage.getDocument(C.hSceneDoc).readDocument() == scText # Nothing to do assert list(dup.duplicate([])) == [] @@ -291,17 +291,17 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd): assert list(dup.duplicate([C.hSceneDoc])) == [ ("0000000000010", C.hSceneDoc), # The Scene ] - assert theProject.tree._order == [ + assert project.tree._order == [ C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot, C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", ] # With the same content - assert theProject.storage.getDocument("0000000000010").readDocument() == scText + assert project.storage.getDocument("0000000000010").readDocument() == scText # They should have the same parent - assert theProject.tree["0000000000010"].itemParent == C.hChapterDir # type: ignore + assert project.tree["0000000000010"].itemParent == C.hChapterDir # type: ignore # Folder w/Two Files # ================== @@ -312,7 +312,7 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd): ("0000000000012", None), # The Chapter ("0000000000013", None), # The Scene ] - assert theProject.tree._order == [ + assert project.tree._order == [ C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot, C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", @@ -320,15 +320,15 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd): ] # With the same content - assert theProject.storage.getDocument("0000000000012").readDocument() == chText - assert theProject.storage.getDocument("0000000000013").readDocument() == scText + assert project.storage.getDocument("0000000000012").readDocument() == chText + assert project.storage.getDocument("0000000000013").readDocument() == scText # The chapter dirs should have the same parent - assert theProject.tree["0000000000011"].itemParent == C.hNovelRoot # type: ignore + assert project.tree["0000000000011"].itemParent == C.hNovelRoot # type: ignore # The new files should have the new folder as parent - assert theProject.tree["0000000000012"].itemParent == "0000000000011" # type: ignore - assert theProject.tree["0000000000013"].itemParent == "0000000000011" # type: ignore + assert project.tree["0000000000012"].itemParent == "0000000000011" # type: ignore + assert project.tree["0000000000013"].itemParent == "0000000000011" # type: ignore # Full Root Folder # ================ @@ -343,7 +343,7 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd): ("0000000000017", None), # The Chapter ("0000000000018", None), # The Scene ] - assert theProject.tree._order == [ + assert project.tree._order == [ C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot, C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", @@ -352,24 +352,24 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd): ] # With the same content - assert theProject.storage.getDocument("0000000000015").readDocument() == ttText - assert theProject.storage.getDocument("0000000000017").readDocument() == chText - assert theProject.storage.getDocument("0000000000018").readDocument() == scText + assert project.storage.getDocument("0000000000015").readDocument() == ttText + assert project.storage.getDocument("0000000000017").readDocument() == chText + assert project.storage.getDocument("0000000000018").readDocument() == scText # The root folder should have no parent - assert theProject.tree["0000000000014"].itemParent is None # type: ignore + assert project.tree["0000000000014"].itemParent is None # type: ignore # The folder and files should have the new root - assert theProject.tree["0000000000015"].itemRoot == "0000000000014" # type: ignore - assert theProject.tree["0000000000016"].itemRoot == "0000000000014" # type: ignore - assert theProject.tree["0000000000017"].itemRoot == "0000000000014" # type: ignore - assert theProject.tree["0000000000018"].itemRoot == "0000000000014" # type: ignore + assert project.tree["0000000000015"].itemRoot == "0000000000014" # type: ignore + assert project.tree["0000000000016"].itemRoot == "0000000000014" # type: ignore + assert project.tree["0000000000017"].itemRoot == "0000000000014" # type: ignore + assert project.tree["0000000000018"].itemRoot == "0000000000014" # type: ignore # And they should have new parents - assert theProject.tree["0000000000015"].itemParent == "0000000000014" # type: ignore - assert theProject.tree["0000000000016"].itemParent == "0000000000014" # type: ignore - assert theProject.tree["0000000000017"].itemParent == "0000000000016" # type: ignore - assert theProject.tree["0000000000018"].itemParent == "0000000000016" # type: ignore + assert project.tree["0000000000015"].itemParent == "0000000000014" # type: ignore + assert project.tree["0000000000016"].itemParent == "0000000000014" # type: ignore + assert project.tree["0000000000017"].itemParent == "0000000000016" # type: ignore + assert project.tree["0000000000018"].itemParent == "0000000000016" # type: ignore # Exceptions # ========== @@ -381,14 +381,14 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd): assert list(dup.duplicate([C.hInvalid, C.hSceneDoc])) == [] # Don't overwrite existing files - content = theProject.storage.contentPath + content = project.storage.contentPath assert isinstance(content, Path) (content / "0000000000019.nwd").touch() assert (content / "0000000000019.nwd").exists() assert list(dup.duplicate([C.hChapterDoc, C.hSceneDoc])) == [] # Save and Close - theProject.saveProject() + project.saveProject() projFile = fncPath / "nwProject.nwx" testFile = tstPaths.outDir / "coreTools_DocDuplicator_nwProject.nwx" @@ -401,134 +401,121 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd): @pytest.mark.core -def testCoreTools_NewMinimal(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd): - """Create a new project from a project wizard dictionary. With - default setting, creating a Minimal project. - """ - monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")) - - projFile = fncPath / "nwProject.nwx" - testFile = tstPaths.outDir / "coreTools_NewMinimal_nwProject.nwx" - compFile = tstPaths.refDir / "coreTools_NewMinimal_nwProject.nwx" - - projBuild = ProjectBuilder() +def testCoreTools_ProjectBuilderWrapper(monkeypatch, caplog, fncPath, mockGUI): + """Test the wrapper function of the project builder.""" + builder = ProjectBuilder() # Setting no data should fail - assert projBuild.buildProject({}) is False + assert builder.buildProject({}) is False # Wrong type should also fail - assert projBuild.buildProject("stuff") is False # type: ignore + assert builder.buildProject("stuff") is False # type: ignore + + # Folder not writable + caplog.clear() + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.mkdir", causeOSError) + assert builder.buildProject({"path": fncPath}) is False + assert "An error occurred" in caplog.text # Try again with a proper path - assert projBuild.buildProject({"projPath": fncPath}) is True + assert builder.buildProject({"path": fncPath}) is True + assert builder.projPath == fncPath # Creating the project once more should fail - assert projBuild.buildProject({"projPath": fncPath}) is False + caplog.clear() + assert builder.buildProject({"path": fncPath}) is False + assert "A project already exists" in caplog.text - # Save and close - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) - -# END Test testCoreTools_NewMinimal +# END Test testCoreTools_ProjectBuilderWrapper @pytest.mark.core -def testCoreTools_NewCustomA(monkeypatch, fncPath, tstPaths, mockRnd): - """Create a new project from a project wizard dictionary. - Custom type with chapters and scenes. - """ +def testCoreTools_ProjectBuilderA(monkeypatch, fncPath, tstPaths, mockRnd): + """Create a new project from a project dictionary, with chapters.""" monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")) projFile = fncPath / "nwProject.nwx" - testFile = tstPaths.outDir / "coreTools_NewCustomA_nwProject.nwx" - compFile = tstPaths.refDir / "coreTools_NewCustomA_nwProject.nwx" + testFile = tstPaths.outDir / "coreTools_ProjectBuilderA_nwProject.nwx" + compFile = tstPaths.refDir / "coreTools_ProjectBuilderA_nwProject.nwx" - projData = { - "projName": "Test Custom", - "projTitle": "Test Novel", - "projAuthor": "Jane Doe", - "projLang": -1, - "projPath": fncPath, - "popSample": False, - "popMinimal": False, - "popCustom": True, - "addRoots": [ + data = { + "name": "Test Project A", + "author": "Jane Doe", + "language": -1, + "path": fncPath, + "sample": False, + "template": None, + "chapters": 3, + "scenes": 3, + "roots": [ nwItemClass.PLOT, nwItemClass.CHARACTER, nwItemClass.WORLD, ], - "addNotes": True, - "numChapters": 3, - "numScenes": 3, + "notes": True, } - projBuild = ProjectBuilder() - assert projBuild.buildProject(projData) is True + builder = ProjectBuilder() + assert builder.buildProject(data) is True copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) -# END Test testCoreTools_NewCustomA +# END Test testCoreTools_ProjectBuilderA @pytest.mark.core -def testCoreTools_NewCustomB(monkeypatch, fncPath, tstPaths, mockRnd): - """Create a new project from a project wizard dictionary. - Custom type without chapters, but with scenes. - """ +def testCoreTools_ProjectBuilderB(monkeypatch, fncPath, tstPaths, mockRnd): + """Create a new project from a project dictionary, without chapters.""" monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")) projFile = fncPath / "nwProject.nwx" - testFile = tstPaths.outDir / "coreTools_NewCustomB_nwProject.nwx" - compFile = tstPaths.refDir / "coreTools_NewCustomB_nwProject.nwx" + testFile = tstPaths.outDir / "coreTools_ProjectBuilderB_nwProject.nwx" + compFile = tstPaths.refDir / "coreTools_ProjectBuilderB_nwProject.nwx" - projData = { - "projName": "Test Custom", - "projTitle": "Test Novel", - "projAuthor": "Jane Doe", - "projLang": -1, - "projPath": fncPath, - "popSample": False, - "popMinimal": False, - "popCustom": True, - "addRoots": [ + data = { + "name": "Test Project B", + "author": "Jane Doe", + "language": -1, + "path": fncPath, + "sample": False, + "template": None, + "chapters": 0, + "scenes": 6, + "roots": [ nwItemClass.PLOT, nwItemClass.CHARACTER, nwItemClass.WORLD, ], - "addNotes": True, - "numChapters": 0, - "numScenes": 6, + "notes": True, } - projBuild = ProjectBuilder() - assert projBuild.buildProject(projData) is True + builder = ProjectBuilder() + assert builder.buildProject(data) is True copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) -# END Test testCoreTools_NewCustomB +# END Test testCoreTools_ProjectBuilderB @pytest.mark.core -def testCoreTools_NewSample(monkeypatch, mockGUI, fncPath, tstPaths): +def testCoreTools_ProjectBuilderSample(monkeypatch, mockGUI, fncPath, tstPaths): """Check that we can create a new project can be created from the provided sample project via a zip file. """ - projData = { - "projName": "Test Sample", - "projTitle": "Test Novel", - "projAuthor": "Jane Doe", - "projPath": fncPath, - "popSample": True, - "popMinimal": False, - "popCustom": False, + data = { + "name": "Test Sample", + "author": "Jane Doe", + "path": fncPath, + "sample": True, } - projBuild = ProjectBuilder() + builder = ProjectBuilder() # No path set - assert projBuild.buildProject({"popSample": True}) is False + assert builder.buildProject({"popSample": True}) is False # Force the lookup path for assets to our temp folder srcSample = CONFIG._appRoot / "sample" @@ -538,13 +525,13 @@ def testCoreTools_NewSample(monkeypatch, mockGUI, fncPath, tstPaths): ) # Cannot extract when the zip does not exist - assert projBuild.buildProject(projData) is False + assert builder.buildProject(data) is False # Create and open a defective zip file with open(dstSample, mode="w+") as outFile: outFile.write("foo") - assert projBuild.buildProject(projData) is False + assert builder.buildProject(data) is False dstSample.unlink() # Create a real zip file, and unpack it @@ -553,7 +540,7 @@ def testCoreTools_NewSample(monkeypatch, mockGUI, fncPath, tstPaths): for docFile in (srcSample / "content").iterdir(): zipObj.write(docFile, f"content/{docFile.name}") - assert projBuild.buildProject(projData) is True + assert builder.buildProject(data) is True dstSample.unlink() -# END Test testCoreTools_NewSample +# END Test testCoreTools_ProjectBuilderSample diff --git a/tests/test_core/test_core_storage.py b/tests/test_core/test_core_storage.py index b34d96eb..e2b877db 100644 --- a/tests/test_core/test_core_storage.py +++ b/tests/test_core/test_core_storage.py @@ -32,7 +32,7 @@ from mocked import causeOSError from novelwriter import CONFIG from novelwriter.constants import nwFiles from novelwriter.core.project import NWProject -from novelwriter.core.storage import NWStorage, NWStorageOpen, _LegacyStorage +from novelwriter.core.storage import NWStorage, NWStorageOpen, NWStorageCreate, _LegacyStorage from novelwriter.core.document import NWDocument from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter @@ -67,16 +67,16 @@ def testCoreStorage_CreateNewProject(mockGUI, fncPath): # Cannot prepare a non-empty folder (fncPath / "foobar.txt").touch() - assert storage.createNewProject(fncPath) is False + assert storage.createNewProject(fncPath) == NWStorageCreate.NOT_EMPTY # Try creating in a non-existent subfolder instead - assert storage.createNewProject(fncPath / "project1") is True + assert storage.createNewProject(fncPath / "project1") == NWStorageCreate.READY assert (fncPath / "project1").is_dir() assert (fncPath / "project1" / "meta").is_dir() assert (fncPath / "project1" / "content").is_dir() # However, the parent folder must exist - assert storage.createNewProject(fncPath / "foobar" / "project1") is False + assert storage.createNewProject(fncPath / "foobar" / "project1") == NWStorageCreate.OS_ERROR assert isinstance(storage.exc, FileNotFoundError) project.closeProject() @@ -297,48 +297,6 @@ def testCoreStorage_ZipIt(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd): # END Test testCoreStorage_ZipIt -# @pytest.mark.core -# def testCoreStorage_PrepareStorage(monkeypatch, fncPath): -# """Test the project path preparation functions.""" -# storage = NWStorage(MockProject()) # type: ignore -# assert storage.isOpen() is False - -# # No path set -# assert storage._prepareStorage() is False - -# # Set path to home -# storage._runtimePath = fncPath -# with monkeypatch.context() as mp: -# mp.setattr("pathlib.Path.home", lambda: fncPath) -# assert storage._prepareStorage() is False - -# # Fail on mkdir -# storage._runtimePath = fncPath -# with monkeypatch.context() as mp: -# mp.setattr("pathlib.Path.mkdir", causeOSError) -# assert storage._prepareStorage() is False - -# # Set up the folder -# storage._runtimePath = fncPath -# assert storage._prepareStorage(checkLegacy=False) is True -# assert (fncPath / "content").exists() -# assert (fncPath / "meta").exists() -# assert not (fncPath / "cache").exists() # Removed in 2.1b1 - -# # Add a legacy folder -# storage._runtimePath = fncPath -# dataDir = fncPath / "data_0" -# dataDir.mkdir() -# assert storage._prepareStorage(checkLegacy=True) is True -# assert not dataDir.exists() - -# # We cannot add a new project here -# storage._runtimePath = fncPath -# assert storage._prepareStorage(checkLegacy=False, newProject=True) is False - -# # END Test testCoreStorage_PrepareStorage - - @pytest.mark.core def testCoreStorage_LegacyDataFolder(monkeypatch, fncPath): """Test project file format 1.0 folder structure conversion.""" diff --git a/tests/test_dialogs/test_dlg_projload.py b/tests/test_dialogs/test_dlg_projload.py deleted file mode 100644 index ad1bd49a..00000000 --- a/tests/test_dialogs/test_dlg_projload.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -novelWriter – Project Load Dialog 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 . -""" -from __future__ import annotations - -import pytest - -from tools import buildTestProject, getGuiItem - -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( - QDialogButtonBox, QTreeWidgetItem, QDialog, QAction, QFileDialog -) - -from novelwriter.dialogs.projload import GuiProjectLoad - - -@pytest.mark.gui -def testDlgLoadProject_Main(qtbot, monkeypatch, nwGUI, projPath): - """Test the load project wizard. - """ - buildTestProject(nwGUI, projPath) - assert nwGUI.closeProject() - - monkeypatch.setattr(GuiProjectLoad, "exec_", lambda *a: None) - monkeypatch.setattr(GuiProjectLoad, "result", lambda *a: QDialog.Accepted) - nwGUI.mainMenu.aOpenProject.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiProjectLoad") is not None, timeout=1000) - - nwLoad = getGuiItem("GuiProjectLoad") - assert isinstance(nwLoad, GuiProjectLoad) - nwLoad.show() - - recentCount = nwLoad.listBox.topLevelItemCount() - assert recentCount > 0 - - selItem = nwLoad.listBox.topLevelItem(0) - selPath = selItem.data(nwLoad.C_NAME, Qt.ItemDataRole.UserRole) - assert isinstance(selItem, QTreeWidgetItem) - - nwLoad.selPath.setText("") - nwLoad.listBox.setCurrentItem(selItem) - nwLoad._doSelectRecent() - assert nwLoad.selPath.text() == selPath - - qtbot.mouseClick(nwLoad.buttonBox.button(QDialogButtonBox.Open), Qt.LeftButton) - assert nwLoad.openPath == selPath - assert nwLoad.openState == nwLoad.OPEN_STATE - - # Just create a new project load from scratch for the rest of the test - del nwLoad - - nwGUI.mainMenu.aOpenProject.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiProjectLoad") is not None, timeout=1000) - - nwLoad = getGuiItem("GuiProjectLoad") - assert isinstance(nwLoad, GuiProjectLoad) - nwLoad.show() - - qtbot.mouseClick(nwLoad.buttonBox.button(QDialogButtonBox.Cancel), Qt.LeftButton) - assert nwLoad.openPath is None - assert nwLoad.openState == nwLoad.NONE_STATE - - nwLoad.show() - qtbot.mouseClick(nwLoad.newButton, Qt.LeftButton) - assert nwLoad.openPath is None - assert nwLoad.openState == nwLoad.NEW_STATE - - nwLoad.show() - nwLoad._doDeleteRecent() - assert nwLoad.listBox.topLevelItemCount() == recentCount - 1 - - getFile = str(projPath / "nwProject.nwx") - monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (getFile, None)) - qtbot.mouseClick(nwLoad.browseButton, Qt.LeftButton) - assert nwLoad.openPath == projPath / "nwProject.nwx" - assert nwLoad.openState == nwLoad.OPEN_STATE - - nwLoad.close() - # qtbot.stop() - -# END Test testDlgLoadProject_Main diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index a43e86e8..37492050 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -26,24 +26,22 @@ import pytest from shutil import copyfile from tools import ( - C, NWD_IGNORE, cmpFiles, buildTestProject, XML_IGNORE, getGuiItem, writeFile + C, NWD_IGNORE, cmpFiles, buildTestProject, XML_IGNORE, getGuiItem ) from PyQt5.QtGui import QColor, QPalette from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QDialog, QMenu, QMessageBox, QInputDialog +from PyQt5.QtWidgets import QMenu, QInputDialog from novelwriter import CONFIG, SHARED from novelwriter.enum import nwItemType, nwView, nwWidget -from novelwriter.constants import nwFiles from novelwriter.gui.outline import GuiOutlineView from novelwriter.gui.projtree import GuiProjectTree from novelwriter.gui.doceditor import GuiDocEditor from novelwriter.gui.noveltree import GuiNovelView +from novelwriter.tools.welcome import GuiWelcome from novelwriter.dialogs.about import GuiAbout -from novelwriter.dialogs.projload import GuiProjectLoad from novelwriter.dialogs.editlabel import GuiEditLabel -from novelwriter.tools.projwizard import GuiProjectWizard KEY_DELAY = 1 @@ -70,8 +68,8 @@ def testGuiMain_ProjectBlocker(nwGUI): @pytest.mark.gui def testGuiMain_Launch(qtbot, monkeypatch, nwGUI, projPath): """Test the handling of launch tasks.""" - monkeypatch.setattr(GuiProjectLoad, "exec_", lambda *a: None) - monkeypatch.setattr(GuiProjectLoad, "result", lambda *a: QDialog.Accepted) + monkeypatch.setattr(GuiWelcome, "exec_", lambda *a: None) + # monkeypatch.setattr(GuiProjectLoad, "result", lambda *a: QDialog.Accepted) CONFIG.lastNotes = "0x0" buildTestProject(nwGUI, projPath) @@ -103,53 +101,16 @@ def testGuiMain_Launch(qtbot, monkeypatch, nwGUI, projPath): # Check that project open dialog launches nwGUI.postLaunchTasks(None) - qtbot.waitUntil(lambda: getGuiItem("GuiProjectLoad") is not None, timeout=1000) - nwLoad = getGuiItem("GuiProjectLoad") - assert isinstance(nwLoad, GuiProjectLoad) - nwLoad.show() - nwLoad.reject() + qtbot.waitUntil(lambda: getGuiItem("GuiWelcome") is not None, timeout=1000) + assert isinstance(welcome := getGuiItem("GuiWelcome"), GuiWelcome) + welcome.show() + welcome.close() # qtbot.stop() # END Test testGuiMain_Launch -@pytest.mark.gui -def testGuiMain_NewProject(monkeypatch, nwGUI, projPath): - """Test creating a new project.""" - # Open wizard, but return no data - with monkeypatch.context() as mp: - mp.setattr(GuiProjectWizard, "exec_", lambda *a: None) - assert nwGUI.newProject(projData=None) is False - - # Close project - with monkeypatch.context() as mp: - SHARED.project._valid = True - mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.No) - assert nwGUI.newProject(projData={"projPath": projPath}) is False - SHARED.project._valid = False - - # No project path - assert nwGUI.newProject(projData={}) is False - - # Project file already exists - projFile = projPath / nwFiles.PROJ_FILE - writeFile(projFile, "Stuff") - assert nwGUI.newProject(projData={"projPath": projPath}) is False - projFile.unlink() - - # An unreachable path should also fail - stuffPath = projPath / "stuff" / "stuff" / "stuff" - assert nwGUI.newProject(projData={"projPath": stuffPath}) is False - - # This one should work just fine - assert nwGUI.newProject(projData={"projPath": projPath}) is True - assert (projPath / nwFiles.PROJ_FILE).is_file() - assert (projPath / "content").is_dir() - -# END Test testGuiMain_NewProject - - @pytest.mark.gui def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): """Test handling of project tree items based on GUI focus states.""" diff --git a/tests/test_gui/test_gui_theme.py b/tests/test_gui/test_gui_theme.py index e660c02c..0e0bd14c 100644 --- a/tests/test_gui/test_gui_theme.py +++ b/tests/test_gui/test_gui_theme.py @@ -263,7 +263,7 @@ def testGuiTheme_Syntax(qtbot, monkeypatch, nwGUI): @pytest.mark.gui -def testGuiTheme_Icons(qtbot, caplog, monkeypatch, nwGUI, tstPaths): +def testGuiTheme_IconThemes(qtbot, caplog, monkeypatch, tstPaths): """Test the icon cache class.""" iconCache = SHARED.theme.iconCache @@ -305,38 +305,16 @@ def testGuiTheme_Icons(qtbot, caplog, monkeypatch, nwGUI, tstPaths): assert iconCache.loadTheme("typicons_dark") is True assert "add" in iconCache._themeMap - # Load Decorations - # ================ + # qtbot.stop() - # Invalid name should return empty pixmap - qPix = iconCache.loadDecoration("stuff") - assert qPix.isNull() is True +# END Test testGuiTheme_IconThemes - # Load an image - qPix = iconCache.loadDecoration("wiz-back") - assert qPix.isNull() is False - # Fail finding the file - with monkeypatch.context() as mp: - mp.setattr("pathlib.Path.is_file", lambda *a: False) - qPix = iconCache.loadDecoration("wiz-back") - assert qPix.isNull() is True - - # Test image sizes - qPix = iconCache.loadDecoration("wiz-back", w=100, h=None) - assert qPix.isNull() is False - assert qPix.width() == 100 - assert qPix.height() > 100 - - qPix = iconCache.loadDecoration("wiz-back", w=None, h=100) - assert qPix.isNull() is False - assert qPix.width() < 100 - assert qPix.height() == 100 - - qPix = iconCache.loadDecoration("wiz-back", w=100, h=100) - assert qPix.isNull() is False - assert qPix.width() == 100 - assert qPix.height() == 100 +@pytest.mark.gui +def testGuiTheme_LoadIcons(qtbot): + """Test the icon cache class.""" + iconCache = SHARED.theme.iconCache + assert iconCache.loadTheme("typicons_dark") is True # Load Icons # ========== @@ -368,6 +346,19 @@ def testGuiTheme_Icons(qtbot, caplog, monkeypatch, nwGUI, tstPaths): assert isinstance(qIcon, QIcon) assert qIcon.isNull() is False + # Toggle icon + qIcon = iconCache.getToggleIcon("bullet", (24, 24)) + assert isinstance(qIcon, QIcon) + assert qIcon.isNull() is False + pOn = qIcon.pixmap(24, 24, QIcon.Mode.Normal, QIcon.State.On) + pOff = qIcon.pixmap(24, 24, QIcon.Mode.Normal, QIcon.State.Off) + assert pOn != pOff + + # Unknown toggle icon + qIcon = iconCache.getToggleIcon("stuff", (24, 24)) + assert isinstance(qIcon, QIcon) + assert qIcon.isNull() is True + # Load Item Icons # =============== @@ -421,6 +412,50 @@ def testGuiTheme_Icons(qtbot, caplog, monkeypatch, nwGUI, tstPaths): nwItemType.NO_TYPE, nwItemClass.NOVEL, nwItemLayout.DOCUMENT, hLevel="H0" ).isNull() is True + # qtbot.stop() + +# END Test testGuiTheme_LoadIcons + + +@pytest.mark.gui +def testGuiTheme_LoadDecorations(qtbot, monkeypatch): + """Test the icon cache class.""" + iconCache = SHARED.theme.iconCache + assert iconCache.loadTheme("typicons_dark") is True + + # Load Decorations + # ================ + + # Invalid name should return empty pixmap + qPix = iconCache.loadDecoration("stuff") + assert qPix.isNull() is True + + # Load an image + qPix = iconCache.loadDecoration("welcome") + assert qPix.isNull() is False + + # Fail finding the file + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.is_file", lambda *a: False) + qPix = iconCache.loadDecoration("welcome") + assert qPix.isNull() is True + + # Test image sizes + qPix = iconCache.loadDecoration("welcome", w=100, h=None) + assert qPix.isNull() is False + assert qPix.width() == 100 + assert qPix.height() > 50 + + qPix = iconCache.loadDecoration("welcome", w=None, h=100) + assert qPix.isNull() is False + assert qPix.width() > 100 + assert qPix.height() == 100 + + qPix = iconCache.loadDecoration("welcome", w=100, h=100) + assert qPix.isNull() is False + assert qPix.width() == 100 + assert qPix.height() == 100 + # Header Decorations # ================== @@ -432,6 +467,18 @@ def testGuiTheme_Icons(qtbot, caplog, monkeypatch, nwGUI, tstPaths): assert iconCache.getHeaderDecoration(4) == iconCache._headerDec[4] assert iconCache.getHeaderDecoration(5) == iconCache._headerDec[4] + # Narrow Header Decorations + # ========================= + + assert iconCache.getHeaderDecorationNarrow(-1) == iconCache._headerDecNarrow[0] + assert iconCache.getHeaderDecorationNarrow(0) == iconCache._headerDecNarrow[0] + assert iconCache.getHeaderDecorationNarrow(1) == iconCache._headerDecNarrow[1] + assert iconCache.getHeaderDecorationNarrow(2) == iconCache._headerDecNarrow[2] + assert iconCache.getHeaderDecorationNarrow(3) == iconCache._headerDecNarrow[3] + assert iconCache.getHeaderDecorationNarrow(4) == iconCache._headerDecNarrow[4] + assert iconCache.getHeaderDecorationNarrow(5) == iconCache._headerDecNarrow[5] + assert iconCache.getHeaderDecorationNarrow(6) == iconCache._headerDecNarrow[5] + # qtbot.stop() -# END Test testGuiTheme_Icons +# END Test testGuiTheme_LoadDecorations diff --git a/tests/test_tools/test_tools_projwizard.py b/tests/test_tools/test_tools_projwizard.py deleted file mode 100644 index 65a80687..00000000 --- a/tests/test_tools/test_tools_projwizard.py +++ /dev/null @@ -1,249 +0,0 @@ -""" -novelWriter – New Project Wizard 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 . -""" -from __future__ import annotations - -import sys -import pytest - -from tools import buildTestProject, getGuiItem - -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QFileDialog, QWizard, QDialog - -from novelwriter.enum import nwItemClass -from novelwriter.tools.projwizard import ( - GuiProjectWizard, ProjWizardIntroPage, ProjWizardFolderPage, - ProjWizardPopulatePage, ProjWizardCustomPage, ProjWizardFinalPage -) - - -@pytest.mark.gui -@pytest.mark.skipif(sys.platform.startswith("darwin"), reason="Not running on Darwin") -def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, projPath): - """Test the launch of the project wizard. - Disabled for macOS because the test segfaults on QWizard.show() - """ - # Test New Project Function - # ======================== - - # New with a project open should cause an error - buildTestProject(nwGUI, projPath) - with monkeypatch.context() as mp: - mp.setattr(nwGUI, "closeProject", lambda *a: False) - assert nwGUI.newProject() is False - - # Close project, but call with invalid path - assert nwGUI.closeProject() - with monkeypatch.context() as mp: - mp.setattr(nwGUI, "showNewProjectDialog", lambda *a: None) - assert nwGUI.newProject() is False - - # Now, with an empty dictionary - mp.setattr(nwGUI, "showNewProjectDialog", lambda *a: {}) - assert nwGUI.newProject() is False - - # Now, with a non-empty folder - mp.setattr(nwGUI, "showNewProjectDialog", lambda *a: {"projPath": projPath}) - assert nwGUI.newProject() is False - - # Test the Wizard Launching - # ========================= - - monkeypatch.setattr(GuiProjectWizard, "exec_", lambda *a: None) - - result = nwGUI.showNewProjectDialog() - qtbot.waitUntil(lambda: getGuiItem("GuiProjectWizard") is not None, timeout=1000) - - nwWiz = getGuiItem("GuiProjectWizard") - assert isinstance(nwWiz, GuiProjectWizard) - nwWiz.show() - - qtbot.mouseClick(nwWiz.button(QWizard.CancelButton), Qt.LeftButton) - assert result is None - - with monkeypatch.context() as mp: - mp.setattr(GuiProjectWizard, "result", lambda *a: QDialog.Accepted) - result = nwGUI.showNewProjectDialog() - nwWiz.button(QWizard.CancelButton).click() - assert isinstance(result, dict) - - nwWiz.reject() - nwWiz.close() - - # qtbot.stop() - -# END Test testToolProjectWizard_Handling - - -@pytest.mark.gui -@pytest.mark.parametrize("prjType", ["minimal", "custom1", "custom2", "sample"]) -@pytest.mark.skipif(sys.platform.startswith("darwin"), reason="Not running on Darwin") -def testToolProjectWizard_Run(qtbot, monkeypatch, nwGUI, fncPath, prjType): - """Test the new project wizard with a set of selection scenarios. - """ - monkeypatch.setattr(GuiProjectWizard, "exec_", lambda *a: None) - - nwWiz = GuiProjectWizard(nwGUI) - nwWiz.show() - qtbot.addWidget(nwWiz) - - # Intro Page - # ========== - - introPage = nwWiz.currentPage() - assert isinstance(introPage, ProjWizardIntroPage) - assert not nwWiz.button(QWizard.NextButton).isEnabled() - - introPage.projName.setText("Test Wizard") - introPage.projTitle.setText("My Novel") - introPage.projAuthor.setText("Jane Doe") - - # Setting projName should activate the button - assert nwWiz.button(QWizard.NextButton).isEnabled() - - qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) - - # Folder Page - # =========== - - storagePage = nwWiz.currentPage() - assert isinstance(storagePage, ProjWizardFolderPage) - assert not nwWiz.button(QWizard.NextButton).isEnabled() - assert storagePage.errLabel.text() == "" - - # Set an invalid path - storagePage.projPath.setText(str(fncPath / "not" / "a" / "path")) - assert not nwWiz.button(QWizard.NextButton).isEnabled() - assert storagePage.errLabel.text().startswith("Error") - - # Set an existing path - storagePage.projPath.setText(str(fncPath)) - assert not nwWiz.button(QWizard.NextButton).isEnabled() - assert storagePage.errLabel.text().startswith("Error") - - # Return a non-result from browse - with monkeypatch.context() as mp: - mp.setattr(QFileDialog, "getExistingDirectory", lambda *a, **k: "") - qtbot.mouseClick(storagePage.browseButton, Qt.LeftButton, delay=100) - assert storagePage.errLabel.text() == "" - - # Let the browse feature handle it - projPath = fncPath / "Test Wizard" - with monkeypatch.context() as mp: - mp.setattr(QFileDialog, "getExistingDirectory", lambda *a, **k: str(fncPath)) - qtbot.mouseClick(storagePage.browseButton, Qt.LeftButton, delay=100) - - assert storagePage.projPath.text() == str(projPath) - assert storagePage.errLabel.text() == "" - - # Setting projPath should activate the button - assert nwWiz.button(QWizard.NextButton).isEnabled() - - qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) - - # Populate Page - # ============= - - popPage = nwWiz.currentPage() - assert isinstance(popPage, ProjWizardPopulatePage) - assert nwWiz.button(QWizard.NextButton).isEnabled() - - if prjType.startswith("minimal"): - popPage.popMinimal.setChecked(True) - elif prjType.startswith("custom"): - popPage.popCustom.setChecked(True) - elif prjType.startswith("sample"): - popPage.popSample.setChecked(True) - - qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) - - # Custom Page - # =========== - if prjType.startswith("custom"): - - customPage = nwWiz.currentPage() - assert isinstance(customPage, ProjWizardCustomPage) - assert nwWiz.button(QWizard.NextButton).isEnabled() - - # Make sure the fourth option is also turned off - customPage.addPlot.setChecked(False) - customPage.addChar.setChecked(False) - customPage.addWorld.setChecked(False) - customPage._syncSwitches() - assert not customPage.addNotes.isChecked() - - # Switch everything back on again - customPage.addPlot.setChecked(True) - customPage.addChar.setChecked(True) - customPage.addWorld.setChecked(True) - customPage.addNotes.setChecked(True) - - if prjType == "custom2": - customPage.numChapters.setValue(0) - customPage.numScenes.setValue(10) - - qtbot.mouseClick(nwWiz.button(QWizard.NextButton), Qt.LeftButton) - - # Final Page - # ========== - - finalPage = nwWiz.currentPage() - assert isinstance(finalPage, ProjWizardFinalPage) - assert nwWiz.button(QWizard.FinishButton).isEnabled() # But we don't click it - - # Check Data - # ========== - - projData = nwGUI._assembleProjectWizardData(nwWiz) - assert projData["projName"] == "Test Wizard" - assert projData["projTitle"] == "My Novel" - assert projData["projAuthor"] == "Jane Doe" - assert projData["projPath"] == str(projPath) - assert projData["popMinimal"] == prjType.startswith("minimal") - assert projData["popCustom"] == prjType.startswith("custom") - assert projData["popSample"] == prjType.startswith("sample") - if prjType.startswith("custom"): - assert projData["addRoots"] == [ - nwItemClass.PLOT, - nwItemClass.CHARACTER, - nwItemClass.WORLD, - ] - if prjType == "custom1": - assert projData["numChapters"] == 5 - assert projData["numScenes"] == 5 - assert projData["addNotes"] is True - else: - assert projData["numChapters"] == 0 - assert projData["numScenes"] == 10 - assert projData["addNotes"] is True - else: - assert projData["addRoots"] == [] - assert projData["numChapters"] == 0 - assert projData["numScenes"] == 0 - assert projData["addNotes"] is False - - # Cleanup - nwWiz.reject() - nwWiz.close() - - # qtbot.stop() - -# END Test testToolProjectWizard_Run diff --git a/tests/test_tools/test_tools_welcome.py b/tests/test_tools/test_tools_welcome.py new file mode 100644 index 00000000..518a7104 --- /dev/null +++ b/tests/test_tools/test_tools_welcome.py @@ -0,0 +1,268 @@ +""" +novelWriter – Welcome Window 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 . +""" +from __future__ import annotations + +import pytest + +from pathlib import Path +from datetime import datetime +from pytestqt.qtbot import QtBot + +from PyQt5.QtCore import QPoint, Qt +from PyQt5.QtWidgets import QAction, QFileDialog, QMenu + +from novelwriter import CONFIG, SHARED +from novelwriter.enum import nwItemClass +from novelwriter.constants import nwFiles +from novelwriter.tools.welcome import GuiWelcome + + +@pytest.mark.gui +def testToolWelcome_Main(qtbot: QtBot, monkeypatch, nwGUI, fncPath): + """Test the main Welcome window.""" + welcome = GuiWelcome(nwGUI) + with qtbot.waitExposed(welcome): + # This ensures the paint event is executed + welcome.show() + + # By default, the project list is shown + assert welcome.mainStack.currentIndex() == 0 + + # Show the new project form + welcome.newButton.click() + assert welcome.mainStack.currentIndex() == 1 + + # Revert to project lits + welcome.tabNew.cancelNewProject.emit() + assert welcome.mainStack.currentIndex() == 0 + + # Open a project + with monkeypatch.context() as mp: + mp.setattr(SHARED, "getProjectPath", lambda *a, **k: fncPath) + with qtbot.waitSignal(welcome.openProjectRequest) as signal: + welcome.browseButton.click() + assert signal.args and signal.args[0] == fncPath + + # qtbot.stop() + welcome.close() + +# END Test testToolWelcome_Main + + +@pytest.mark.gui +def testToolWelcome_Open(qtbot: QtBot, monkeypatch, nwGUI, fncPath): + """Test the open tab in the Welcome window.""" + monkeypatch.setattr(QMenu, "exec_", lambda *a: None) + monkeypatch.setattr(QMenu, "deleteLater", lambda *a: None) + + CONFIG.recentProjects.update("/stuff/project_one", "Project One", 12345, 1690000000) + CONFIG.recentProjects.update("/stuff/project_two", "Project Two", 54321, 1700000000) + dateOne = datetime.fromtimestamp(1700000000).strftime("%x") + dateTwo = datetime.fromtimestamp(1690000000).strftime("%x") + + welcome = GuiWelcome(nwGUI) + with qtbot.waitExposed(welcome): + welcome.show() + + assert welcome.mainStack.currentIndex() == 0 + tabOpen = welcome.tabOpen + listModel = tabOpen.listModel + vPort = tabOpen.listWidget.viewport() + + posOne = tabOpen.listWidget.rectForIndex(listModel.createIndex(0, 0)).center() + posTwo = tabOpen.listWidget.rectForIndex(listModel.createIndex(1, 0)).center() + + # Check items, which should be in opposite order + assert listModel.data(listModel.createIndex(0, 0)) == ( + "Project Two", "/stuff/project_two", f"Last Opened: {dateOne}, Word Count: 54.3\u2009k" + ) + assert listModel.data(listModel.createIndex(1, 0)) == ( + "Project One", "/stuff/project_one", f"Last Opened: {dateTwo}, Word Count: 12.3\u2009k" + ) + + # Single click item + assert tabOpen.selectedPath.text() == "Path: /stuff/project_two" + qtbot.mouseClick(vPort, Qt.MouseButton.LeftButton, pos=posTwo, delay=10) + assert tabOpen.selectedPath.text() == "Path: /stuff/project_one" + + # Double Click item + qtbot.mouseClick(vPort, Qt.MouseButton.LeftButton, pos=posTwo, delay=10) + with monkeypatch.context() as mp: + mp.setattr(welcome, "close", lambda *a: None) + with qtbot.waitSignal(welcome.openProjectRequest, timeout=5000) as signal: + qtbot.mouseDClick(vPort, Qt.MouseButton.LeftButton, pos=posTwo, delay=10) + assert signal.args and signal.args[0] == Path("/stuff/project_one") + + # Context Menu + def getMenuForPos(pos: QPoint) -> QMenu | None: + nonlocal tabOpen + tabOpen._openContextMenu(pos) + for obj in tabOpen.children(): + if isinstance(obj, QMenu) and obj.objectName() == "ContextMenu": + return obj + return None + + qtbot.mouseClick(vPort, Qt.MouseButton.LeftButton, pos=posOne, delay=10) + ctxMenu = getMenuForPos(posOne) + assert isinstance(ctxMenu, QMenu) + assert ctxMenu.actions()[0].text() == "Open Project" + assert ctxMenu.actions()[1].text() == "Remove Project" + + # Open item from context menu + with monkeypatch.context() as mp: + mp.setattr(welcome, "close", lambda *a: None) + with qtbot.waitSignal(welcome.openProjectRequest, timeout=5000) as signal: + ctxMenu.actions()[0].activate(QAction.ActionEvent.Trigger) + assert signal.args and signal.args[0] == Path("/stuff/project_two") + + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # Delete item from context menu + ctxMenu = getMenuForPos(posOne) + assert isinstance(ctxMenu, QMenu) + ctxMenu.actions()[1].activate(QAction.ActionEvent.Trigger) + assert len(CONFIG.recentProjects.listEntries()) == 1 + + ctxMenu.setObjectName("") + ctxMenu.deleteLater() + + # Check removing entry error handling + assert listModel.data(listModel.createIndex(1, 0)) == ("", "", "") + assert listModel.removeEntry(listModel.createIndex(1, 0)) is False + with monkeypatch.context() as mp: + mp.setattr(listModel, "data", lambda *a: ("a", "b", "c")) + assert listModel.removeEntry(listModel.createIndex(1, 0)) is False + + # Delete last entry with keypress + qtbot.mouseClick(vPort, Qt.MouseButton.LeftButton, pos=posOne, delay=10) + qtbot.keyClick(tabOpen, Qt.Key.Key_Delete, delay=10) + assert len(CONFIG.recentProjects.listEntries()) == 0 + + # qtbot.stop() + welcome.close() + +# END Test testToolWelcome_Open + + +@pytest.mark.gui +def testToolWelcome_New(qtbot: QtBot, caplog, monkeypatch, nwGUI, fncPath): + """Test the new project tab in the Welcome window.""" + welcome = GuiWelcome(nwGUI) + with qtbot.waitExposed(welcome): + welcome.show() + + welcome.newButton.click() + assert welcome.mainStack.currentIndex() == 1 + tabNew = welcome.tabNew + newForm = tabNew.projectForm + + # Populate the path + with monkeypatch.context() as mp: + mp.setattr(QFileDialog, "getExistingDirectory", lambda *a, **k: fncPath) + newForm.browsePath.click() + assert newForm.projPath.text() == str(fncPath) + + # Turning off root folders should disable notes switch + newForm.addNotes.setChecked(True) + newForm.addPlot.setChecked(False) + newForm.addChar.setChecked(False) + newForm.addWorld.setChecked(False) + newForm._syncSwitches() + assert newForm.addNotes.isChecked() is False + + # Change fill info to sample + newForm.fillSample.trigger() + assert newForm._fillMode == newForm.FILL_SAMPLE + assert newForm.projFill.text() == "Example Project" + assert newForm.addNotes.isEnabled() is False + assert newForm.addPlot.isEnabled() is False + assert newForm.addChar.isEnabled() is False + assert newForm.addWorld.isEnabled() is False + assert newForm.numChapters.isEnabled() is False + assert newForm.numScenes.isEnabled() is False + + # Change fill info to template + with monkeypatch.context() as mp: + mp.setattr(SHARED, "getProjectPath", lambda *a, **k: fncPath) + newForm.fillCopy.trigger() + assert newForm._fillMode == newForm.FILL_COPY + assert newForm.projFill.text() == f"Template: {fncPath}" + assert newForm.addNotes.isEnabled() is False + assert newForm.addPlot.isEnabled() is False + assert newForm.addChar.isEnabled() is False + assert newForm.addWorld.isEnabled() is False + assert newForm.numChapters.isEnabled() is False + assert newForm.numScenes.isEnabled() is False + + # Change back to fill blank using the menu + newForm.browseFill.click() + assert newForm.fillMenu.isVisible() is True + newForm.fillMenu.actions()[0].activate(QAction.ActionEvent.Trigger) + newForm.fillMenu.close() + assert newForm._fillMode == newForm.FILL_BLANK + assert newForm.projFill.text() == "Fresh Project" + assert newForm.addNotes.isEnabled() is True + assert newForm.addPlot.isEnabled() is True + assert newForm.addChar.isEnabled() is True + assert newForm.addWorld.isEnabled() is True + assert newForm.numChapters.isEnabled() is True + assert newForm.numScenes.isEnabled() is True + + # Creating a project without a name, pops an error + caplog.clear() + tabNew.createButton.click() + assert "A project name is required." in caplog.text + + # Set some more values, and extract data + projPath = fncPath / "Test Project" + newForm.projName.setText("Test Project") + newForm.projAuthor.setText("Jane Smith") + newForm.addPlot.setChecked(True) + newForm.addChar.setChecked(True) + newForm.addWorld.setChecked(True) + newForm.addNotes.setChecked(True) + newForm.numChapters.setValue(10) + newForm.numScenes.setValue(6) + assert newForm.getProjectData() == { + "name": "Test Project", + "author": "Jane Smith", + "language": "en_GB", + "path": str(projPath), + "blank": True, + "sample": False, + "template": None, + "chapters": 10, + "scenes": 6, + "roots": [nwItemClass.PLOT, nwItemClass.CHARACTER, nwItemClass.WORLD], + "notes": True, + } + + # Create a project with these values + with qtbot.waitSignal(welcome.openProjectRequest, timeout=5000) as signal: + tabNew.createButton.click() + assert signal.args and signal.args[0] == projPath + assert (projPath / nwFiles.PROJ_FILE).exists() + + # qtbot.stop() + welcome.close() + +# END Test testToolWelcome_New