"""
novelWriter – Config Class
==========================
File History:
Created: 2018-09-22 [0.0.1] Config
Created: 2022-11-09 [2.0rc2] RecentProjects
Created: 2024-06-16 [2.5rc1] RecentPaths
This file is a part of novelWriter
Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
from time import time
from typing import TYPE_CHECKING, Final
from PyQt6.QtCore import (
PYQT_VERSION, PYQT_VERSION_STR, QT_VERSION, QT_VERSION_STR, QDate,
QDateTime, QLibraryInfo, QLocale, QStandardPaths, QSysInfo, QTranslator
)
from PyQt6.QtGui import QFont, QFontDatabase, QFontMetrics
from PyQt6.QtWidgets import QApplication
from novelwriter.common import (
NWConfigParser, checkInt, checkPath, describeFont, fontMatcher,
formatTimeStamp, processDialogSymbols, simplified
)
from novelwriter.constants import nwFiles, nwQuotes, nwUnicode
from novelwriter.enum import nwTheme
from novelwriter.error import formatException, logException
if TYPE_CHECKING:
from datetime import datetime
from novelwriter.core.projectdata import NWProjectData
from novelwriter.splash import NSplashScreen
logger = logging.getLogger(__name__)
DEF_GUI_DARK = "default_dark"
DEF_GUI_LIGHT = "default_light"
DEF_ICONS = "material_rounded_normal"
DEF_TREECOL = "theme"
class Config:
__slots__ = (
"_appPath", "_appRoot", "_backPath", "_backupPath", "_confPath", "_dLocale", "_dShortDate",
"_dShortDateTime", "_dataPath", "_errData", "_hasError", "_homePath", "_lastAuthor",
"_manuals", "_nwLangPath", "_qLocale", "_qtLangPath", "_qtTrans", "_recentPaths",
"_recentProjects", "_splash", "allowOpenDial", "altDialogClose", "altDialogOpen",
"appHandle", "appName", "askBeforeBackup", "askBeforeExit", "autoSaveDoc", "autoSaveProj",
"autoScroll", "autoScrollPos", "autoSelect", "backupOnClose", "cursorWidth", "darkTheme",
"dialogLine", "dialogStyle", "doJustify", "doReplace", "doReplaceDQuote", "doReplaceDash",
"doReplaceDots", "doReplaceSQuote", "emphLabels", "fmtApostrophe", "fmtDQuoteClose",
"fmtDQuoteOpen", "fmtPadAfter", "fmtPadBefore", "fmtPadThin", "fmtSQuoteClose",
"fmtSQuoteOpen", "focusWidth", "guiFont", "guiLocale", "hasEnchant", "hideFocusFooter",
"hideHScroll", "hideVScroll", "highlightEmph", "hostName", "iconColDocs", "iconColTree",
"iconTheme", "incNotesWCount", "isDebug", "kernelVer", "lastNotes", "lightTheme",
"lineHighlight", "mainPanePos", "mainWinSize", "memInfo", "narratorBreak",
"narratorDialog", "nativeFont", "osDarwin", "osLinux", "osType", "osUnknown", "osWindows",
"outlinePanePos", "prefsWinSize", "scrollPastEnd", "searchCase", "searchLoop",
"searchMatchCap", "searchNextFile", "searchProjCase", "searchProjRegEx", "searchProjWord",
"searchRegEx", "searchWord", "showEditToolBar", "showFullPath", "showLineEndings",
"showMultiSpaces", "showSessionTime", "showTabsNSpaces", "showViewerPanel",
"spellLanguage", "stopWhenIdle", "tabWidth", "textFont", "textMargin", "textWidth",
"themeMode", "useCharCount", "userIdleTime", "verPyQtString", "verPyQtValue",
"verPyString", "verQtString", "verQtValue", "viewComments", "viewNotes", "viewPanePos",
"viewSynopsis", "welcomeWinSize",
)
LANG_NW = 1
LANG_PROJ = 2
def __init__(self) -> None:
# Initialisation
# ==============
self._splash = None
# Set Application Variables
self.appName = "novelWriter"
self.appHandle = "novelwriter"
# Set Paths
confRoot = Path(QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.ConfigLocation)
)
dataRoot = Path(QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.AppDataLocation)
)
self._confPath = confRoot.absolute() / self.appHandle # The user config location
self._dataPath = dataRoot.absolute() / self.appHandle # The user data location
self._homePath = Path.home().absolute() # The user's home directory
self._backPath = self._homePath / "Backups"
self._appPath = Path(__file__).parent.absolute()
self._appRoot = self._appPath.parent
if getattr(sys, "frozen", False): # pragma: no cover
# novelWriter is packaged as an exe
self._appPath = Path(__file__).parent.parent.absolute()
self._appRoot = self._appPath
# Runtime Settings and Variables
self._hasError = False # True if the config class encountered an error
self._errData = [] # List of error messages
# Localisation
# Note that these paths must be strings
self._nwLangPath = self._appPath / "assets" / "i18n"
self._qtLangPath = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
hasLocale = (self._nwLangPath / f"nw_{QLocale.system().name()}.qm").exists()
self._qLocale = QLocale.system() if hasLocale else QLocale("en_GB")
self._dLocale = QLocale.system()
self._dShortDate = self._dLocale.dateFormat(QLocale.FormatType.ShortFormat)
self._dShortDateTime = self._dLocale.dateTimeFormat(QLocale.FormatType.ShortFormat)
self._qtTrans = {}
# PDF Manual
self._manuals: dict[str, Path] = {}
if (assets := self._appPath / "assets").is_dir():
for item in assets.iterdir():
if item.is_file() and item.stem.startswith("manual") and item.suffix == ".pdf":
self._manuals[item.stem] = item
# User Settings
# =============
self._recentProjects = RecentProjects(self)
self._recentPaths = RecentPaths(self)
self._lastAuthor = ""
# General GUI Settings
self.guiLocale = self._qLocale.name()
self.lightTheme = DEF_GUI_LIGHT # Light GUI theme
self.darkTheme = DEF_GUI_DARK # Dark GUI theme
self.themeMode = nwTheme.AUTO # Colour theme mode
self.guiFont = QFont() # Main GUI font
self.hideVScroll = False # Hide vertical scroll bars on main widgets
self.hideHScroll = False # Hide horizontal scroll bars on main widgets
self.lastNotes = "0x0" # The latest release notes that have been shown
self.nativeFont = True # Use native font dialog
self.useCharCount = False # Use character count as primary count
# Icons
self.iconTheme = DEF_ICONS # Icons theme
self.iconColTree = DEF_TREECOL # Project tree icon colours
self.iconColDocs = False # Keep theme colours on documents
# Size Settings
self.mainWinSize = [1200, 650] # Last size of the main GUI window
self.welcomeWinSize = [800, 550] # Last size of the welcome window
self.prefsWinSize = [700, 615] # Last size of the Preferences dialog
self.mainPanePos = [300, 800] # Last position of the main window splitter
self.viewPanePos = [500, 150] # Last position of the document viewer splitter
self.outlinePanePos = [500, 150] # Last position of the outline panel splitter
# Project Settings
self.autoSaveProj = 60 # Interval for auto-saving project, in seconds
self.autoSaveDoc = 30 # Interval for auto-saving document, in seconds
self.emphLabels = False # Add emphasis to H1 and H2 item labels
self.backupOnClose = True # Flag for running automatic backups
self.askBeforeBackup = True # Flag for asking before running automatic backup
self.askBeforeExit = True # Flag for asking before exiting the app
# Text Editor Settings
self.textFont = QFont() # Editor font
self.textWidth = 700 # Editor text width
self.textMargin = 40 # Editor/viewer text margin
self.tabWidth = 40 # Editor tabulator width
self.cursorWidth = 1 # Editor cursor width
self.lineHighlight = False # Highlight current line in editor
self.focusWidth = 800 # Focus Mode text width
self.hideFocusFooter = False # Hide document footer in Focus Mode
self.showFullPath = True # Show full document path in editor header
self.autoSelect = True # Auto-select word when applying format with no selection
self.doJustify = False # Justify text
self.showTabsNSpaces = False # Show tabs and spaces in editor
self.showLineEndings = False # Show line endings in editor
self.showMultiSpaces = True # Highlight multiple spaces in the text
self.doReplace = True # Enable auto-replace as you type
self.doReplaceSQuote = True # Smart single quotes
self.doReplaceDQuote = True # Smart double quotes
self.doReplaceDash = True # Replace multiple hyphens with dashes
self.doReplaceDots = True # Replace three dots with ellipsis
self.autoScroll = True # Typewriter-like scrolling
self.autoScrollPos = 30 # Start point for typewriter-like scrolling
self.scrollPastEnd = True # Scroll past end of document, and centre cursor
self.dialogStyle = 2 # Quote type to use for dialogue
self.allowOpenDial = True # Allow open-ended dialogue quotes
self.dialogLine = "" # Symbol to use for dialogue line
self.narratorBreak = "" # Symbol to use for narrator break
self.narratorDialog = "" # Symbol for alternating between dialogue and narrator
self.altDialogOpen = "" # Alternative dialog symbol, open
self.altDialogClose = "" # Alternative dialog symbol, close
self.highlightEmph = True # Add colour to text emphasis
self.stopWhenIdle = True # Stop the status bar clock when the user is idle
self.userIdleTime = 300 # Time of inactivity to consider user idle
self.incNotesWCount = True # The status bar word count includes notes
# User-Selected Symbol Settings
self.fmtApostrophe = nwUnicode.U_RSQUO
self.fmtSQuoteOpen = nwUnicode.U_LSQUO
self.fmtSQuoteClose = nwUnicode.U_RSQUO
self.fmtDQuoteOpen = nwUnicode.U_LDQUO
self.fmtDQuoteClose = nwUnicode.U_RDQUO
self.fmtPadBefore = ""
self.fmtPadAfter = ""
self.fmtPadThin = False
# User Paths
self._backupPath = self._backPath # Backup path to use, can be none
# Spell Checking Settings
self.spellLanguage = "en"
# State
self.showViewerPanel = True # The panel for the viewer is visible
self.showEditToolBar = False # The document editor toolbar visibility
self.showSessionTime = True # Show the session time in the status bar
self.viewComments = True # Comments are shown in the viewer
self.viewSynopsis = True # Synopsis is shown in the viewer
self.viewNotes = True # Notes are shown in the viewer
# Search Box States
self.searchCase = False
self.searchWord = False
self.searchRegEx = False
self.searchLoop = False
self.searchNextFile = False
self.searchMatchCap = False
self.searchProjCase = False
self.searchProjWord = False
self.searchProjRegEx = False
# System and App Information
# ==========================
# Check Qt Versions
self.verQtString = QT_VERSION_STR
self.verQtValue = QT_VERSION
self.verPyQtString = PYQT_VERSION_STR
self.verPyQtValue = PYQT_VERSION
# Check Python Version
self.verPyString = sys.version.split()[0]
# Check OS Type
self.osType = sys.platform
self.osLinux = False
self.osWindows = False
self.osDarwin = False
self.osUnknown = False
if self.osType.startswith("linux"):
self.osLinux = True
elif self.osType.startswith("darwin"):
self.osDarwin = True
elif self.osType.startswith("win32"):
self.osWindows = True
elif self.osType.startswith("cygwin"):
self.osWindows = True
else:
self.osUnknown = True
# Other System Info
self.hostName = QSysInfo.machineHostName()
self.kernelVer = QSysInfo.kernelVersion()
self.isDebug = False # True if running in debug mode
self.memInfo = False # True if displaying mem info in status bar
# Packages
self.hasEnchant = False # The pyenchant package
return
##
# Properties
##
@property
def hasError(self) -> bool:
return self._hasError
@property
def pdfDocs(self) -> Path | None:
"""Return the local manual PDF file, if any exist."""
return self._manuals.get(f"manual_{self.locale.bcp47Name()}", self._manuals.get("manual"))
@property
def nwLangPath(self) -> Path:
return self._nwLangPath
@property
def locale(self) -> QLocale:
return self._dLocale
@property
def recentProjects(self) -> RecentProjects:
return self._recentProjects
@property
def lastAuthor(self) -> str:
"""Return the last author name used."""
return simplified(self._lastAuthor)
##
# Getters
##
def getTextWidth(self, focusMode: bool = False) -> int:
"""Get the text with for the correct editor mode."""
if focusMode:
return max(self.focusWidth, 200)
else:
return max(self.textWidth, 200)
##
# Setters
##
def setLastAuthor(self, value: str) -> None:
"""Set tle last used author name."""
self._lastAuthor = simplified(value)
return
def setMainWinSize(self, width: int, height: int) -> None:
"""Set the size of the main window, but only if the change is
larger than 5 pixels. The OS window manager will sometimes
adjust it a bit, and we don't want the main window to shrink or
grow each time the app is opened.
"""
if abs(self.mainWinSize[0] - width) > 5:
self.mainWinSize[0] = width
if abs(self.mainWinSize[1] - height) > 5:
self.mainWinSize[1] = height
return
def setWelcomeWinSize(self, width: int, height: int) -> None:
"""Set the size of the Preferences dialog window."""
self.welcomeWinSize = [width, height]
return
def setPreferencesWinSize(self, width: int, height: int) -> None:
"""Set the size of the Preferences dialog window."""
self.prefsWinSize = [width, height]
return
def setLastPath(self, key: str, path: str | Path) -> None:
"""Set the last used path. Only the folder is saved, so if the
path is not a folder, the parent of the path is used instead.
"""
if isinstance(path, str | Path):
path = checkPath(path, self._homePath)
if not path.is_dir():
path = path.parent
if path.is_dir():
self._recentPaths.setPath(key, path)
return
def setBackupPath(self, path: Path | str) -> None:
"""Set the current backup path."""
self._backupPath = checkPath(path, self._backPath)
return
def setGuiFont(self, value: QFont | str | None) -> None:
"""Update the GUI's font style from settings."""
if isinstance(value, QFont):
self.guiFont = fontMatcher(value)
elif value and isinstance(value, str):
font = QFont()
font.fromString(value)
self.guiFont = fontMatcher(font)
else:
font = QFont()
if self.osWindows and "Arial" in QFontDatabase.families():
# On Windows we default to Arial if possible
font.setFamily("Arial")
font.setPointSize(10)
else:
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont)
self.guiFont = fontMatcher(font)
logger.debug("Main font set to: %s", describeFont(font))
QApplication.setFont(self.guiFont)
return
def setTextFont(self, value: QFont | str | None) -> None:
"""Set the text font if it exists. If it doesn't, or is None,
set to default font.
"""
if isinstance(value, QFont):
self.textFont = fontMatcher(value)
elif value and isinstance(value, str):
font = QFont()
font.fromString(value)
self.textFont = fontMatcher(font)
else:
fontFam = QFontDatabase.families()
if self.osWindows and "Arial" in fontFam:
font = QFont()
font.setFamily("Arial")
font.setPointSize(12)
elif self.osDarwin and "Helvetica" in fontFam:
font = QFont()
font.setFamily("Helvetica")
font.setPointSize(12)
else:
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont)
self.textFont = fontMatcher(font)
logger.debug("Text font set to: %s", describeFont(self.textFont))
return
##
# Methods
##
def homePath(self) -> Path:
"""The user's home folder."""
return self._homePath
def dataPath(self, target: str | None = None) -> Path:
"""Return a path in the data folder."""
if isinstance(target, str):
return self._dataPath / target
return self._dataPath
def assetPath(self, target: str | None = None) -> Path:
"""Return a path in the assets folder."""
if isinstance(target, str):
return self._appPath / "assets" / target
return self._appPath / "assets"
def lastPath(self, key: str) -> Path:
"""Return the last path used by the user, if it exists."""
if path := self._recentPaths.getPath(key):
asPath = Path(path)
if asPath.is_dir():
return asPath
return self._homePath
def backupPath(self) -> Path:
"""Return the backup path."""
if isinstance(self._backupPath, Path) and self._backupPath.is_dir():
return self._backupPath
return self._backPath
def errorText(self) -> str:
"""Compile and return error messages from the initialisation of
the Config class, and clear the error buffer.
"""
message = "
".join(self._errData)
self._hasError = False
self._errData = []
return message
def localDate(self, value: datetime) -> str:
"""Return a localised date format."""
# Explicitly convert the date first, see bug #2325
return self._dLocale.toString(QDate(value.year, value.month, value.day), self._dShortDate)
def localDateTime(self, value: datetime) -> str:
"""Return a localised datetime format."""
# Explicitly convert the datetime first, see bug #2325
return self._dLocale.toString(
QDateTime(value.year, value.month, value.day, value.hour, value.minute, value.second),
self._dShortDateTime,
)
def listLanguages(self, lngSet: int) -> list[tuple[str, str]]:
"""List localisation files in the i18n folder. The default GUI
language is British English (en_GB).
"""
if lngSet == self.LANG_NW:
fPre = "nw_"
fExt = ".qm"
langList = {"en_GB": QLocale("en_GB").nativeLanguageName().title()}
elif lngSet == self.LANG_PROJ:
fPre = "project_"
fExt = ".json"
langList = {"en_GB": QLocale("en_GB").nativeLanguageName().title()}
else:
return []
for qmFile in self._nwLangPath.iterdir():
qmName = qmFile.name
if not (qmFile.is_file() and qmName.startswith(fPre) and qmName.endswith(fExt)):
continue
qmLang = qmName[len(fPre):-len(fExt)]
qmName = QLocale(qmLang).nativeLanguageName().title()
if qmLang and qmName and qmLang != "en_GB":
langList[qmLang] = qmName
return sorted(langList.items(), key=lambda x: x[0])
def splashMessage(self, message: str) -> None:
"""Send a message to the splash screen."""
if self._splash:
self._splash.showStatus(message)
return
##
# Config Actions
##
def initConfig(
self, confPath: str | Path | None = None, dataPath: str | Path | None = None
) -> None:
"""Initialise the config class. The manual setting of confPath
and dataPath is mainly intended for the test suite.
"""
logger.debug("Initialising Config ...")
if isinstance(confPath, str | Path):
logger.info("Setting alternative config path: %s", confPath)
self._confPath = Path(confPath)
if isinstance(dataPath, str | Path):
logger.info("Setting alternative data path: %s", dataPath)
self._dataPath = Path(dataPath)
logger.debug("Config Path: %s", self._confPath)
logger.debug("Data Path: %s", self._dataPath)
logger.debug("App Root: %s", self._appRoot)
logger.debug("App Path: %s", self._appPath)
logger.debug("PDF Manual: %s", self.pdfDocs)
# If the config and data folders don't exist, create them
# This assumes that the os config and data folders exist
self._confPath.mkdir(exist_ok=True)
self._dataPath.mkdir(exist_ok=True)
# Also create the themes and icons folders if possible
if self._dataPath.is_dir():
(self._dataPath / "cache").mkdir(exist_ok=True)
(self._dataPath / "icons").mkdir(exist_ok=True)
(self._dataPath / "themes").mkdir(exist_ok=True)
self._recentPaths.loadCache()
self._recentProjects.loadCache()
self._checkOptionalPackages()
logger.debug("Config instance initialised")
return
def initLocalisation(self, nwApp: QApplication) -> None:
"""Initialise the localisation of the GUI."""
self.splashMessage("Loading localisation ...")
self._qLocale = QLocale(self.guiLocale)
QLocale.setDefault(self._qLocale)
self._qtTrans = {}
hasLocale = (self._nwLangPath / f"nw_{self._qLocale.name()}.qm").exists()
self._dLocale = self._qLocale if hasLocale else QLocale.system()
self._dShortDate = self._dLocale.dateFormat(QLocale.FormatType.ShortFormat)
self._dShortDateTime = self._dLocale.dateTimeFormat(QLocale.FormatType.ShortFormat)
langList = [
(self._qtLangPath, "qtbase"), # Qt
(str(self._nwLangPath), "nw"), # novelWriter
]
for lngPath, lngBase in langList:
for lngCode in self._qLocale.uiLanguages():
qTrans = QTranslator()
lngFile = "{0}_{1}".format(lngBase, lngCode.replace("-", "_"))
if lngFile not in self._qtTrans:
if qTrans.load(lngFile, lngPath):
logger.debug("Loaded: %s.qm", lngFile)
nwApp.installTranslator(qTrans)
self._qtTrans[lngFile] = qTrans
return
def loadConfig(self, splash: NSplashScreen | None = None) -> bool:
"""Load preferences from file and replace default settings."""
self._splash = splash
self.splashMessage("Loading user configuration ...")
logger.debug("Loading config file")
conf = NWConfigParser()
cnfPath = self._confPath / nwFiles.CONF_FILE
if not cnfPath.exists():
# Initial file, so we just create one from defaults
self.setGuiFont(None)
self.setTextFont(None)
self.saveConfig()
return True
try:
with open(cnfPath, mode="r", encoding="utf-8") as inFile:
conf.read_file(inFile)
except Exception as exc:
logger.error("Could not load config file")
logException()
self._hasError = True
self._errData.append("Could not load config file")
self._errData.append(formatException(exc))
return False
# Main
sec = "Main"
self.setGuiFont(conf.rdStr(sec, "font", ""))
self.lightTheme = conf.rdStr(sec, "lighttheme", self.lightTheme)
self.darkTheme = conf.rdStr(sec, "darktheme", self.darkTheme)
self.themeMode = conf.rdEnum(sec, "thememode", self.themeMode)
self.iconTheme = conf.rdStr(sec, "icons", self.iconTheme)
self.iconColTree = conf.rdStr(sec, "iconcoltree", self.iconColTree)
self.iconColDocs = conf.rdBool(sec, "iconcoldocs", self.iconColDocs)
self.guiLocale = conf.rdStr(sec, "localisation", self.guiLocale)
self.hideVScroll = conf.rdBool(sec, "hidevscroll", self.hideVScroll)
self.hideHScroll = conf.rdBool(sec, "hidehscroll", self.hideHScroll)
self.lastNotes = conf.rdStr(sec, "lastnotes", self.lastNotes)
self.nativeFont = conf.rdBool(sec, "nativefont", self.nativeFont)
self.useCharCount = conf.rdBool(sec, "usecharcount", self.useCharCount)
# Sizes
sec = "Sizes"
self.mainWinSize = conf.rdIntList(sec, "mainwindow", self.mainWinSize)
self.welcomeWinSize = conf.rdIntList(sec, "welcome", self.welcomeWinSize)
self.prefsWinSize = conf.rdIntList(sec, "preferences", self.prefsWinSize)
self.mainPanePos = conf.rdIntList(sec, "mainpane", self.mainPanePos)
self.viewPanePos = conf.rdIntList(sec, "viewpane", self.viewPanePos)
self.outlinePanePos = conf.rdIntList(sec, "outlinepane", self.outlinePanePos)
# Project
sec = "Project"
self.autoSaveProj = conf.rdInt(sec, "autosaveproject", self.autoSaveProj)
self.autoSaveDoc = conf.rdInt(sec, "autosavedoc", self.autoSaveDoc)
self.emphLabels = conf.rdBool(sec, "emphlabels", self.emphLabels)
self._backupPath = conf.rdPath(sec, "backuppath", self._backupPath)
self.backupOnClose = conf.rdBool(sec, "backuponclose", self.backupOnClose)
self.askBeforeBackup = conf.rdBool(sec, "askbeforebackup", self.askBeforeBackup)
self.askBeforeExit = conf.rdBool(sec, "askbeforeexit", self.askBeforeExit)
self._lastAuthor = conf.rdStr(sec, "lastauthor", self._lastAuthor)
# Editor
sec = "Editor"
self.setTextFont(conf.rdStr(sec, "textfont", ""))
self.textWidth = conf.rdInt(sec, "width", self.textWidth)
self.textMargin = conf.rdInt(sec, "margin", self.textMargin)
self.tabWidth = conf.rdInt(sec, "tabwidth", self.tabWidth)
self.cursorWidth = conf.rdInt(sec, "cursorwidth", self.cursorWidth)
self.lineHighlight = conf.rdBool(sec, "linehighlight", self.lineHighlight)
self.focusWidth = conf.rdInt(sec, "focuswidth", self.focusWidth)
self.hideFocusFooter = conf.rdBool(sec, "hidefocusfooter", self.hideFocusFooter)
self.doJustify = conf.rdBool(sec, "justify", self.doJustify)
self.autoSelect = conf.rdBool(sec, "autoselect", self.autoSelect)
self.doReplace = conf.rdBool(sec, "autoreplace", self.doReplace)
self.doReplaceSQuote = conf.rdBool(sec, "repsquotes", self.doReplaceSQuote)
self.doReplaceDQuote = conf.rdBool(sec, "repdquotes", self.doReplaceDQuote)
self.doReplaceDash = conf.rdBool(sec, "repdash", self.doReplaceDash)
self.doReplaceDots = conf.rdBool(sec, "repdots", self.doReplaceDots)
self.autoScroll = conf.rdBool(sec, "autoscroll", self.autoScroll)
self.autoScrollPos = conf.rdInt(sec, "autoscrollpos", self.autoScrollPos)
self.scrollPastEnd = conf.rdBool(sec, "scrollpastend", self.scrollPastEnd)
self.fmtSQuoteOpen = conf.rdStr(sec, "fmtsquoteopen", self.fmtSQuoteOpen)
self.fmtSQuoteClose = conf.rdStr(sec, "fmtsquoteclose", self.fmtSQuoteClose)
self.fmtDQuoteOpen = conf.rdStr(sec, "fmtdquoteopen", self.fmtDQuoteOpen)
self.fmtDQuoteClose = conf.rdStr(sec, "fmtdquoteclose", self.fmtDQuoteClose)
self.fmtPadBefore = conf.rdStr(sec, "fmtpadbefore", self.fmtPadBefore)
self.fmtPadAfter = conf.rdStr(sec, "fmtpadafter", self.fmtPadAfter)
self.fmtPadThin = conf.rdBool(sec, "fmtpadthin", self.fmtPadThin)
self.spellLanguage = conf.rdStr(sec, "spellcheck", self.spellLanguage)
self.showTabsNSpaces = conf.rdBool(sec, "showtabsnspaces", self.showTabsNSpaces)
self.showLineEndings = conf.rdBool(sec, "showlineendings", self.showLineEndings)
self.showMultiSpaces = conf.rdBool(sec, "showmultispaces", self.showMultiSpaces)
self.incNotesWCount = conf.rdBool(sec, "incnoteswcount", self.incNotesWCount)
self.showFullPath = conf.rdBool(sec, "showfullpath", self.showFullPath)
self.dialogStyle = conf.rdInt(sec, "dialogstyle", self.dialogStyle)
self.allowOpenDial = conf.rdBool(sec, "allowopendial", self.allowOpenDial)
dialogLine = conf.rdStr(sec, "dialogline", self.dialogLine)
narratorBreak = conf.rdStr(sec, "narratorbreak", self.narratorBreak)
narratorDialog = conf.rdStr(sec, "narratordialog", self.narratorDialog)
self.altDialogOpen = conf.rdStr(sec, "altdialogopen", self.altDialogOpen)
self.altDialogClose = conf.rdStr(sec, "altdialogclose", self.altDialogClose)
self.highlightEmph = conf.rdBool(sec, "highlightemph", self.highlightEmph)
self.stopWhenIdle = conf.rdBool(sec, "stopwhenidle", self.stopWhenIdle)
self.userIdleTime = conf.rdInt(sec, "useridletime", self.userIdleTime)
# State
sec = "State"
self.showViewerPanel = conf.rdBool(sec, "showviewerpanel", self.showViewerPanel)
self.showEditToolBar = conf.rdBool(sec, "showedittoolbar", self.showEditToolBar)
self.showSessionTime = conf.rdBool(sec, "showsessiontime", self.showSessionTime)
self.viewComments = conf.rdBool(sec, "viewcomments", self.viewComments)
self.viewSynopsis = conf.rdBool(sec, "viewsynopsis", self.viewSynopsis)
self.viewNotes = conf.rdBool(sec, "viewnotes", self.viewNotes)
self.searchCase = conf.rdBool(sec, "searchcase", self.searchCase)
self.searchWord = conf.rdBool(sec, "searchword", self.searchWord)
self.searchRegEx = conf.rdBool(sec, "searchregex", self.searchRegEx)
self.searchLoop = conf.rdBool(sec, "searchloop", self.searchLoop)
self.searchNextFile = conf.rdBool(sec, "searchnextfile", self.searchNextFile)
self.searchMatchCap = conf.rdBool(sec, "searchmatchcap", self.searchMatchCap)
self.searchProjCase = conf.rdBool(sec, "searchprojcase", self.searchProjCase)
self.searchProjWord = conf.rdBool(sec, "searchprojword", self.searchProjWord)
self.searchProjRegEx = conf.rdBool(sec, "searchprojregex", self.searchProjRegEx)
# Check Values
# ============
self._prepareFont(self.guiFont, "main")
self._prepareFont(self.textFont, "document")
# If we're using straight quotes, disable auto-replace
if self.fmtSQuoteOpen == self.fmtSQuoteClose == "'" and self.doReplaceSQuote:
logger.info("Using straight single quotes, so disabling auto-replace")
self.doReplaceSQuote = False
if self.fmtDQuoteOpen == self.fmtDQuoteClose == '"' and self.doReplaceDQuote:
logger.info("Using straight double quotes, so disabling auto-replace")
self.doReplaceDQuote = False
self.dialogLine = processDialogSymbols(dialogLine)
self.narratorBreak = narratorBreak if narratorBreak in nwQuotes.DASHES else ""
self.narratorDialog = narratorDialog if narratorDialog in nwQuotes.DASHES else ""
return True
def saveConfig(self) -> bool:
"""Save the current preferences to file."""
logger.debug("Saving config file")
conf = NWConfigParser()
conf["Meta"] = {
"timestamp": formatTimeStamp(time()),
}
conf["Main"] = {
"font": self.guiFont.toString(),
"lighttheme": str(self.lightTheme),
"darktheme": str(self.darkTheme),
"thememode": self.themeMode.name,
"icons": str(self.iconTheme),
"iconcoltree": str(self.iconColTree),
"iconcoldocs": str(self.iconColDocs),
"localisation": str(self.guiLocale),
"hidevscroll": str(self.hideVScroll),
"hidehscroll": str(self.hideHScroll),
"lastnotes": str(self.lastNotes),
"nativefont": str(self.nativeFont),
"usecharcount": str(self.useCharCount),
}
conf["Sizes"] = {
"mainwindow": self._packList(self.mainWinSize),
"welcome": self._packList(self.welcomeWinSize),
"preferences": self._packList(self.prefsWinSize),
"mainpane": self._packList(self.mainPanePos),
"viewpane": self._packList(self.viewPanePos),
"outlinepane": self._packList(self.outlinePanePos),
}
conf["Project"] = {
"autosaveproject": str(self.autoSaveProj),
"autosavedoc": str(self.autoSaveDoc),
"emphlabels": str(self.emphLabels),
"backuppath": str(self._backupPath),
"backuponclose": str(self.backupOnClose),
"askbeforebackup": str(self.askBeforeBackup),
"askbeforeexit": str(self.askBeforeExit),
"lastauthor": str(self._lastAuthor),
}
conf["Editor"] = {
"textfont": self.textFont.toString(),
"width": str(self.textWidth),
"margin": str(self.textMargin),
"tabwidth": str(self.tabWidth),
"cursorwidth": str(self.cursorWidth),
"lineHighlight": str(self.lineHighlight),
"focuswidth": str(self.focusWidth),
"hidefocusfooter": str(self.hideFocusFooter),
"justify": str(self.doJustify),
"autoselect": str(self.autoSelect),
"autoreplace": str(self.doReplace),
"repsquotes": str(self.doReplaceSQuote),
"repdquotes": str(self.doReplaceDQuote),
"repdash": str(self.doReplaceDash),
"repdots": str(self.doReplaceDots),
"autoscroll": str(self.autoScroll),
"autoscrollpos": str(self.autoScrollPos),
"scrollpastend": str(self.scrollPastEnd),
"fmtsquoteopen": str(self.fmtSQuoteOpen),
"fmtsquoteclose": str(self.fmtSQuoteClose),
"fmtdquoteopen": str(self.fmtDQuoteOpen),
"fmtdquoteclose": str(self.fmtDQuoteClose),
"fmtpadbefore": str(self.fmtPadBefore),
"fmtpadafter": str(self.fmtPadAfter),
"fmtpadthin": str(self.fmtPadThin),
"spellcheck": str(self.spellLanguage),
"showtabsnspaces": str(self.showTabsNSpaces),
"showlineendings": str(self.showLineEndings),
"showmultispaces": str(self.showMultiSpaces),
"incnoteswcount": str(self.incNotesWCount),
"showfullpath": str(self.showFullPath),
"dialogstyle": str(self.dialogStyle),
"allowopendial": str(self.allowOpenDial),
"dialogline": str(self.dialogLine),
"narratorbreak": str(self.narratorBreak),
"narratordialog": str(self.narratorDialog),
"altdialogopen": str(self.altDialogOpen),
"altdialogclose": str(self.altDialogClose),
"highlightemph": str(self.highlightEmph),
"stopwhenidle": str(self.stopWhenIdle),
"useridletime": str(self.userIdleTime),
}
conf["State"] = {
"showviewerpanel": str(self.showViewerPanel),
"showedittoolbar": str(self.showEditToolBar),
"showsessiontime": str(self.showSessionTime),
"viewcomments": str(self.viewComments),
"viewsynopsis": str(self.viewSynopsis),
"viewnotes": str(self.viewNotes),
"searchcase": str(self.searchCase),
"searchword": str(self.searchWord),
"searchregex": str(self.searchRegEx),
"searchloop": str(self.searchLoop),
"searchnextfile": str(self.searchNextFile),
"searchmatchcap": str(self.searchMatchCap),
"searchprojcase": str(self.searchProjCase),
"searchprojword": str(self.searchProjWord),
"searchprojregex": str(self.searchProjRegEx),
}
# Write config file
cnfPath = self._confPath / nwFiles.CONF_FILE
try:
with open(cnfPath, mode="w", encoding="utf-8") as outFile:
conf.write(outFile)
except Exception as exc:
logger.error("Could not save config file")
logException()
self._hasError = True
self._errData.append("Could not save config file")
self._errData.append(formatException(exc))
return False
return True
def finishStartup(self) -> None:
"""Call after startup is complete."""
self._splash = None
return
##
# Internal Functions
##
def _packList(self, data: list) -> str:
"""Pack a list of items into a comma-separated string for saving
to the config file.
"""
return ", ".join(str(inVal) for inVal in data)
def _checkOptionalPackages(self) -> None:
"""Check optional packages used by some features."""
try:
import enchant # noqa: F401
except ImportError:
self.hasEnchant = False
logger.debug("Checking package 'pyenchant': Missing")
else:
self.hasEnchant = True
logger.debug("Checking package 'pyenchant': OK")
return
def _prepareFont(self, font: QFont, kind: str) -> None:
"""Check Unicode availability in font. This also initialises any
alternative character used for missing glyphs. See #2315.
"""
self.splashMessage(f"Initialising {kind} font: {font.family()}")
metrics = QFontMetrics(font)
for char in nwUnicode.UI_SYMBOLS:
if not metrics.inFont(char): # type: ignore
logger.warning("No glyph U+%04x in font", ord(char)) # pragma: no cover
return
class RecentProjects:
def __init__(self, config: Config) -> None:
self._conf = config
self._data: dict[str, dict[str, str | int]] = {}
self._map: dict[str, str] = {}
return
def loadCache(self) -> bool:
"""Load the cache file for recent projects."""
self._data = {}
self._map = {}
cacheFile = self._conf.dataPath(nwFiles.RECENT_FILE)
if cacheFile.is_file():
try:
with open(cacheFile, mode="r", encoding="utf-8") as inFile:
data = json.load(inFile)
for path, entry in data.items():
puuid = str(entry.get("uuid", ""))
title = str(entry.get("title", ""))
words = checkInt(entry.get("words", 0), 0)
chars = checkInt(entry.get("chars", 0), 0)
saved = checkInt(entry.get("time", 0), 0)
if path and title:
self._setEntry(puuid, path, title, words, chars, saved)
except Exception:
logger.error("Could not load recent project cache")
logException()
return False
return True
def saveCache(self) -> bool:
"""Save the cache dictionary of recent projects."""
cacheFile = self._conf.dataPath(nwFiles.RECENT_FILE)
cacheTemp = cacheFile.with_suffix(".tmp")
try:
with open(cacheTemp, mode="w+", encoding="utf-8") as outFile:
json.dump(self._data, outFile, indent=2)
cacheTemp.replace(cacheFile)
except Exception:
logger.error("Could not save recent project cache")
logException()
return False
return True
def listEntries(self) -> list[tuple[str, str, int, int]]:
"""List all items in the cache."""
return [
(str(k), str(e["title"]), checkInt(e["words"], 0), checkInt(e["time"], 0))
for k, e in self._data.items()
]
def update(self, path: str | Path, data: NWProjectData, saved: float | int) -> None:
"""Add or update recent cache information on a given project."""
try:
if (remove := self._map.get(data.uuid)) and (remove != str(path)):
self.remove(remove)
self._setEntry(
data.uuid,
str(path),
data.name,
sum(data.currCounts[:2]),
sum(data.currCounts[2:]),
int(saved),
)
self.saveCache()
except Exception:
pass
return
def remove(self, path: str | Path) -> None:
"""Try to remove a path from the recent projects cache."""
if self._data.pop(str(path), None) is not None:
logger.debug("Removed recent: %s", path)
self.saveCache()
return
def _setEntry(
self, puuid: str, path: str, title: str, words: int, chars: int, saved: int
) -> None:
"""Set an entry in the recent projects record."""
self._data[path] = {
"uuid": puuid,
"title": title,
"words": words,
"chars": chars,
"time": saved,
}
if puuid:
self._map[puuid] = path
return
class RecentPaths:
KEYS: Final[list[str]] = ["default", "project", "import", "outline", "stats"]
def __init__(self, config: Config) -> None:
self._conf = config
self._data = {}
return
def setPath(self, key: str, path: Path | str) -> None:
"""Set a path for a given key, and save the cache."""
if key in self.KEYS:
self._data[key] = str(path)
self.saveCache()
return
def getPath(self, key: str) -> str | None:
"""Get a path for a given key, or return None."""
return self._data.get(key)
def loadCache(self) -> bool:
"""Load the cache file for recent paths."""
self._data = {}
cacheFile = self._conf.dataPath(nwFiles.RECENT_PATH)
if cacheFile.is_file():
try:
with open(cacheFile, mode="r", encoding="utf-8") as inFile:
data = json.load(inFile)
if isinstance(data, dict):
for key, path in data.items():
if key in self.KEYS and isinstance(path, str):
self._data[key] = path
except Exception:
logger.error("Could not load recent paths cache")
logException()
return False
return True
def saveCache(self) -> bool:
"""Save the cache dictionary of recent paths."""
cacheFile = self._conf.dataPath(nwFiles.RECENT_PATH)
cacheTemp = cacheFile.with_suffix(".tmp")
try:
with open(cacheTemp, mode="w+", encoding="utf-8") as outFile:
json.dump(self._data, outFile, indent=2)
cacheTemp.replace(cacheFile)
except Exception:
logger.error("Could not save recent paths cache")
logException()
return False
return True