Add a welcome dialog (#1647)
@@ -17,6 +17,7 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/
|
|||||||
|
|
||||||
[Map]
|
[Map]
|
||||||
add = typ_plus.svg
|
add = typ_plus.svg
|
||||||
|
add_document = typ_document-add.svg
|
||||||
alert_error = typ_delete-full.svg
|
alert_error = typ_delete-full.svg
|
||||||
alert_info = typ_lightbulb-full.svg
|
alert_info = typ_lightbulb-full.svg
|
||||||
alert_question = typ_directions-full.svg
|
alert_question = typ_directions-full.svg
|
||||||
@@ -43,6 +44,7 @@ cls_timeline = typ_calendar.svg
|
|||||||
cls_trash = typ_trash.svg
|
cls_trash = typ_trash.svg
|
||||||
cls_world = typ_location.svg
|
cls_world = typ_location.svg
|
||||||
cross = typ_times.svg
|
cross = typ_times.svg
|
||||||
|
document = typ_document.svg
|
||||||
down = typ_chevron-down.svg
|
down = typ_chevron-down.svg
|
||||||
edit = typ_pencil.svg
|
edit = typ_pencil.svg
|
||||||
export = typ_export.svg
|
export = typ_export.svg
|
||||||
@@ -83,6 +85,7 @@ search_regex = nw_search-regex.svg
|
|||||||
search_replace = mixed_search-replace.svg
|
search_replace = mixed_search-replace.svg
|
||||||
search_word = nw_search-word.svg
|
search_word = nw_search-word.svg
|
||||||
settings = typ_cog.svg
|
settings = typ_cog.svg
|
||||||
|
star = typ_star.svg
|
||||||
status_idle = typ_media-pause-grey.svg
|
status_idle = typ_media-pause-grey.svg
|
||||||
status_lang = typ_globe-grey.svg
|
status_lang = typ_globe-grey.svg
|
||||||
status_lines = typ_th-list-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]
|
[Map]
|
||||||
add = typ_plus.svg
|
add = typ_plus.svg
|
||||||
|
add_document = typ_document-add.svg
|
||||||
alert_error = typ_delete-full.svg
|
alert_error = typ_delete-full.svg
|
||||||
alert_info = typ_lightbulb-full.svg
|
alert_info = typ_lightbulb-full.svg
|
||||||
alert_question = typ_directions-full.svg
|
alert_question = typ_directions-full.svg
|
||||||
@@ -43,6 +44,7 @@ cls_timeline = typ_calendar.svg
|
|||||||
cls_trash = typ_trash.svg
|
cls_trash = typ_trash.svg
|
||||||
cls_world = typ_location.svg
|
cls_world = typ_location.svg
|
||||||
cross = typ_times.svg
|
cross = typ_times.svg
|
||||||
|
document = typ_document.svg
|
||||||
down = typ_chevron-down.svg
|
down = typ_chevron-down.svg
|
||||||
edit = typ_pencil.svg
|
edit = typ_pencil.svg
|
||||||
export = typ_export.svg
|
export = typ_export.svg
|
||||||
@@ -83,6 +85,7 @@ search_regex = nw_search-regex.svg
|
|||||||
search_replace = mixed_search-replace.svg
|
search_replace = mixed_search-replace.svg
|
||||||
search_word = nw_search-word.svg
|
search_word = nw_search-word.svg
|
||||||
settings = typ_cog.svg
|
settings = typ_cog.svg
|
||||||
|
star = typ_star.svg
|
||||||
status_idle = typ_media-pause-grey.svg
|
status_idle = typ_media-pause-grey.svg
|
||||||
status_lang = typ_globe-grey.svg
|
status_lang = typ_globe-grey.svg
|
||||||
status_lines = typ_th-list-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
|
# Size Settings
|
||||||
self._mainWinSize = [1200, 650] # Last size of the main GUI window
|
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._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._mainPanePos = [300, 800] # Last position of the main window splitter
|
||||||
self._viewPanePos = [500, 150] # Last position of the document viewer splitter
|
self._viewPanePos = [500, 150] # Last position of the document viewer splitter
|
||||||
self._outlnPanePos = [500, 150] # Last position of the outline panel 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]
|
return [int(x*self.guiScale) for x in self._mainWinSize]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def preferencesWinSize(self) -> list[int]:
|
def welcomeWinSize(self) -> list[int]:
|
||||||
return [int(x*self.guiScale) for x in self._prefsWinSize]
|
return [int(x*self.guiScale) for x in self._welcomeSize]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def projLoadColWidths(self) -> list[int]:
|
def preferencesWinSize(self) -> list[int]:
|
||||||
return [int(x*self.guiScale) for x in self._projLoadCols]
|
return [int(x*self.guiScale) for x in self._prefsWinSize]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mainPanePos(self) -> list[int]:
|
def mainPanePos(self) -> list[int]:
|
||||||
@@ -306,17 +306,18 @@ class Config:
|
|||||||
self._mainWinSize[1] = height
|
self._mainWinSize[1] = height
|
||||||
return
|
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:
|
def setPreferencesWinSize(self, width: int, height: int) -> None:
|
||||||
"""Set the size of the Preferences dialog window."""
|
"""Set the size of the Preferences dialog window."""
|
||||||
self._prefsWinSize[0] = int(width/self.guiScale)
|
self._prefsWinSize[0] = int(width/self.guiScale)
|
||||||
self._prefsWinSize[1] = int(height/self.guiScale)
|
self._prefsWinSize[1] = int(height/self.guiScale)
|
||||||
return
|
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:
|
def setMainPanePos(self, pos: list[int]) -> None:
|
||||||
"""Set the position of the main GUI splitter."""
|
"""Set the position of the main GUI splitter."""
|
||||||
self._mainPanePos = [int(x/self.guiScale) for x in pos]
|
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
|
"""Compile and return error messages from the initialisation of
|
||||||
the Config class, and clear the error buffer.
|
the Config class, and clear the error buffer.
|
||||||
"""
|
"""
|
||||||
errMessage = "<br>".join(self._errData)
|
message = "<br>".join(self._errData)
|
||||||
self._hasError = False
|
self._hasError = False
|
||||||
self._errData = []
|
self._errData = []
|
||||||
return errMessage
|
return message
|
||||||
|
|
||||||
def listLanguages(self, lngSet: int) -> list[tuple[str, str]]:
|
def listLanguages(self, lngSet: int) -> list[tuple[str, str]]:
|
||||||
"""List localisation files in the i18n folder. The default GUI
|
"""List localisation files in the i18n folder. The default GUI
|
||||||
@@ -545,8 +546,8 @@ class Config:
|
|||||||
# Sizes
|
# Sizes
|
||||||
sec = "Sizes"
|
sec = "Sizes"
|
||||||
self._mainWinSize = conf.rdIntList(sec, "mainwindow", self._mainWinSize)
|
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._prefsWinSize = conf.rdIntList(sec, "preferences", self._prefsWinSize)
|
||||||
self._projLoadCols = conf.rdIntList(sec, "projloadcols", self._projLoadCols)
|
|
||||||
self._mainPanePos = conf.rdIntList(sec, "mainpane", self._mainPanePos)
|
self._mainPanePos = conf.rdIntList(sec, "mainpane", self._mainPanePos)
|
||||||
self._viewPanePos = conf.rdIntList(sec, "viewpane", self._viewPanePos)
|
self._viewPanePos = conf.rdIntList(sec, "viewpane", self._viewPanePos)
|
||||||
self._outlnPanePos = conf.rdIntList(sec, "outlinepane", self._outlnPanePos)
|
self._outlnPanePos = conf.rdIntList(sec, "outlinepane", self._outlnPanePos)
|
||||||
@@ -635,7 +636,7 @@ class Config:
|
|||||||
conf = NWConfigParser()
|
conf = NWConfigParser()
|
||||||
|
|
||||||
conf["Meta"] = {
|
conf["Meta"] = {
|
||||||
"timestamp": formatTimeStamp(time()),
|
"timestamp": formatTimeStamp(time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
conf["Main"] = {
|
conf["Main"] = {
|
||||||
@@ -651,12 +652,12 @@ class Config:
|
|||||||
}
|
}
|
||||||
|
|
||||||
conf["Sizes"] = {
|
conf["Sizes"] = {
|
||||||
"mainwindow": self._packList(self._mainWinSize),
|
"mainwindow": self._packList(self._mainWinSize),
|
||||||
"preferences": self._packList(self._prefsWinSize),
|
"welcome": self._packList(self._welcomeSize),
|
||||||
"projloadcols": self._packList(self._projLoadCols),
|
"preferences": self._packList(self._prefsWinSize),
|
||||||
"mainpane": self._packList(self._mainPanePos),
|
"mainpane": self._packList(self._mainPanePos),
|
||||||
"viewpane": self._packList(self._viewPanePos),
|
"viewpane": self._packList(self._viewPanePos),
|
||||||
"outlinepane": self._packList(self._outlnPanePos),
|
"outlinepane": self._packList(self._outlnPanePos),
|
||||||
}
|
}
|
||||||
|
|
||||||
conf["Project"] = {
|
conf["Project"] = {
|
||||||
@@ -774,22 +775,20 @@ class RecentProjects:
|
|||||||
self._data = {}
|
self._data = {}
|
||||||
|
|
||||||
cacheFile = self._conf.dataPath(nwFiles.RECENT_FILE)
|
cacheFile = self._conf.dataPath(nwFiles.RECENT_FILE)
|
||||||
if not cacheFile.is_file():
|
if cacheFile.is_file():
|
||||||
return True
|
try:
|
||||||
|
with open(cacheFile, mode="r", encoding="utf-8") as inFile:
|
||||||
try:
|
data = json.load(inFile)
|
||||||
with open(cacheFile, mode="r", encoding="utf-8") as inFile:
|
for path, entry in data.items():
|
||||||
theData = json.load(inFile)
|
self._data[path] = {
|
||||||
for projPath, theEntry in theData.items():
|
"title": entry.get("title", ""),
|
||||||
self._data[projPath] = {
|
"words": entry.get("words", 0),
|
||||||
"title": theEntry.get("title", ""),
|
"time": entry.get("time", 0),
|
||||||
"words": theEntry.get("words", 0),
|
}
|
||||||
"time": theEntry.get("time", 0),
|
except Exception:
|
||||||
}
|
logger.error("Could not load recent project cache")
|
||||||
except Exception:
|
logException()
|
||||||
logger.error("Could not load recent project cache")
|
return False
|
||||||
logException()
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import shutil
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
from pathlib import Path
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
from PyQt5.QtCore import QCoreApplication
|
from PyQt5.QtCore import QCoreApplication
|
||||||
@@ -39,6 +40,7 @@ from novelwriter.common import minmax, simplified
|
|||||||
from novelwriter.constants import nwItemClass
|
from novelwriter.constants import nwItemClass
|
||||||
from novelwriter.core.item import NWItem
|
from novelwriter.core.item import NWItem
|
||||||
from novelwriter.core.project import NWProject
|
from novelwriter.core.project import NWProject
|
||||||
|
from novelwriter.core.storage import NWStorageCreate
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -310,55 +312,69 @@ class ProjectBuilder:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
self._path = None
|
||||||
self.tr = partial(QCoreApplication.translate, "NWProject")
|
self.tr = partial(QCoreApplication.translate, "NWProject")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@property
|
||||||
|
def projPath(self) -> Path | None:
|
||||||
|
"""The path of the newly created project."""
|
||||||
|
return self._path
|
||||||
|
|
||||||
##
|
##
|
||||||
# Methods
|
# Methods
|
||||||
##
|
##
|
||||||
|
|
||||||
def buildProject(self, data: dict) -> bool:
|
def buildProject(self, data: dict) -> bool:
|
||||||
"""Build a project from a data dictionary of specifications
|
"""Build or copy a project from a data dictionary."""
|
||||||
provided by the wizard.
|
if isinstance(data, dict):
|
||||||
"""
|
path = data.get("path", None) or None
|
||||||
if not isinstance(data, dict):
|
if isinstance(path, (str, Path)):
|
||||||
logger.error("Invalid call to newProject function")
|
self._path = Path(path).resolve()
|
||||||
return False
|
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)
|
# Internal Functions
|
||||||
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
|
|
||||||
|
|
||||||
|
def _buildAndPopulate(self, path: Path, data: dict) -> bool:
|
||||||
|
"""Build a blank project from a data dictionary."""
|
||||||
project = NWProject()
|
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
|
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")
|
lblNewProject = self.tr("New Project")
|
||||||
lblNewChapter = self.tr("New Chapter")
|
|
||||||
lblNewScene = self.tr("New Scene")
|
|
||||||
lblTitlePage = self.tr("Title Page")
|
lblTitlePage = self.tr("Title Page")
|
||||||
lblByAuthors = self.tr("By")
|
lblByAuthors = self.tr("By")
|
||||||
|
|
||||||
# Settings
|
# Settings
|
||||||
projName = data.get("projName", lblNewProject)
|
projName = data.get("name", lblNewProject)
|
||||||
projTitle = data.get("projTitle", lblNewProject)
|
projAuthor = data.get("author", "")
|
||||||
projAuthor = data.get("projAuthor", "")
|
projLang = data.get("language", "en_GB")
|
||||||
projLang = data.get("projLang", "en_GB")
|
|
||||||
|
|
||||||
project.data.setUuid(None)
|
project.data.setUuid(None)
|
||||||
project.data.setName(projName)
|
project.data.setName(projName)
|
||||||
project.data.setTitle(projTitle)
|
project.data.setTitle(projName)
|
||||||
project.data.setAuthor(projAuthor)
|
project.data.setAuthor(projAuthor)
|
||||||
project.data.setLanguage(projLang)
|
project.data.setLanguage(projLang)
|
||||||
project.setDefaultStatusImport()
|
project.setDefaultStatusImport()
|
||||||
@@ -376,116 +392,85 @@ class ProjectBuilder:
|
|||||||
aDoc = project.storage.getDocument(hTitlePage)
|
aDoc = project.storage.getDocument(hTitlePage)
|
||||||
aDoc.writeDocument(titlePage)
|
aDoc.writeDocument(titlePage)
|
||||||
|
|
||||||
if popMinimal:
|
# Create a project structure based on selected root folders
|
||||||
# Creating a minimal project with a few root folders and a
|
# and a number of chapters and scenes selected in the
|
||||||
# single chapter with a single scene.
|
# wizard's custom page.
|
||||||
hChapter = project.newFile(lblNewChapter, hNovelRoot)
|
|
||||||
aDoc = project.storage.getDocument(hChapter)
|
|
||||||
aDoc.writeDocument(f"## {lblNewChapter}\n\n")
|
|
||||||
|
|
||||||
if hChapter:
|
# Create chapters and scenes
|
||||||
hScene = project.newFile(lblNewScene, hChapter)
|
numChapters = data.get("chapters", 0)
|
||||||
aDoc = project.storage.getDocument(hScene)
|
numScenes = data.get("scenes", 0)
|
||||||
aDoc.writeDocument(f"### {lblNewScene}\n\n")
|
|
||||||
|
|
||||||
project.newRoot(nwItemClass.PLOT)
|
chSynop = self.tr("Summary of the chapter.")
|
||||||
project.newRoot(nwItemClass.CHARACTER)
|
scSynop = self.tr("Summary of the scene.")
|
||||||
project.newRoot(nwItemClass.WORLD)
|
bfNote = self.tr("A short description.")
|
||||||
project.newRoot(nwItemClass.ARCHIVE)
|
|
||||||
|
|
||||||
project.saveProject()
|
# Create chapters
|
||||||
project.closeProject()
|
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 chapter scenes
|
||||||
# Create a project structure based on selected root folders
|
if numScenes > 0 and cHandle:
|
||||||
# and a number of chapters and scenes selected in the
|
for sc in range(numScenes):
|
||||||
# wizard's custom page.
|
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
|
# Create scenes (no chapters)
|
||||||
numChapters = data.get("numChapters", 0)
|
elif numScenes > 0:
|
||||||
numScenes = data.get("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.")
|
# Create notes folders
|
||||||
scSynop = self.tr("Summary of the scene.")
|
noteTitles = {
|
||||||
bfNote = self.tr("A short description.")
|
nwItemClass.PLOT: self.tr("Main Plot"),
|
||||||
|
nwItemClass.CHARACTER: self.tr("Protagonist"),
|
||||||
|
nwItemClass.WORLD: self.tr("Main Location"),
|
||||||
|
}
|
||||||
|
|
||||||
# Create chapters
|
addNotes = data.get("notes", False)
|
||||||
if numChapters > 0:
|
for newRoot in data.get("roots", []):
|
||||||
for ch in range(numChapters):
|
if newRoot in nwItemClass:
|
||||||
chTitle = self.tr("Chapter {0}").format(f"{ch+1:d}")
|
rHandle = project.newRoot(newRoot)
|
||||||
cHandle = project.newFile(chTitle, hNovelRoot)
|
if addNotes:
|
||||||
aDoc = project.storage.getDocument(cHandle)
|
aHandle = project.newFile(noteTitles[newRoot], rHandle)
|
||||||
aDoc.writeDocument(f"## {chTitle}\n\n% Synopsis: {chSynop}\n\n")
|
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
|
# Also add the archive and trash folders
|
||||||
if numScenes > 0 and cHandle:
|
project.newRoot(nwItemClass.ARCHIVE)
|
||||||
for sc in range(numScenes):
|
project.trashFolder()
|
||||||
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 scenes (no chapters)
|
project.saveProject()
|
||||||
elif numScenes > 0:
|
project.closeProject()
|
||||||
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()
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
##
|
def _extractSampleProject(self, path: Path) -> bool:
|
||||||
# Internal Functions
|
|
||||||
##
|
|
||||||
|
|
||||||
def _extractSampleProject(self, data: dict) -> bool:
|
|
||||||
"""Make a copy of the sample project by extracting the
|
"""Make a copy of the sample project by extracting the
|
||||||
sample.zip file to the new path.
|
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")
|
pkgSample = CONFIG.assetPath("sample.zip")
|
||||||
if pkgSample.is_file():
|
if pkgSample.is_file():
|
||||||
try:
|
try:
|
||||||
shutil.unpack_archive(pkgSample, projPath)
|
shutil.unpack_archive(pkgSample, path)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
SHARED.error(self.tr(
|
SHARED.error(self.tr(
|
||||||
"Failed to create a new example project."
|
"Failed to create a new example project."
|
||||||
), exc=exc)
|
), exc=exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
else:
|
else:
|
||||||
SHARED.error(self.tr(
|
SHARED.error(self.tr(
|
||||||
"Failed to create a new example project. "
|
"Failed to create a new example project. "
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
NWEnum = TypeVar("NWEnum", bound=Enum)
|
NWEnum = TypeVar("NWEnum", bound=Enum)
|
||||||
|
|
||||||
VALID_MAP = {
|
VALID_MAP: dict[str, set[str]] = {
|
||||||
"GuiWritingStats": {
|
"GuiWritingStats": {
|
||||||
"winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2",
|
"winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2",
|
||||||
"widthCol3", "sortCol", "sortOrder", "incNovel", "incNotes",
|
"widthCol3", "sortCol", "sortOrder", "incNovel", "incNotes",
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ class NWStorageOpen(Enum):
|
|||||||
# END Enum NWStorageOpen
|
# END Enum NWStorageOpen
|
||||||
|
|
||||||
|
|
||||||
|
class NWStorageCreate(Enum):
|
||||||
|
|
||||||
|
NOT_EMPTY = 0
|
||||||
|
OS_ERROR = 1
|
||||||
|
READY = 2
|
||||||
|
|
||||||
|
# END Enum NWStorageCreate
|
||||||
|
|
||||||
|
|
||||||
class NWStorage:
|
class NWStorage:
|
||||||
"""Core: Project Storage Class
|
"""Core: Project Storage Class
|
||||||
|
|
||||||
@@ -133,12 +142,12 @@ class NWStorage:
|
|||||||
"""Check if the storage location is open."""
|
"""Check if the storage location is open."""
|
||||||
return self._ready and self._runtimePath is not None
|
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."""
|
"""Create a new project at the given location."""
|
||||||
inPath = Path(path).resolve()
|
inPath = Path(path).resolve()
|
||||||
if inPath.is_dir() and len(list(inPath.iterdir())) > 0:
|
if inPath.is_dir() and len(list(inPath.iterdir())) > 0:
|
||||||
logger.error("Folder is not empty: %s", inPath)
|
logger.error("Folder is not empty: %s", inPath)
|
||||||
return False
|
return NWStorageCreate.NOT_EMPTY
|
||||||
|
|
||||||
self._storagePath = inPath
|
self._storagePath = inPath
|
||||||
self._runtimePath = inPath
|
self._runtimePath = inPath
|
||||||
@@ -156,11 +165,11 @@ class NWStorage:
|
|||||||
self._exception = exc
|
self._exception = exc
|
||||||
logger.error("Failed to create project folders", exc_info=exc)
|
logger.error("Failed to create project folders", exc_info=exc)
|
||||||
self.clear()
|
self.clear()
|
||||||
return False
|
return NWStorageCreate.OS_ERROR
|
||||||
|
|
||||||
self._ready = True
|
self._ready = True
|
||||||
|
|
||||||
return True
|
return NWStorageCreate.READY
|
||||||
|
|
||||||
def initProjectStorage(self, path: str | Path, clearLock: bool = False) -> NWStorageOpen:
|
def initProjectStorage(self, path: str | Path, clearLock: bool = False) -> NWStorageOpen:
|
||||||
"""Initialise a novelWriter project location."""
|
"""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
|
from novelwriter import CONFIG
|
||||||
|
|
||||||
FONT_SCALE = 0.9
|
FONT_SCALE = 0.9
|
||||||
|
RIGHT_TOP = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop
|
||||||
|
LEFT_TOP = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
|
||||||
|
|
||||||
|
|
||||||
class NConfigLayout(QGridLayout):
|
class NConfigLayout(QGridLayout):
|
||||||
@@ -103,31 +105,31 @@ class NConfigLayout(QGridLayout):
|
|||||||
labelBox.addWidget(qHelp)
|
labelBox.addWidget(qHelp)
|
||||||
labelBox.setSpacing(0)
|
labelBox.setSpacing(0)
|
||||||
labelBox.addStretch(1)
|
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:
|
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):
|
if isinstance(unit, str):
|
||||||
controlBox = QHBoxLayout()
|
controlBox = QHBoxLayout()
|
||||||
controlBox.addWidget(widget, 0, Qt.AlignVCenter)
|
controlBox.addWidget(widget, 0, Qt.AlignVCenter)
|
||||||
controlBox.addWidget(QLabel(unit), 0, Qt.AlignVCenter)
|
controlBox.addWidget(QLabel(unit), 0, Qt.AlignVCenter)
|
||||||
controlBox.setSpacing(wSp)
|
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):
|
elif isinstance(button, QAbstractButton):
|
||||||
controlBox = QHBoxLayout()
|
controlBox = QHBoxLayout()
|
||||||
controlBox.addWidget(widget, 0, Qt.AlignVCenter)
|
controlBox.addWidget(widget, 0, Qt.AlignVCenter)
|
||||||
controlBox.addWidget(button, 0, Qt.AlignVCenter)
|
controlBox.addWidget(button, 0, Qt.AlignVCenter)
|
||||||
controlBox.setSpacing(wSp)
|
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:
|
else:
|
||||||
if isinstance(widget, QLineEdit):
|
if isinstance(widget, QLineEdit):
|
||||||
qLayout = QHBoxLayout()
|
qLayout = QHBoxLayout()
|
||||||
qLayout.addWidget(widget)
|
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:
|
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, 0)
|
||||||
self.setRowStretch(self._nextRow+1, 1)
|
self.setRowStretch(self._nextRow+1, 1)
|
||||||
@@ -145,14 +147,14 @@ class NSimpleLayout(QGridLayout):
|
|||||||
column layout.
|
column layout.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, stretcColumn: int = 0) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._nextRow = 0
|
self._nextRow = 0
|
||||||
|
|
||||||
wSp = CONFIG.pxInt(8)
|
wSp = CONFIG.pxInt(8)
|
||||||
self.setHorizontalSpacing(wSp)
|
self.setHorizontalSpacing(wSp)
|
||||||
self.setVerticalSpacing(wSp)
|
self.setVerticalSpacing(wSp)
|
||||||
self.setColumnStretch(0, 1)
|
self.setColumnStretch(stretcColumn, 1)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -176,14 +178,14 @@ class NSimpleLayout(QGridLayout):
|
|||||||
wSp = CONFIG.pxInt(8)
|
wSp = CONFIG.pxInt(8)
|
||||||
qLabel = QLabel(label)
|
qLabel = QLabel(label)
|
||||||
qLabel.setIndent(wSp)
|
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):
|
if isinstance(widget, QLineEdit):
|
||||||
qLayout = QHBoxLayout()
|
qLayout = QHBoxLayout()
|
||||||
qLayout.addWidget(widget)
|
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:
|
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)
|
qLabel.setBuddy(widget)
|
||||||
|
|
||||||
|
|||||||
@@ -132,14 +132,10 @@ class GuiMainMenu(QMenuBar):
|
|||||||
# Project
|
# Project
|
||||||
self.projMenu = self.addMenu(self.tr("&Project"))
|
self.projMenu = self.addMenu(self.tr("&Project"))
|
||||||
|
|
||||||
# Project > New Project
|
# Project > Create or Open Project
|
||||||
self.aNewProject = self.projMenu.addAction(self.tr("New Project"))
|
self.aOpenProject = self.projMenu.addAction(self.tr("Create or Open Project"))
|
||||||
self.aNewProject.triggered.connect(lambda: self.mainGui.newProject(None))
|
|
||||||
|
|
||||||
# Project > Open Project
|
|
||||||
self.aOpenProject = self.projMenu.addAction(self.tr("Open Project"))
|
|
||||||
self.aOpenProject.setShortcut("Ctrl+Shift+O")
|
self.aOpenProject.setShortcut("Ctrl+Shift+O")
|
||||||
self.aOpenProject.triggered.connect(lambda: self.mainGui.showProjectLoadDialog())
|
self.aOpenProject.triggered.connect(self.mainGui.showWelcomeDialog)
|
||||||
|
|
||||||
# Project > Save Project
|
# Project > Save Project
|
||||||
self.aSaveProject = self.projMenu.addAction(self.tr("Save Project"))
|
self.aSaveProject = self.projMenu.addAction(self.tr("Save Project"))
|
||||||
|
|||||||
@@ -167,8 +167,7 @@ class _PopRightMenu(QMenu):
|
|||||||
def event(self, event: QEvent) -> bool:
|
def event(self, event: QEvent) -> bool:
|
||||||
"""Overload the show event and move the menu popup location."""
|
"""Overload the show event and move the menu popup location."""
|
||||||
if event.type() == QEvent.Show:
|
if event.type() == QEvent.Show:
|
||||||
parent = self.parent()
|
if isinstance(parent := self.parent(), QWidget):
|
||||||
if isinstance(parent, QWidget):
|
|
||||||
offset = QPoint(parent.width(), parent.height() - self.height())
|
offset = QPoint(parent.width(), parent.height() - self.height())
|
||||||
self.move(parent.mapToGlobal(offset))
|
self.move(parent.mapToGlobal(offset))
|
||||||
return super(_PopRightMenu, self).event(event)
|
return super(_PopRightMenu, self).event(event)
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class GuiTheme:
|
|||||||
self.themeLicense = ""
|
self.themeLicense = ""
|
||||||
self.themeLicenseUrl = ""
|
self.themeLicenseUrl = ""
|
||||||
self.themeIcons = ""
|
self.themeIcons = ""
|
||||||
|
self.isLightTheme = True
|
||||||
|
|
||||||
# GUI
|
# GUI
|
||||||
self.statNone = [120, 120, 120]
|
self.statNone = [120, 120, 120]
|
||||||
@@ -112,7 +113,7 @@ class GuiTheme:
|
|||||||
self._setGuiFont()
|
self._setGuiFont()
|
||||||
|
|
||||||
# Load Themes
|
# Load Themes
|
||||||
self._guiPalette = QPalette()
|
self._guiPalette = QPalette()
|
||||||
self._themeList: list[tuple[str, str]] = []
|
self._themeList: list[tuple[str, str]] = []
|
||||||
self._syntaxList: list[tuple[str, str]] = []
|
self._syntaxList: list[tuple[str, str]] = []
|
||||||
self._availThemes: dict[str, Path] = {}
|
self._availThemes: dict[str, Path] = {}
|
||||||
@@ -254,9 +255,9 @@ class GuiTheme:
|
|||||||
|
|
||||||
backLNess = backCol.lightnessF()
|
backLNess = backCol.lightnessF()
|
||||||
textLNess = textCol.lightnessF()
|
textLNess = textCol.lightnessF()
|
||||||
|
self.isLightTheme = backLNess > textLNess
|
||||||
if self.helpText == [0, 0, 0]:
|
if self.helpText == [0, 0, 0]:
|
||||||
if backLNess > textLNess:
|
if self.isLightTheme:
|
||||||
helpLCol = textLNess + 0.35*(backLNess - textLNess)
|
helpLCol = textLNess + 0.35*(backLNess - textLNess)
|
||||||
else:
|
else:
|
||||||
helpLCol = backLNess + 0.65*(textLNess - backLNess)
|
helpLCol = backLNess + 0.65*(textLNess - backLNess)
|
||||||
@@ -443,7 +444,7 @@ class GuiIcons:
|
|||||||
missing, a blank icon is returned and a warning issued.
|
missing, a blank icon is returned and a warning issued.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ICON_KEYS = {
|
ICON_KEYS: set[str] = {
|
||||||
# Project and GUI Icons
|
# Project and GUI Icons
|
||||||
"novelwriter", "alert_error", "alert_info", "alert_question", "alert_warn",
|
"novelwriter", "alert_error", "alert_info", "alert_question", "alert_warn",
|
||||||
"build_excluded", "build_filtered", "build_included", "proj_chapter", "proj_details",
|
"build_excluded", "build_filtered", "build_included", "proj_chapter", "proj_details",
|
||||||
@@ -464,9 +465,10 @@ class GuiIcons:
|
|||||||
"fmt_subscript", "fmt_superscript", "fmt_underline",
|
"fmt_subscript", "fmt_superscript", "fmt_underline",
|
||||||
|
|
||||||
# General Button Icons
|
# General Button Icons
|
||||||
"add", "backward", "bookmark", "browse", "checked", "close", "cross", "down", "edit",
|
"add", "add_document", "backward", "bookmark", "browse", "checked", "close", "cross",
|
||||||
"export", "forward", "maximise", "menu", "minimise", "noncheckable", "panel", "refresh",
|
"document", "down", "edit", "export", "forward", "maximise", "menu", "minimise",
|
||||||
"remove", "revert", "search_replace", "search", "settings", "unchecked", "up", "view",
|
"noncheckable", "panel", "refresh", "remove", "revert", "search_replace", "search",
|
||||||
|
"settings", "star", "unchecked", "up", "view",
|
||||||
|
|
||||||
# Switches
|
# Switches
|
||||||
"sticky-on", "sticky-off",
|
"sticky-on", "sticky-off",
|
||||||
@@ -478,13 +480,14 @@ class GuiIcons:
|
|||||||
"deco_doc_nt_n",
|
"deco_doc_nt_n",
|
||||||
}
|
}
|
||||||
|
|
||||||
TOGGLE_ICON_KEYS = {
|
TOGGLE_ICON_KEYS: dict[str, tuple[str, str]] = {
|
||||||
"sticky": ("sticky-on", "sticky-off"),
|
"sticky": ("sticky-on", "sticky-off"),
|
||||||
"bullet": ("bullet-on", "bullet-off"),
|
"bullet": ("bullet-on", "bullet-off"),
|
||||||
}
|
}
|
||||||
|
|
||||||
IMAGE_MAP = {
|
IMAGE_MAP: dict[str, tuple[str, str]] = {
|
||||||
"wiz-back": "wizard-back.jpg",
|
"welcome": ("welcome-light.jpg", "welcome-dark.jpg"),
|
||||||
|
"nw-text": ("novelwriter-text-light.svg", "novelwriter-text-dark.svg"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, mainTheme: GuiTheme) -> None:
|
def __init__(self, mainTheme: GuiTheme) -> None:
|
||||||
@@ -598,7 +601,8 @@ class GuiIcons:
|
|||||||
if name in self._themeMap:
|
if name in self._themeMap:
|
||||||
imgPath = self._themeMap[name]
|
imgPath = self._themeMap[name]
|
||||||
elif name in self.IMAGE_MAP:
|
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:
|
else:
|
||||||
logger.error("Decoration with name '%s' does not exist", name)
|
logger.error("Decoration with name '%s' does not exist", name)
|
||||||
return QPixmap()
|
return QPixmap()
|
||||||
@@ -608,12 +612,13 @@ class GuiIcons:
|
|||||||
return QPixmap()
|
return QPixmap()
|
||||||
|
|
||||||
pixmap = QPixmap(str(imgPath))
|
pixmap = QPixmap(str(imgPath))
|
||||||
|
tMode = Qt.TransformationMode.SmoothTransformation
|
||||||
if w is not None and h is not None:
|
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:
|
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:
|
elif w is not None and h is None:
|
||||||
return pixmap.scaledToWidth(w, Qt.SmoothTransformation)
|
return pixmap.scaledToWidth(w, tMode)
|
||||||
|
|
||||||
return pixmap
|
return pixmap
|
||||||
|
|
||||||
@@ -632,8 +637,8 @@ class GuiIcons:
|
|||||||
pOne = self.getPixmap(self.TOGGLE_ICON_KEYS[name][0], size)
|
pOne = self.getPixmap(self.TOGGLE_ICON_KEYS[name][0], size)
|
||||||
pTwo = self.getPixmap(self.TOGGLE_ICON_KEYS[name][1], size)
|
pTwo = self.getPixmap(self.TOGGLE_ICON_KEYS[name][1], size)
|
||||||
icon = QIcon()
|
icon = QIcon()
|
||||||
icon.addPixmap(pOne, QIcon.Normal, QIcon.On)
|
icon.addPixmap(pOne, QIcon.Mode.Normal, QIcon.State.On)
|
||||||
icon.addPixmap(pTwo, QIcon.Normal, QIcon.Off)
|
icon.addPixmap(pTwo, QIcon.Mode.Normal, QIcon.State.Off)
|
||||||
return icon
|
return icon
|
||||||
return QIcon()
|
return QIcon()
|
||||||
|
|
||||||
@@ -641,7 +646,7 @@ class GuiIcons:
|
|||||||
"""Return an icon from the icon buffer as a QPixmap. If it
|
"""Return an icon from the icon buffer as a QPixmap. If it
|
||||||
doesn't exist, return an empty QPixmap.
|
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,
|
def getItemIcon(self, tType: nwItemType, tClass: nwItemClass,
|
||||||
tLayout: nwItemLayout, hLevel: str = "H0") -> QIcon:
|
tLayout: nwItemLayout, hLevel: str = "H0") -> QIcon:
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ from time import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, QTimer, pyqtSlot
|
|
||||||
from PyQt5.QtGui import QCloseEvent, QCursor, QIcon
|
from PyQt5.QtGui import QCloseEvent, QCursor, QIcon
|
||||||
|
from PyQt5.QtCore import Qt, QTimer, pyqtSlot
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QDialog, QFileDialog, QHBoxLayout, QMainWindow, QMessageBox, QShortcut,
|
QFileDialog, QHBoxLayout, QMainWindow, QMessageBox, QShortcut, QSplitter,
|
||||||
QSplitter, QStackedWidget, QVBoxLayout, QWidget, qApp
|
QStackedWidget, QVBoxLayout, QWidget, qApp
|
||||||
)
|
)
|
||||||
|
|
||||||
from novelwriter import CONFIG, SHARED, __hexversion__
|
from novelwriter import CONFIG, SHARED, __hexversion__
|
||||||
@@ -51,22 +51,19 @@ from novelwriter.gui.itemdetails import GuiItemDetails
|
|||||||
from novelwriter.gui.docviewerpanel import GuiDocViewerPanel
|
from novelwriter.gui.docviewerpanel import GuiDocViewerPanel
|
||||||
from novelwriter.dialogs.about import GuiAbout
|
from novelwriter.dialogs.about import GuiAbout
|
||||||
from novelwriter.dialogs.updates import GuiUpdates
|
from novelwriter.dialogs.updates import GuiUpdates
|
||||||
from novelwriter.dialogs.projload import GuiProjectLoad
|
|
||||||
from novelwriter.dialogs.wordlist import GuiWordList
|
from novelwriter.dialogs.wordlist import GuiWordList
|
||||||
from novelwriter.dialogs.preferences import GuiPreferences
|
from novelwriter.dialogs.preferences import GuiPreferences
|
||||||
from novelwriter.dialogs.projdetails import GuiProjectDetails
|
from novelwriter.dialogs.projdetails import GuiProjectDetails
|
||||||
from novelwriter.dialogs.projsettings import GuiProjectSettings
|
from novelwriter.dialogs.projsettings import GuiProjectSettings
|
||||||
|
from novelwriter.tools.welcome import GuiWelcome
|
||||||
from novelwriter.tools.manuscript import GuiManuscript
|
from novelwriter.tools.manuscript import GuiManuscript
|
||||||
from novelwriter.tools.projwizard import GuiProjectWizard
|
|
||||||
from novelwriter.tools.dictionaries import GuiDictionaries
|
from novelwriter.tools.dictionaries import GuiDictionaries
|
||||||
from novelwriter.tools.writingstats import GuiWritingStats
|
from novelwriter.tools.writingstats import GuiWritingStats
|
||||||
from novelwriter.core.coretools import ProjectBuilder
|
|
||||||
|
|
||||||
from novelwriter.enum import (
|
from novelwriter.enum import (
|
||||||
nwDocAction, nwDocInsert, nwDocMode, nwItemType, nwItemClass, nwWidget, nwView
|
nwDocAction, nwDocInsert, nwDocMode, nwItemType, nwWidget, nwView
|
||||||
)
|
)
|
||||||
from novelwriter.common import hexToInt
|
from novelwriter.common import hexToInt
|
||||||
from novelwriter.constants import nwFiles
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -359,7 +356,7 @@ class GuiMain(QMainWindow):
|
|||||||
self.openProject(cmdOpen)
|
self.openProject(cmdOpen)
|
||||||
|
|
||||||
if not SHARED.hasProject:
|
if not SHARED.hasProject:
|
||||||
self.showProjectLoadDialog()
|
self.showWelcomeDialog()
|
||||||
|
|
||||||
# Determine whether release notes need to be shown or not
|
# Determine whether release notes need to be shown or not
|
||||||
if hexToInt(CONFIG.lastNotes) < hexToInt(__hexversion__):
|
if hexToInt(CONFIG.lastNotes) < hexToInt(__hexversion__):
|
||||||
@@ -372,42 +369,6 @@ class GuiMain(QMainWindow):
|
|||||||
# Project Actions
|
# 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:
|
def closeProject(self, isYes: bool = False) -> bool:
|
||||||
"""Close the project if one is open. isYes is passed on from the
|
"""Close the project if one is open. isYes is passed on from the
|
||||||
close application event so the user doesn't get prompted twice
|
close application event so the user doesn't get prompted twice
|
||||||
@@ -837,33 +798,14 @@ class GuiMain(QMainWindow):
|
|||||||
# Main Dialogs
|
# Main Dialogs
|
||||||
##
|
##
|
||||||
|
|
||||||
def showProjectLoadDialog(self) -> None:
|
@pyqtSlot()
|
||||||
"""Open the projects dialog for selecting either existing
|
def showWelcomeDialog(self) -> None:
|
||||||
projects from a cache of recently opened projects, or provide a
|
"""Open the welcome dialog."""
|
||||||
browse button for projects not yet cached. Selecting to create a
|
dialog = GuiWelcome(self)
|
||||||
new project is forwarded to the new project wizard.
|
dialog.openProjectRequest.connect(self._openProject)
|
||||||
"""
|
dialog.exec_()
|
||||||
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()
|
|
||||||
|
|
||||||
return
|
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()
|
@pyqtSlot()
|
||||||
def showPreferencesDialog(self) -> None:
|
def showPreferencesDialog(self) -> None:
|
||||||
"""Open the preferences dialog."""
|
"""Open the preferences dialog."""
|
||||||
@@ -1204,6 +1146,12 @@ class GuiMain(QMainWindow):
|
|||||||
self.viewDocument(tHandle=tHandle, sTitle=sTitle)
|
self.viewDocument(tHandle=tHandle, sTitle=sTitle)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@pyqtSlot(Path)
|
||||||
|
def _openProject(self, path: Path) -> None:
|
||||||
|
"""Handle an open project request."""
|
||||||
|
self.openProject(path)
|
||||||
|
return
|
||||||
|
|
||||||
@pyqtSlot(str, nwDocMode, str, bool)
|
@pyqtSlot(str, nwDocMode, str, bool)
|
||||||
def _openDocument(self, tHandle: str, mode: nwDocMode, sTitle: str, setFocus: bool) -> None:
|
def _openDocument(self, tHandle: str, mode: nwDocMode, sTitle: str, setFocus: bool) -> None:
|
||||||
"""Handle an open document request."""
|
"""Handle an open document request."""
|
||||||
@@ -1458,44 +1406,6 @@ class GuiMain(QMainWindow):
|
|||||||
self.setWindowTitle(winTitle)
|
self.setWindowTitle(winTitle)
|
||||||
return
|
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]:
|
def _getTagSource(self, tag: str) -> tuple[str | None, str | None]:
|
||||||
"""Handle the index lookup of a tag and display an alert if the
|
"""Handle the index lookup of a tag and display an alert if the
|
||||||
tag cannot be found.
|
tag cannot be found.
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ from typing import TYPE_CHECKING
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt5.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal
|
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
|
from novelwriter.core.spellcheck import NWSpellEnchant
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
@@ -215,6 +216,20 @@ class SharedData(QObject):
|
|||||||
QThreadPool.globalInstance().start(runnable, priority=priority)
|
QThreadPool.globalInstance().start(runnable, priority=priority)
|
||||||
return
|
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
|
# 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}"])
|
nwGUI = main(["--testmode", f"--config={_TMP_CONF}", f"--data={_TMP_CONF}"])
|
||||||
qtbot.addWidget(nwGUI)
|
qtbot.addWidget(nwGUI)
|
||||||
resetConfigVars()
|
resetConfigVars()
|
||||||
SHARED._alert = None
|
|
||||||
nwGUI.docEditor.initEditor()
|
nwGUI.docEditor.initEditor()
|
||||||
|
|
||||||
nwGUI.show()
|
nwGUI.show()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Meta]
|
[Meta]
|
||||||
timestamp = 2023-08-08 19:01:25
|
timestamp = 2023-12-29 14:11:20
|
||||||
|
|
||||||
[Main]
|
[Main]
|
||||||
theme = default
|
theme = default
|
||||||
@@ -14,8 +14,8 @@ lastpath =
|
|||||||
|
|
||||||
[Sizes]
|
[Sizes]
|
||||||
mainwindow = 1200, 650
|
mainwindow = 1200, 650
|
||||||
|
welcome = 800, 500
|
||||||
preferences = 700, 615
|
preferences = 700, 615
|
||||||
projloadcols = 280, 60, 160
|
|
||||||
mainpane = 300, 800
|
mainpane = 300, 800
|
||||||
viewpane = 500, 150
|
viewpane = 500, 150
|
||||||
outlinepane = 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'?>
|
<?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">
|
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="1" autoCount="0" editTime="0">
|
||||||
<name>Test Custom</name>
|
<name>Test Project A</name>
|
||||||
<title>Test Novel</title>
|
<title>Test Project A</title>
|
||||||
<author>Jane Doe</author>
|
<author>Jane Doe</author>
|
||||||
</project>
|
</project>
|
||||||
<settings>
|
<settings>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<?xml version='1.0' encoding='utf-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">
|
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="1" autoCount="0" editTime="0">
|
||||||
<name>Test Custom</name>
|
<name>Test Project B</name>
|
||||||
<title>Test Novel</title>
|
<title>Test Project B</title>
|
||||||
<author>Jane Doe</author>
|
<author>Jane Doe</author>
|
||||||
</project>
|
</project>
|
||||||
<settings>
|
<settings>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[Meta]
|
[Meta]
|
||||||
timestamp = 2023-08-02 14:53:40
|
timestamp = 2023-12-29 14:09:13
|
||||||
|
|
||||||
[Main]
|
[Main]
|
||||||
theme = default
|
theme = default
|
||||||
@@ -14,8 +14,8 @@ lastpath =
|
|||||||
|
|
||||||
[Sizes]
|
[Sizes]
|
||||||
mainwindow = 1200, 650
|
mainwindow = 1200, 650
|
||||||
preferences = 765, 614
|
welcome = 800, 500
|
||||||
projloadcols = 280, 60, 160
|
preferences = 713, 614
|
||||||
mainpane = 300, 800
|
mainpane = 300, 800
|
||||||
viewpane = 500, 150
|
viewpane = 500, 150
|
||||||
outlinepane = 500, 150
|
outlinepane = 500, 150
|
||||||
|
|||||||
@@ -278,6 +278,19 @@ def testBaseConfig_SettersGetters(fncPath):
|
|||||||
|
|
||||||
tstConf.setMainWinSize(1200, 650)
|
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
|
# Preferences Size
|
||||||
tstConf.guiScale = 2.0
|
tstConf.guiScale = 2.0
|
||||||
tstConf.setPreferencesWinSize(70, 70)
|
tstConf.setPreferencesWinSize(70, 70)
|
||||||
@@ -291,19 +304,6 @@ def testBaseConfig_SettersGetters(fncPath):
|
|||||||
|
|
||||||
tstConf.setPreferencesWinSize(700, 615)
|
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
|
# Main Pane Splitter
|
||||||
tstConf.guiScale = 2.0
|
tstConf.guiScale = 2.0
|
||||||
tstConf.setMainPanePos([200, 700])
|
tstConf.setMainPanePos([200, 700])
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import pytest
|
|||||||
from tools import buildTestProject
|
from tools import buildTestProject
|
||||||
from mocked import MockGuiMain, MockTheme
|
from mocked import MockGuiMain, MockTheme
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QMessageBox
|
from PyQt5.QtWidgets import QFileDialog, QMessageBox, QWidget
|
||||||
|
|
||||||
from novelwriter.shared import SharedData
|
from novelwriter.shared import SharedData
|
||||||
from novelwriter.core.project import NWProject
|
from novelwriter.core.project import NWProject
|
||||||
@@ -66,7 +66,7 @@ def testBaseSharedData_Init():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.base
|
@pytest.mark.base
|
||||||
def testBaseSharedData_Projects(fncPath, caplog):
|
def testBaseSharedData_Projects(monkeypatch, caplog, fncPath):
|
||||||
"""Test SharedData handling of projects."""
|
"""Test SharedData handling of projects."""
|
||||||
project = NWProject()
|
project = NWProject()
|
||||||
buildTestProject(project, fncPath)
|
buildTestProject(project, fncPath)
|
||||||
@@ -118,6 +118,11 @@ def testBaseSharedData_Projects(fncPath, caplog):
|
|||||||
assert shared.hasProject is False
|
assert shared.hasProject is False
|
||||||
assert isinstance(shared.projectLock, list)
|
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
|
# END Test testBaseSharedData_Projects
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,32 +39,32 @@ from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter, Pr
|
|||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ipsumText):
|
def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ipsumText):
|
||||||
"""Test the DocMerger utility."""
|
"""Test the DocMerger utility."""
|
||||||
theProject = NWProject()
|
project = NWProject()
|
||||||
mockRnd.reset()
|
mockRnd.reset()
|
||||||
buildTestProject(theProject, fncPath)
|
buildTestProject(project, fncPath)
|
||||||
|
|
||||||
# Create Files to Merge
|
# Create Files to Merge
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
hChapter1 = theProject.newFile("Chapter 1", C.hNovelRoot)
|
hChapter1 = project.newFile("Chapter 1", C.hNovelRoot)
|
||||||
hSceneOne11 = theProject.newFile("Scene 1.1", hChapter1) # type: ignore
|
hSceneOne11 = project.newFile("Scene 1.1", hChapter1) # type: ignore
|
||||||
hSceneOne12 = theProject.newFile("Scene 1.2", hChapter1) # type: ignore
|
hSceneOne12 = project.newFile("Scene 1.2", hChapter1) # type: ignore
|
||||||
hSceneOne13 = theProject.newFile("Scene 1.3", hChapter1) # type: ignore
|
hSceneOne13 = project.newFile("Scene 1.3", hChapter1) # type: ignore
|
||||||
|
|
||||||
docText1 = "\n\n".join(ipsumText[0:2]) + "\n\n"
|
docText1 = "\n\n".join(ipsumText[0:2]) + "\n\n"
|
||||||
docText2 = "\n\n".join(ipsumText[1:3]) + "\n\n"
|
docText2 = "\n\n".join(ipsumText[1:3]) + "\n\n"
|
||||||
docText3 = "\n\n".join(ipsumText[2:4]) + "\n\n"
|
docText3 = "\n\n".join(ipsumText[2:4]) + "\n\n"
|
||||||
docText4 = "\n\n".join(ipsumText[3:5]) + "\n\n"
|
docText4 = "\n\n".join(ipsumText[3:5]) + "\n\n"
|
||||||
|
|
||||||
theProject.writeNewFile(hChapter1, 2, True, docText1) # type: ignore
|
project.writeNewFile(hChapter1, 2, True, docText1) # type: ignore
|
||||||
theProject.writeNewFile(hSceneOne11, 3, True, docText2) # type: ignore
|
project.writeNewFile(hSceneOne11, 3, True, docText2) # type: ignore
|
||||||
theProject.writeNewFile(hSceneOne12, 3, True, docText3) # type: ignore
|
project.writeNewFile(hSceneOne12, 3, True, docText3) # type: ignore
|
||||||
theProject.writeNewFile(hSceneOne13, 3, True, docText4) # type: ignore
|
project.writeNewFile(hSceneOne13, 3, True, docText4) # type: ignore
|
||||||
|
|
||||||
# Basic Checks
|
# Basic Checks
|
||||||
# ============
|
# ============
|
||||||
|
|
||||||
docMerger = DocMerger(theProject)
|
docMerger = DocMerger(project)
|
||||||
|
|
||||||
# No writing without a target set
|
# No writing without a target set
|
||||||
assert docMerger.writeTargetDoc() is False
|
assert docMerger.writeTargetDoc() is False
|
||||||
@@ -127,14 +127,14 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
|
|||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText):
|
def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText):
|
||||||
"""Test the DocSplitter utility."""
|
"""Test the DocSplitter utility."""
|
||||||
theProject = NWProject()
|
project = NWProject()
|
||||||
mockRnd.reset()
|
mockRnd.reset()
|
||||||
buildTestProject(theProject, fncPath)
|
buildTestProject(project, fncPath)
|
||||||
|
|
||||||
# Create File to Split
|
# Create File to Split
|
||||||
# ====================
|
# ====================
|
||||||
|
|
||||||
hSplitDoc = theProject.newFile("Split Doc", C.hNovelRoot)
|
hSplitDoc = project.newFile("Split Doc", C.hNovelRoot)
|
||||||
|
|
||||||
docData = [
|
docData = [
|
||||||
"# Part One", ipsumText[0],
|
"# Part One", ipsumText[0],
|
||||||
@@ -163,16 +163,16 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText)
|
|||||||
|
|
||||||
docText = "\n\n".join(docData)
|
docText = "\n\n".join(docData)
|
||||||
docRaw = docText.splitlines()
|
docRaw = docText.splitlines()
|
||||||
assert theProject.storage.getDocument(hSplitDoc).writeDocument(docText) is True
|
assert project.storage.getDocument(hSplitDoc).writeDocument(docText) is True
|
||||||
theProject.tree[hSplitDoc].setStatus(C.sFinished) # type: ignore
|
project.tree[hSplitDoc].setStatus(C.sFinished) # type: ignore
|
||||||
theProject.tree[hSplitDoc].setImport(C.iMain) # 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._srcItem.isFileType() # type: ignore
|
||||||
assert docSplitter.getError() == ""
|
assert docSplitter.getError() == ""
|
||||||
|
|
||||||
# Run the split algorithm
|
# Run the split algorithm
|
||||||
docSplitter.splitDocument(splitData, docRaw)
|
docSplitter.splitDocument(splitData, docRaw) # type: ignore
|
||||||
for i, (lineNo, hLevel, hLabel) in enumerate(splitData):
|
for i, (lineNo, hLevel, hLabel) in enumerate(splitData):
|
||||||
assert docSplitter._rawData[i] == (docRaw[lineNo:lineNo+4], hLevel, hLabel)
|
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
|
# Check that status and importance has been preserved
|
||||||
for rHandle in resDocHandle:
|
for rHandle in resDocHandle:
|
||||||
assert theProject.tree[rHandle].itemStatus == C.sFinished # type: ignore
|
assert project.tree[rHandle].itemStatus == C.sFinished # type: ignore
|
||||||
assert theProject.tree[rHandle].itemImport == C.iMain # type: ignore
|
assert project.tree[rHandle].itemImport == C.iMain # type: ignore
|
||||||
|
|
||||||
# Check handling of improper initialisation
|
# Check handling of improper initialisation
|
||||||
docSplitter = DocSplitter(theProject, C.hInvalid)
|
docSplitter = DocSplitter(project, C.hInvalid)
|
||||||
assert docSplitter._srcHandle is None
|
assert docSplitter._srcHandle is None
|
||||||
assert docSplitter._srcItem is None
|
assert docSplitter._srcItem is None
|
||||||
assert docSplitter.newParentFolder(C.hNovelRoot, "Split Folder") is None
|
assert docSplitter.newParentFolder(C.hNovelRoot, "Split Folder") is None
|
||||||
assert list(docSplitter.writeDocuments(False)) == []
|
assert list(docSplitter.writeDocuments(False)) == []
|
||||||
|
|
||||||
theProject.saveProject()
|
project.saveProject()
|
||||||
|
|
||||||
# END Test testCoreTools_DocSplitter
|
# END Test testCoreTools_DocSplitter
|
||||||
|
|
||||||
@@ -266,20 +266,20 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText)
|
|||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
|
def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
|
||||||
"""Test the DocDuplicator utility."""
|
"""Test the DocDuplicator utility."""
|
||||||
theProject = NWProject()
|
project = NWProject()
|
||||||
mockRnd.reset()
|
mockRnd.reset()
|
||||||
buildTestProject(theProject, fncPath)
|
buildTestProject(project, fncPath)
|
||||||
|
|
||||||
dup = DocDuplicator(theProject)
|
dup = DocDuplicator(project)
|
||||||
|
|
||||||
ttText = "#! New Novel\n\n>> By Jane Doe <<\n"
|
ttText = "#! New Novel\n\n>> By Jane Doe <<\n"
|
||||||
chText = "## New Chapter\n\n"
|
chText = "## New Chapter\n\n"
|
||||||
scText = "### New Scene\n\n"
|
scText = "### New Scene\n\n"
|
||||||
|
|
||||||
# Check document content
|
# Check document content
|
||||||
assert theProject.storage.getDocument(C.hTitlePage).readDocument() == ttText
|
assert project.storage.getDocument(C.hTitlePage).readDocument() == ttText
|
||||||
assert theProject.storage.getDocument(C.hChapterDoc).readDocument() == chText
|
assert project.storage.getDocument(C.hChapterDoc).readDocument() == chText
|
||||||
assert theProject.storage.getDocument(C.hSceneDoc).readDocument() == scText
|
assert project.storage.getDocument(C.hSceneDoc).readDocument() == scText
|
||||||
|
|
||||||
# Nothing to do
|
# Nothing to do
|
||||||
assert list(dup.duplicate([])) == []
|
assert list(dup.duplicate([])) == []
|
||||||
@@ -291,17 +291,17 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
|
|||||||
assert list(dup.duplicate([C.hSceneDoc])) == [
|
assert list(dup.duplicate([C.hSceneDoc])) == [
|
||||||
("0000000000010", C.hSceneDoc), # The Scene
|
("0000000000010", C.hSceneDoc), # The Scene
|
||||||
]
|
]
|
||||||
assert theProject.tree._order == [
|
assert project.tree._order == [
|
||||||
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
|
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
|
||||||
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
|
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
|
||||||
"0000000000010",
|
"0000000000010",
|
||||||
]
|
]
|
||||||
|
|
||||||
# With the same content
|
# With the same content
|
||||||
assert theProject.storage.getDocument("0000000000010").readDocument() == scText
|
assert project.storage.getDocument("0000000000010").readDocument() == scText
|
||||||
|
|
||||||
# They should have the same parent
|
# 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
|
# Folder w/Two Files
|
||||||
# ==================
|
# ==================
|
||||||
@@ -312,7 +312,7 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
|
|||||||
("0000000000012", None), # The Chapter
|
("0000000000012", None), # The Chapter
|
||||||
("0000000000013", None), # The Scene
|
("0000000000013", None), # The Scene
|
||||||
]
|
]
|
||||||
assert theProject.tree._order == [
|
assert project.tree._order == [
|
||||||
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
|
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
|
||||||
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
|
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
|
||||||
"0000000000010",
|
"0000000000010",
|
||||||
@@ -320,15 +320,15 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
|
|||||||
]
|
]
|
||||||
|
|
||||||
# With the same content
|
# With the same content
|
||||||
assert theProject.storage.getDocument("0000000000012").readDocument() == chText
|
assert project.storage.getDocument("0000000000012").readDocument() == chText
|
||||||
assert theProject.storage.getDocument("0000000000013").readDocument() == scText
|
assert project.storage.getDocument("0000000000013").readDocument() == scText
|
||||||
|
|
||||||
# The chapter dirs should have the same parent
|
# 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
|
# The new files should have the new folder as parent
|
||||||
assert theProject.tree["0000000000012"].itemParent == "0000000000011" # type: ignore
|
assert project.tree["0000000000012"].itemParent == "0000000000011" # type: ignore
|
||||||
assert theProject.tree["0000000000013"].itemParent == "0000000000011" # type: ignore
|
assert project.tree["0000000000013"].itemParent == "0000000000011" # type: ignore
|
||||||
|
|
||||||
# Full Root Folder
|
# Full Root Folder
|
||||||
# ================
|
# ================
|
||||||
@@ -343,7 +343,7 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
|
|||||||
("0000000000017", None), # The Chapter
|
("0000000000017", None), # The Chapter
|
||||||
("0000000000018", None), # The Scene
|
("0000000000018", None), # The Scene
|
||||||
]
|
]
|
||||||
assert theProject.tree._order == [
|
assert project.tree._order == [
|
||||||
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
|
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
|
||||||
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
|
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
|
||||||
"0000000000010",
|
"0000000000010",
|
||||||
@@ -352,24 +352,24 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
|
|||||||
]
|
]
|
||||||
|
|
||||||
# With the same content
|
# With the same content
|
||||||
assert theProject.storage.getDocument("0000000000015").readDocument() == ttText
|
assert project.storage.getDocument("0000000000015").readDocument() == ttText
|
||||||
assert theProject.storage.getDocument("0000000000017").readDocument() == chText
|
assert project.storage.getDocument("0000000000017").readDocument() == chText
|
||||||
assert theProject.storage.getDocument("0000000000018").readDocument() == scText
|
assert project.storage.getDocument("0000000000018").readDocument() == scText
|
||||||
|
|
||||||
# The root folder should have no parent
|
# 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
|
# The folder and files should have the new root
|
||||||
assert theProject.tree["0000000000015"].itemRoot == "0000000000014" # type: ignore
|
assert project.tree["0000000000015"].itemRoot == "0000000000014" # type: ignore
|
||||||
assert theProject.tree["0000000000016"].itemRoot == "0000000000014" # type: ignore
|
assert project.tree["0000000000016"].itemRoot == "0000000000014" # type: ignore
|
||||||
assert theProject.tree["0000000000017"].itemRoot == "0000000000014" # type: ignore
|
assert project.tree["0000000000017"].itemRoot == "0000000000014" # type: ignore
|
||||||
assert theProject.tree["0000000000018"].itemRoot == "0000000000014" # type: ignore
|
assert project.tree["0000000000018"].itemRoot == "0000000000014" # type: ignore
|
||||||
|
|
||||||
# And they should have new parents
|
# And they should have new parents
|
||||||
assert theProject.tree["0000000000015"].itemParent == "0000000000014" # type: ignore
|
assert project.tree["0000000000015"].itemParent == "0000000000014" # type: ignore
|
||||||
assert theProject.tree["0000000000016"].itemParent == "0000000000014" # type: ignore
|
assert project.tree["0000000000016"].itemParent == "0000000000014" # type: ignore
|
||||||
assert theProject.tree["0000000000017"].itemParent == "0000000000016" # type: ignore
|
assert project.tree["0000000000017"].itemParent == "0000000000016" # type: ignore
|
||||||
assert theProject.tree["0000000000018"].itemParent == "0000000000016" # type: ignore
|
assert project.tree["0000000000018"].itemParent == "0000000000016" # type: ignore
|
||||||
|
|
||||||
# Exceptions
|
# Exceptions
|
||||||
# ==========
|
# ==========
|
||||||
@@ -381,14 +381,14 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
|
|||||||
assert list(dup.duplicate([C.hInvalid, C.hSceneDoc])) == []
|
assert list(dup.duplicate([C.hInvalid, C.hSceneDoc])) == []
|
||||||
|
|
||||||
# Don't overwrite existing files
|
# Don't overwrite existing files
|
||||||
content = theProject.storage.contentPath
|
content = project.storage.contentPath
|
||||||
assert isinstance(content, Path)
|
assert isinstance(content, Path)
|
||||||
(content / "0000000000019.nwd").touch()
|
(content / "0000000000019.nwd").touch()
|
||||||
assert (content / "0000000000019.nwd").exists()
|
assert (content / "0000000000019.nwd").exists()
|
||||||
assert list(dup.duplicate([C.hChapterDoc, C.hSceneDoc])) == []
|
assert list(dup.duplicate([C.hChapterDoc, C.hSceneDoc])) == []
|
||||||
|
|
||||||
# Save and Close
|
# Save and Close
|
||||||
theProject.saveProject()
|
project.saveProject()
|
||||||
|
|
||||||
projFile = fncPath / "nwProject.nwx"
|
projFile = fncPath / "nwProject.nwx"
|
||||||
testFile = tstPaths.outDir / "coreTools_DocDuplicator_nwProject.nwx"
|
testFile = tstPaths.outDir / "coreTools_DocDuplicator_nwProject.nwx"
|
||||||
@@ -401,134 +401,121 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreTools_NewMinimal(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd):
|
def testCoreTools_ProjectBuilderWrapper(monkeypatch, caplog, fncPath, mockGUI):
|
||||||
"""Create a new project from a project wizard dictionary. With
|
"""Test the wrapper function of the project builder."""
|
||||||
default setting, creating a Minimal project.
|
builder = ProjectBuilder()
|
||||||
"""
|
|
||||||
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()
|
|
||||||
|
|
||||||
# Setting no data should fail
|
# Setting no data should fail
|
||||||
assert projBuild.buildProject({}) is False
|
assert builder.buildProject({}) is False
|
||||||
|
|
||||||
# Wrong type should also fail
|
# 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
|
# 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
|
# 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
|
# END Test testCoreTools_ProjectBuilderWrapper
|
||||||
copyfile(projFile, testFile)
|
|
||||||
assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE)
|
|
||||||
|
|
||||||
# END Test testCoreTools_NewMinimal
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreTools_NewCustomA(monkeypatch, fncPath, tstPaths, mockRnd):
|
def testCoreTools_ProjectBuilderA(monkeypatch, fncPath, tstPaths, mockRnd):
|
||||||
"""Create a new project from a project wizard dictionary.
|
"""Create a new project from a project dictionary, with chapters."""
|
||||||
Custom type with chapters and scenes.
|
|
||||||
"""
|
|
||||||
monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed"))
|
monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed"))
|
||||||
|
|
||||||
projFile = fncPath / "nwProject.nwx"
|
projFile = fncPath / "nwProject.nwx"
|
||||||
testFile = tstPaths.outDir / "coreTools_NewCustomA_nwProject.nwx"
|
testFile = tstPaths.outDir / "coreTools_ProjectBuilderA_nwProject.nwx"
|
||||||
compFile = tstPaths.refDir / "coreTools_NewCustomA_nwProject.nwx"
|
compFile = tstPaths.refDir / "coreTools_ProjectBuilderA_nwProject.nwx"
|
||||||
|
|
||||||
projData = {
|
data = {
|
||||||
"projName": "Test Custom",
|
"name": "Test Project A",
|
||||||
"projTitle": "Test Novel",
|
"author": "Jane Doe",
|
||||||
"projAuthor": "Jane Doe",
|
"language": -1,
|
||||||
"projLang": -1,
|
"path": fncPath,
|
||||||
"projPath": fncPath,
|
"sample": False,
|
||||||
"popSample": False,
|
"template": None,
|
||||||
"popMinimal": False,
|
"chapters": 3,
|
||||||
"popCustom": True,
|
"scenes": 3,
|
||||||
"addRoots": [
|
"roots": [
|
||||||
nwItemClass.PLOT,
|
nwItemClass.PLOT,
|
||||||
nwItemClass.CHARACTER,
|
nwItemClass.CHARACTER,
|
||||||
nwItemClass.WORLD,
|
nwItemClass.WORLD,
|
||||||
],
|
],
|
||||||
"addNotes": True,
|
"notes": True,
|
||||||
"numChapters": 3,
|
|
||||||
"numScenes": 3,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
projBuild = ProjectBuilder()
|
builder = ProjectBuilder()
|
||||||
assert projBuild.buildProject(projData) is True
|
assert builder.buildProject(data) is True
|
||||||
|
|
||||||
copyfile(projFile, testFile)
|
copyfile(projFile, testFile)
|
||||||
assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE)
|
assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE)
|
||||||
|
|
||||||
# END Test testCoreTools_NewCustomA
|
# END Test testCoreTools_ProjectBuilderA
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreTools_NewCustomB(monkeypatch, fncPath, tstPaths, mockRnd):
|
def testCoreTools_ProjectBuilderB(monkeypatch, fncPath, tstPaths, mockRnd):
|
||||||
"""Create a new project from a project wizard dictionary.
|
"""Create a new project from a project dictionary, without chapters."""
|
||||||
Custom type without chapters, but with scenes.
|
|
||||||
"""
|
|
||||||
monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed"))
|
monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed"))
|
||||||
|
|
||||||
projFile = fncPath / "nwProject.nwx"
|
projFile = fncPath / "nwProject.nwx"
|
||||||
testFile = tstPaths.outDir / "coreTools_NewCustomB_nwProject.nwx"
|
testFile = tstPaths.outDir / "coreTools_ProjectBuilderB_nwProject.nwx"
|
||||||
compFile = tstPaths.refDir / "coreTools_NewCustomB_nwProject.nwx"
|
compFile = tstPaths.refDir / "coreTools_ProjectBuilderB_nwProject.nwx"
|
||||||
|
|
||||||
projData = {
|
data = {
|
||||||
"projName": "Test Custom",
|
"name": "Test Project B",
|
||||||
"projTitle": "Test Novel",
|
"author": "Jane Doe",
|
||||||
"projAuthor": "Jane Doe",
|
"language": -1,
|
||||||
"projLang": -1,
|
"path": fncPath,
|
||||||
"projPath": fncPath,
|
"sample": False,
|
||||||
"popSample": False,
|
"template": None,
|
||||||
"popMinimal": False,
|
"chapters": 0,
|
||||||
"popCustom": True,
|
"scenes": 6,
|
||||||
"addRoots": [
|
"roots": [
|
||||||
nwItemClass.PLOT,
|
nwItemClass.PLOT,
|
||||||
nwItemClass.CHARACTER,
|
nwItemClass.CHARACTER,
|
||||||
nwItemClass.WORLD,
|
nwItemClass.WORLD,
|
||||||
],
|
],
|
||||||
"addNotes": True,
|
"notes": True,
|
||||||
"numChapters": 0,
|
|
||||||
"numScenes": 6,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
projBuild = ProjectBuilder()
|
builder = ProjectBuilder()
|
||||||
assert projBuild.buildProject(projData) is True
|
assert builder.buildProject(data) is True
|
||||||
|
|
||||||
copyfile(projFile, testFile)
|
copyfile(projFile, testFile)
|
||||||
assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE)
|
assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE)
|
||||||
|
|
||||||
# END Test testCoreTools_NewCustomB
|
# END Test testCoreTools_ProjectBuilderB
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@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
|
"""Check that we can create a new project can be created from the
|
||||||
provided sample project via a zip file.
|
provided sample project via a zip file.
|
||||||
"""
|
"""
|
||||||
projData = {
|
data = {
|
||||||
"projName": "Test Sample",
|
"name": "Test Sample",
|
||||||
"projTitle": "Test Novel",
|
"author": "Jane Doe",
|
||||||
"projAuthor": "Jane Doe",
|
"path": fncPath,
|
||||||
"projPath": fncPath,
|
"sample": True,
|
||||||
"popSample": True,
|
|
||||||
"popMinimal": False,
|
|
||||||
"popCustom": False,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
projBuild = ProjectBuilder()
|
builder = ProjectBuilder()
|
||||||
|
|
||||||
# No path set
|
# 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
|
# Force the lookup path for assets to our temp folder
|
||||||
srcSample = CONFIG._appRoot / "sample"
|
srcSample = CONFIG._appRoot / "sample"
|
||||||
@@ -538,13 +525,13 @@ def testCoreTools_NewSample(monkeypatch, mockGUI, fncPath, tstPaths):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Cannot extract when the zip does not exist
|
# 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
|
# Create and open a defective zip file
|
||||||
with open(dstSample, mode="w+") as outFile:
|
with open(dstSample, mode="w+") as outFile:
|
||||||
outFile.write("foo")
|
outFile.write("foo")
|
||||||
|
|
||||||
assert projBuild.buildProject(projData) is False
|
assert builder.buildProject(data) is False
|
||||||
dstSample.unlink()
|
dstSample.unlink()
|
||||||
|
|
||||||
# Create a real zip file, and unpack it
|
# 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():
|
for docFile in (srcSample / "content").iterdir():
|
||||||
zipObj.write(docFile, f"content/{docFile.name}")
|
zipObj.write(docFile, f"content/{docFile.name}")
|
||||||
|
|
||||||
assert projBuild.buildProject(projData) is True
|
assert builder.buildProject(data) is True
|
||||||
dstSample.unlink()
|
dstSample.unlink()
|
||||||
|
|
||||||
# END Test testCoreTools_NewSample
|
# END Test testCoreTools_ProjectBuilderSample
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from mocked import causeOSError
|
|||||||
from novelwriter import CONFIG
|
from novelwriter import CONFIG
|
||||||
from novelwriter.constants import nwFiles
|
from novelwriter.constants import nwFiles
|
||||||
from novelwriter.core.project import NWProject
|
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.document import NWDocument
|
||||||
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
|
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
|
||||||
|
|
||||||
@@ -67,16 +67,16 @@ def testCoreStorage_CreateNewProject(mockGUI, fncPath):
|
|||||||
|
|
||||||
# Cannot prepare a non-empty folder
|
# Cannot prepare a non-empty folder
|
||||||
(fncPath / "foobar.txt").touch()
|
(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
|
# 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").is_dir()
|
||||||
assert (fncPath / "project1" / "meta").is_dir()
|
assert (fncPath / "project1" / "meta").is_dir()
|
||||||
assert (fncPath / "project1" / "content").is_dir()
|
assert (fncPath / "project1" / "content").is_dir()
|
||||||
|
|
||||||
# However, the parent folder must exist
|
# 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)
|
assert isinstance(storage.exc, FileNotFoundError)
|
||||||
|
|
||||||
project.closeProject()
|
project.closeProject()
|
||||||
@@ -297,48 +297,6 @@ def testCoreStorage_ZipIt(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd):
|
|||||||
# END Test testCoreStorage_ZipIt
|
# 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
|
@pytest.mark.core
|
||||||
def testCoreStorage_LegacyDataFolder(monkeypatch, fncPath):
|
def testCoreStorage_LegacyDataFolder(monkeypatch, fncPath):
|
||||||
"""Test project file format 1.0 folder structure conversion."""
|
"""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 shutil import copyfile
|
||||||
|
|
||||||
from tools import (
|
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.QtGui import QColor, QPalette
|
||||||
from PyQt5.QtCore import Qt
|
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 import CONFIG, SHARED
|
||||||
from novelwriter.enum import nwItemType, nwView, nwWidget
|
from novelwriter.enum import nwItemType, nwView, nwWidget
|
||||||
from novelwriter.constants import nwFiles
|
|
||||||
from novelwriter.gui.outline import GuiOutlineView
|
from novelwriter.gui.outline import GuiOutlineView
|
||||||
from novelwriter.gui.projtree import GuiProjectTree
|
from novelwriter.gui.projtree import GuiProjectTree
|
||||||
from novelwriter.gui.doceditor import GuiDocEditor
|
from novelwriter.gui.doceditor import GuiDocEditor
|
||||||
from novelwriter.gui.noveltree import GuiNovelView
|
from novelwriter.gui.noveltree import GuiNovelView
|
||||||
|
from novelwriter.tools.welcome import GuiWelcome
|
||||||
from novelwriter.dialogs.about import GuiAbout
|
from novelwriter.dialogs.about import GuiAbout
|
||||||
from novelwriter.dialogs.projload import GuiProjectLoad
|
|
||||||
from novelwriter.dialogs.editlabel import GuiEditLabel
|
from novelwriter.dialogs.editlabel import GuiEditLabel
|
||||||
from novelwriter.tools.projwizard import GuiProjectWizard
|
|
||||||
|
|
||||||
KEY_DELAY = 1
|
KEY_DELAY = 1
|
||||||
|
|
||||||
@@ -70,8 +68,8 @@ def testGuiMain_ProjectBlocker(nwGUI):
|
|||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
def testGuiMain_Launch(qtbot, monkeypatch, nwGUI, projPath):
|
def testGuiMain_Launch(qtbot, monkeypatch, nwGUI, projPath):
|
||||||
"""Test the handling of launch tasks."""
|
"""Test the handling of launch tasks."""
|
||||||
monkeypatch.setattr(GuiProjectLoad, "exec_", lambda *a: None)
|
monkeypatch.setattr(GuiWelcome, "exec_", lambda *a: None)
|
||||||
monkeypatch.setattr(GuiProjectLoad, "result", lambda *a: QDialog.Accepted)
|
# monkeypatch.setattr(GuiProjectLoad, "result", lambda *a: QDialog.Accepted)
|
||||||
CONFIG.lastNotes = "0x0"
|
CONFIG.lastNotes = "0x0"
|
||||||
buildTestProject(nwGUI, projPath)
|
buildTestProject(nwGUI, projPath)
|
||||||
|
|
||||||
@@ -103,53 +101,16 @@ def testGuiMain_Launch(qtbot, monkeypatch, nwGUI, projPath):
|
|||||||
|
|
||||||
# Check that project open dialog launches
|
# Check that project open dialog launches
|
||||||
nwGUI.postLaunchTasks(None)
|
nwGUI.postLaunchTasks(None)
|
||||||
qtbot.waitUntil(lambda: getGuiItem("GuiProjectLoad") is not None, timeout=1000)
|
qtbot.waitUntil(lambda: getGuiItem("GuiWelcome") is not None, timeout=1000)
|
||||||
nwLoad = getGuiItem("GuiProjectLoad")
|
assert isinstance(welcome := getGuiItem("GuiWelcome"), GuiWelcome)
|
||||||
assert isinstance(nwLoad, GuiProjectLoad)
|
welcome.show()
|
||||||
nwLoad.show()
|
welcome.close()
|
||||||
nwLoad.reject()
|
|
||||||
|
|
||||||
# qtbot.stop()
|
# qtbot.stop()
|
||||||
|
|
||||||
# END Test testGuiMain_Launch
|
# 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
|
@pytest.mark.gui
|
||||||
def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||||
"""Test handling of project tree items based on GUI focus states."""
|
"""Test handling of project tree items based on GUI focus states."""
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ def testGuiTheme_Syntax(qtbot, monkeypatch, nwGUI):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
def testGuiTheme_Icons(qtbot, caplog, monkeypatch, nwGUI, tstPaths):
|
def testGuiTheme_IconThemes(qtbot, caplog, monkeypatch, tstPaths):
|
||||||
"""Test the icon cache class."""
|
"""Test the icon cache class."""
|
||||||
iconCache = SHARED.theme.iconCache
|
iconCache = SHARED.theme.iconCache
|
||||||
|
|
||||||
@@ -305,38 +305,16 @@ def testGuiTheme_Icons(qtbot, caplog, monkeypatch, nwGUI, tstPaths):
|
|||||||
assert iconCache.loadTheme("typicons_dark") is True
|
assert iconCache.loadTheme("typicons_dark") is True
|
||||||
assert "add" in iconCache._themeMap
|
assert "add" in iconCache._themeMap
|
||||||
|
|
||||||
# Load Decorations
|
# qtbot.stop()
|
||||||
# ================
|
|
||||||
|
|
||||||
# Invalid name should return empty pixmap
|
# END Test testGuiTheme_IconThemes
|
||||||
qPix = iconCache.loadDecoration("stuff")
|
|
||||||
assert qPix.isNull() is True
|
|
||||||
|
|
||||||
# Load an image
|
|
||||||
qPix = iconCache.loadDecoration("wiz-back")
|
|
||||||
assert qPix.isNull() is False
|
|
||||||
|
|
||||||
# Fail finding the file
|
@pytest.mark.gui
|
||||||
with monkeypatch.context() as mp:
|
def testGuiTheme_LoadIcons(qtbot):
|
||||||
mp.setattr("pathlib.Path.is_file", lambda *a: False)
|
"""Test the icon cache class."""
|
||||||
qPix = iconCache.loadDecoration("wiz-back")
|
iconCache = SHARED.theme.iconCache
|
||||||
assert qPix.isNull() is True
|
assert iconCache.loadTheme("typicons_dark") 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
|
|
||||||
|
|
||||||
# Load Icons
|
# Load Icons
|
||||||
# ==========
|
# ==========
|
||||||
@@ -368,6 +346,19 @@ def testGuiTheme_Icons(qtbot, caplog, monkeypatch, nwGUI, tstPaths):
|
|||||||
assert isinstance(qIcon, QIcon)
|
assert isinstance(qIcon, QIcon)
|
||||||
assert qIcon.isNull() is False
|
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
|
# Load Item Icons
|
||||||
# ===============
|
# ===============
|
||||||
|
|
||||||
@@ -421,6 +412,50 @@ def testGuiTheme_Icons(qtbot, caplog, monkeypatch, nwGUI, tstPaths):
|
|||||||
nwItemType.NO_TYPE, nwItemClass.NOVEL, nwItemLayout.DOCUMENT, hLevel="H0"
|
nwItemType.NO_TYPE, nwItemClass.NOVEL, nwItemLayout.DOCUMENT, hLevel="H0"
|
||||||
).isNull() is True
|
).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
|
# Header Decorations
|
||||||
# ==================
|
# ==================
|
||||||
|
|
||||||
@@ -432,6 +467,18 @@ def testGuiTheme_Icons(qtbot, caplog, monkeypatch, nwGUI, tstPaths):
|
|||||||
assert iconCache.getHeaderDecoration(4) == iconCache._headerDec[4]
|
assert iconCache.getHeaderDecoration(4) == iconCache._headerDec[4]
|
||||||
assert iconCache.getHeaderDecoration(5) == 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()
|
# 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
|
||||||