"""
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 .
""" # noqa
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:
"""User Config.
The main user config. The state of the config is stored in the
novelwriter.conf file between sessions. Most of the settings can be
modified by the user in the Preferences dialog, but some just record
various states of the GUI.
"""
__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 = False # 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
##
# 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)
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
def setWelcomeWinSize(self, width: int, height: int) -> None:
"""Set the size of the Preferences dialog window."""
self.welcomeWinSize = [width, height]
def setPreferencesWinSize(self, width: int, height: int) -> None:
"""Set the size of the Preferences dialog window."""
self.prefsWinSize = [width, height]
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)
def setBackupPath(self, path: Path | str) -> None:
"""Set the current backup path."""
self._backupPath = checkPath(path, self._backPath)
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)
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))
##
# Methods
##
def homePath(self) -> Path:
"""Return 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)
##
# 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")
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
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
##
# 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")
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
class RecentProjects:
"""A record of recently opened projects."""
def __init__(self, config: Config) -> None:
self._conf = config
self._data: dict[str, dict[str, str | int]] = {}
self._map: dict[str, str] = {}
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
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()
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
class RecentPaths:
"""A record of recently used file paths."""
KEYS: Final[list[str]] = ["default", "project", "import", "outline", "stats"]
def __init__(self, config: Config) -> None:
self._conf = config
self._data = {}
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()
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