Add a welcome dialog (#1647)
@@ -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 |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 116 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
"""
|
||||
novelWriter – GUI Open Project
|
||||
==============================
|
||||
|
||||
File History:
|
||||
Created: 2020-02-26 [0.4.5] GuiProjectLoad
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2023, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
##
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
"""
|
||||
novelWriter – GUI New Project Wizard
|
||||
====================================
|
||||
|
||||
File History:
|
||||
Created: 2020-07-11 [0.10.1] GuiProjectWizard
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2023, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <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> • %s</p><p>%s</p>" % (
|
||||
self.tr("You have selected the following:"),
|
||||
"<br> • ".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
|
||||
@@ -0,0 +1,777 @@
|
||||
"""
|
||||
novelWriter – GUI Welcome Dialog
|
||||
================================
|
||||
|
||||
File History:
|
||||
Created: 2023-12-14 [2.3b1] GuiWelcome
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2023, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""
|
||||
novelWriter – Project Load Dialog Class Tester
|
||||
==============================================
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2023, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
"""
|
||||
novelWriter – New Project Wizard Class Tester
|
||||
=============================================
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2023, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <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
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
novelWriter – Welcome Window Tester
|
||||
===================================
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2023, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <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
|
||||