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