Add a welcome dialog (#1647)

This commit is contained in:
Veronica Berglyd Olsen
2023-12-31 17:01:18 +01:00
committed by GitHub
39 changed files with 1538 additions and 1772 deletions
@@ -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
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m15.667 12h-2.4444v-2.4444c0-0.67589-0.54633-1.2222-1.2222-1.2222s-1.2222 0.54633-1.2222 1.2222v2.4444h-2.4444c-0.67589 0-1.2222 0.54633-1.2222 1.2222s0.54633 1.2222 1.2222 1.2222h2.4444v2.4444c0 0.67589 0.54633 1.2222 1.2222 1.2222s1.2222-0.54633 1.2222-1.2222v-2.4444h2.4444c0.67589 0 1.2222-0.54633 1.2222-1.2222s-0.54633-1.2222-1.2222-1.2222zm5.753-5.753-4.8889-4.8889c-0.22856-0.22978-0.539-0.35811-0.86411-0.35811h-9.7778c-2.0216 0-3.6667 1.6451-3.6667 3.6667v14.667c0 2.0216 1.6451 3.6667 3.6667 3.6667h12.222c2.0216 0 3.6667-1.6451 3.6667-3.6667v-12.222c0-0.32511-0.12833-0.63556-0.35811-0.86411zm-2.5923 0.86411h-1.3273c-1.0108 0-1.8333-0.82256-1.8333-1.8333v-1.3273zm-0.71622 13.444h-12.222c-0.67467 0-1.2222-0.54756-1.2222-1.2222v-14.667c0-0.67467 0.54756-1.2222 1.2222-1.2222h8.5556v1.8333c0 1.6854 1.3701 3.0556 3.0556 3.0556h1.8333v11c0 0.67467-0.54756 1.2222-1.2222 1.2222z" fill="#aeaeae" stroke-width="1.2222"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m21.42 6.247-4.8889-4.8889c-0.22856-0.22978-0.539-0.35811-0.86411-0.35811h-9.7778c-2.0216 0-3.6667 1.6451-3.6667 3.6667v14.667c0 2.0216 1.6451 3.6667 3.6667 3.6667h12.222c2.0216 0 3.6667-1.6451 3.6667-3.6667v-12.222c0-0.32511-0.12833-0.63556-0.35811-0.86411zm-2.5923 0.86411h-1.3273c-1.0108 0-1.8333-0.82256-1.8333-1.8333v-1.3273zm-0.71622 13.444h-12.222c-0.67467 0-1.2222-0.54756-1.2222-1.2222v-14.667c0-0.67467 0.54756-1.2222 1.2222-1.2222h8.5556v1.8333c0 1.6854 1.3701 3.0556 3.0556 3.0556h1.8333v11c0 0.67467-0.54756 1.2222-1.2222 1.2222z" fill="#aeaeae" stroke-width="1.2222"/>
</svg>

After

Width:  |  Height:  |  Size: 737 B

@@ -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
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m15.667 12h-2.4444v-2.4444c0-0.67589-0.54633-1.2222-1.2222-1.2222s-1.2222 0.54633-1.2222 1.2222v2.4444h-2.4444c-0.67589 0-1.2222 0.54633-1.2222 1.2222s0.54633 1.2222 1.2222 1.2222h2.4444v2.4444c0 0.67589 0.54633 1.2222 1.2222 1.2222s1.2222-0.54633 1.2222-1.2222v-2.4444h2.4444c0.67589 0 1.2222-0.54633 1.2222-1.2222s-0.54633-1.2222-1.2222-1.2222zm5.753-5.753-4.8889-4.8889c-0.22856-0.22978-0.539-0.35811-0.86411-0.35811h-9.7778c-2.0216 0-3.6667 1.6451-3.6667 3.6667v14.667c0 2.0216 1.6451 3.6667 3.6667 3.6667h12.222c2.0216 0 3.6667-1.6451 3.6667-3.6667v-12.222c0-0.32511-0.12833-0.63556-0.35811-0.86411zm-2.5923 0.86411h-1.3273c-1.0108 0-1.8333-0.82256-1.8333-1.8333v-1.3273zm-0.71622 13.444h-12.222c-0.67467 0-1.2222-0.54756-1.2222-1.2222v-14.667c0-0.67467 0.54756-1.2222 1.2222-1.2222h8.5556v1.8333c0 1.6854 1.3701 3.0556 3.0556 3.0556h1.8333v11c0 0.67467-0.54756 1.2222-1.2222 1.2222z" fill-opacity=".78039" stroke-width="1.2222"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m21.42 6.247-4.8889-4.8889c-0.22856-0.22978-0.539-0.35811-0.86411-0.35811h-9.7778c-2.0216 0-3.6667 1.6451-3.6667 3.6667v14.667c0 2.0216 1.6451 3.6667 3.6667 3.6667h12.222c2.0216 0 3.6667-1.6451 3.6667-3.6667v-12.222c0-0.32511-0.12833-0.63556-0.35811-0.86411zm-2.5923 0.86411h-1.3273c-1.0108 0-1.8333-0.82256-1.8333-1.8333v-1.3273zm-0.71622 13.444h-12.222c-0.67467 0-1.2222-0.54756-1.2222-1.2222v-14.667c0-0.67467 0.54756-1.2222 1.2222-1.2222h8.5556v1.8333c0 1.6854 1.3701 3.0556 3.0556 3.0556h1.8333v11c0 0.67467-0.54756 1.2222-1.2222 1.2222z" fill-opacity=".78039" stroke-width="1.2222"/>
</svg>

After

Width:  |  Height:  |  Size: 744 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

+35 -36
View File
@@ -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 = "<br>".join(self._errData)
message = "<br>".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
+100 -115
View File
@@ -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. "
+1 -1
View File
@@ -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",
+13 -4
View File
@@ -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."""
-294
View File
@@ -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 20182023, 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 <https://www.gnu.org/licenses/>.
"""
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("<b>%s</b>" % self.tr("Recently Opened Projects"))
self.lblPath = QLabel("<b>%s</b>" % 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
+13 -11
View File
@@ -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)
+3 -7
View File
@@ -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"))
+1 -2
View File
@@ -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)
+22 -17
View File
@@ -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:
+18 -108
View File
@@ -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.
+16 -1
View File
@@ -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
##
-478
View File
@@ -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 20182023, 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 <https://www.gnu.org/licenses/>.
"""
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(
"<p>%s</p><p>&nbsp;&bull;&nbsp;%s</p><p>%s</p>" % (
self.tr("You have selected the following:"),
"<br>&nbsp;&bull;&nbsp;".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
+777
View File
@@ -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 20182023, 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 <https://www.gnu.org/licenses/>.
"""
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("<b>{0}</b>".format(self.tr("Create New Project"))))
self.formBox.addLayout(self.projectForm)
self.formBox.addSpacing(16)
self.formBox.addWidget(QLabel("<b>{0}</b>".format(self.tr("Chapters and Scenes"))))
self.formBox.addLayout(self.novelForm)
self.formBox.addSpacing(16)
self.formBox.addWidget(QLabel("<b>{0}</b>".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
-1
View File
@@ -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()
+2 -2
View File
@@ -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
@@ -1,66 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.0.7" hexVersion="0x020007f0" fileVersion="1.5" fileRevision="1" timeStamp="2023-06-03 18:18:22">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="1" autoCount="0" editTime="0">
<name>New Project</name>
<title>New Project</title>
<author />
</project>
<settings>
<doBackup>yes</doBackup>
<language>en_GB</language>
<spellChecking auto="no">None</spellChecking>
<lastHandle>
<entry key="editor">None</entry>
<entry key="viewer">None</entry>
<entry key="novelTree">None</entry>
<entry key="outline">None</entry>
</lastHandle>
<autoReplace />
<status>
<entry key="s000000" count="5" red="100" green="100" blue="100">New</entry>
<entry key="s000001" count="0" red="200" green="50" blue="0">Note</entry>
<entry key="s000002" count="0" red="200" green="150" blue="0">Draft</entry>
<entry key="s000003" count="0" red="50" green="200" blue="0">Finished</entry>
</status>
<importance>
<entry key="i000004" count="3" red="100" green="100" blue="100">New</entry>
<entry key="i000005" count="0" red="200" green="50" blue="0">Minor</entry>
<entry key="i000006" count="0" red="200" green="150" blue="0">Major</entry>
<entry key="i000007" count="0" red="50" green="200" blue="0">Main</entry>
</importance>
</settings>
<content items="8" novelWords="0" notesWords="0">
<item handle="0000000000008" parent="None" root="0000000000008" order="0" type="ROOT" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">Novel</name>
</item>
<item handle="0000000000009" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Title Page</name>
</item>
<item handle="000000000000a" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">New Chapter</name>
</item>
<item handle="000000000000b" parent="000000000000a" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">New Scene</name>
</item>
<item handle="000000000000c" parent="None" root="000000000000c" order="0" type="ROOT" class="PLOT">
<meta expanded="no" />
<name status="s000000" import="i000004">Plot</name>
</item>
<item handle="000000000000d" parent="None" root="000000000000d" order="0" type="ROOT" class="CHARACTER">
<meta expanded="no" />
<name status="s000000" import="i000004">Characters</name>
</item>
<item handle="000000000000e" parent="None" root="000000000000e" order="0" type="ROOT" class="WORLD">
<meta expanded="no" />
<name status="s000000" import="i000004">Locations</name>
</item>
<item handle="000000000000f" parent="None" root="000000000000f" order="0" type="ROOT" class="ARCHIVE">
<meta expanded="no" />
<name status="s000000" import="i000004">Archive</name>
</item>
</content>
</novelWriterXML>
@@ -1,8 +1,8 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.0.7" hexVersion="0x020007f0" fileVersion="1.5" fileRevision="1" timeStamp="2023-06-03 18:18:22">
<novelWriterXML appVersion="2.3-alpha1" hexVersion="0x020300a1" fileVersion="1.5" fileRevision="1" timeStamp="2023-12-29 14:34:07">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="1" autoCount="0" editTime="0">
<name>Test Custom</name>
<title>Test Novel</title>
<name>Test Project A</name>
<title>Test Project A</title>
<author>Jane Doe</author>
</project>
<settings>
@@ -1,8 +1,8 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.0.7" hexVersion="0x020007f0" fileVersion="1.5" fileRevision="1" timeStamp="2023-06-03 18:18:22">
<novelWriterXML appVersion="2.3-alpha1" hexVersion="0x020300a1" fileVersion="1.5" fileRevision="1" timeStamp="2023-12-29 14:36:07">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="1" autoCount="0" editTime="0">
<name>Test Custom</name>
<title>Test Novel</title>
<name>Test Project B</name>
<title>Test Project B</title>
<author>Jane Doe</author>
</project>
<settings>
@@ -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
+13 -13
View File
@@ -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])
+7 -2
View File
@@ -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
+119 -132
View File
@@ -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
+4 -46
View File
@@ -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."""
-99
View File
@@ -1,99 +0,0 @@
"""
novelWriter Project Load Dialog Class Tester
==============================================
This file is a part of novelWriter
Copyright 20182023, 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 <https://www.gnu.org/licenses/>.
"""
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
+9 -48
View File
@@ -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."""
+78 -31
View File
@@ -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
-249
View File
@@ -1,249 +0,0 @@
"""
novelWriter New Project Wizard Class Tester
=============================================
This file is a part of novelWriter
Copyright 20182023, 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 <https://www.gnu.org/licenses/>.
"""
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
+268
View File
@@ -0,0 +1,268 @@
"""
novelWriter Welcome Window Tester
===================================
This file is a part of novelWriter
Copyright 20182023, 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 <https://www.gnu.org/licenses/>.
"""
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