Files
UmbraEdit/umbraedit/config.py
B.D. Murphy 70d74e675f chore: rename project from novelWriter to UmbraEdit
- Rename project name and package: novelWriter -> UmbraEdit, novelwriter -> umbraedit
- Rename entry script novelWriter.py -> UmbraEdit.py
- Rename package directory novelwriter/ -> umbraedit/
- Rename i18n translation sources nw_*.ts -> ue_*.ts
- Rename packaging files (appdata, project xml template)
- Update all imports, references, docs, workflows, tests and build config
- Update pyproject.toml project name, GUI script and URLs
- Preserve full upstream git history; origin repointed below
2026-11-05 07:08:12 -08:00

1334 lines
53 KiB
Python

"""
UmbraEdit - Config Class
==========================
This file is a part of UmbraEdit.
Copyright (C) 2018 Veronica Berglyd Olsen and UmbraEdit 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 <https://www.gnu.org/licenses/>.
""" # noqa
from __future__ import annotations
import json
import logging
import sys
import tomllib
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,
QRect,
QStandardPaths,
QSysInfo,
Qt,
QTranslator,
)
from PyQt6.QtGui import QFont, QFontDatabase, QFontMetrics
from PyQt6.QtWidgets import QApplication
from umbraedit.common import (
checkInt,
checkPath,
compact,
describeFont,
fontMatcher,
formatTimeStamp,
joinLines,
languageName,
minmax,
processDialogSymbols,
safeExists,
safeIsDir,
safeIsFile,
simplified,
uniqueCompact,
)
from umbraedit.constants import nwConst, nwFiles, nwQuotes, nwUnicode, trStats
from umbraedit.enum import nwTheme
from umbraedit.error import formatException, logException
from umbraedit.formats.configparser import NConfigParser
from umbraedit.formats.tomlparser import NTomlParser, T_ConfigMap
if TYPE_CHECKING:
from datetime import datetime
from umbraedit.core.projectdata import ProjectData
from umbraedit.formats.tomlparser import T_TomlConfig
from umbraedit.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"
# Maps the current location of an option to its old location
MOVED_CONFIGS: T_ConfigMap = {
("Main", "hideFocusFooter"): ("Editor", "hideFocusFooter"),
("Editor", "allowOpenDialog"): ("Editor", "allowOpenDial"),
}
class Config:
"""User Config.
The main user config. The state of the config is stored in the
umbraedit.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",
"_ueLangPath",
"_qLocale",
"_qtLangPath",
"_qtTrans",
"_recentPaths",
"_recentProjects",
"_splash",
"allowOpenDialog",
"altDialogClose",
"altDialogOpen",
"appHandle",
"appName",
"askBeforeBackup",
"askBeforeExit",
"autoSaveDoc",
"autoSaveProj",
"autoScroll",
"autoScrollPos",
"autoSelect",
"backupInterval",
"backupOnClose",
"buildFormat",
"buildTime",
"buildType",
"cleanFocusHeader",
"countUnit",
"cursorWidth",
"darkTheme",
"dialogLine",
"dialogStyle",
"doJustify",
"doReplace",
"doReplaceDQuote",
"doReplaceDash",
"doReplaceDots",
"doReplaceSQuote",
"dottedModCodes",
"editShowLineEndings",
"editShowTabsNSpaces",
"emphLabels",
"fmtApostrophe",
"fmtDQuoteClose",
"fmtDQuoteOpen",
"fmtPadAfter",
"fmtPadBefore",
"fmtPadThin",
"fmtSQuoteClose",
"fmtSQuoteOpen",
"focusWidth",
"fontWinSize",
"forceUnixLineEndings",
"goalInFocusMode",
"guiFont",
"guiForceRTL",
"guiLocale",
"hasEnchant",
"hideFocusFooter",
"hideHScroll",
"hideVScroll",
"highlightEmph",
"hostName",
"iconColDocs",
"iconColTree",
"iconTheme",
"incNotesWCount",
"installSource",
"isDebug",
"kernelVer",
"lastNotes",
"lightTheme",
"lineHeight",
"lineHighlight",
"mainPanePos",
"mainWinSize",
"memInfo",
"moveMainWin",
"narratorBreak",
"narratorDialog",
"nativeFont",
"novelViewSubCounts",
"osDarwin",
"osLinux",
"osType",
"osUnknown",
"osWindows",
"prefsWinSize",
"rtlMode",
"scaleHeadings",
"scrollPastEnd",
"searchAuto",
"searchCase",
"searchLoop",
"searchMatchCap",
"searchNextFile",
"searchPanePos",
"searchProjAuto",
"searchProjCase",
"searchProjRegEx",
"searchProjWord",
"searchRegEx",
"searchWord",
"showDetailsPanel",
"showEditToolBar",
"showFullPath",
"showMultiSpaces",
"showSessionTime",
"showViewerPanel",
"singleStarBold",
"spellLanguage",
"stopWhenIdle",
"storyPanePos",
"tabSize",
"textFont",
"textMargin",
"textWidth",
"themeMode",
"useCharCount",
"userIdleTime",
"verPyQtString",
"verPyQtValue",
"verPyString",
"verQtString",
"verQtValue",
"viewComments",
"viewFirstLineIndent",
"viewNotes",
"viewPanePos",
"viewSynopsis",
"vimMode",
"welcomeWinSize",
)
LANG_NW = 1
LANG_PROJ = 2
def __init__(self) -> None:
# Initialisation
# ==============
self._splash = None
# Set Application Variables
self.appName = "UmbraEdit"
self.appHandle = "umbraedit"
# 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
# UmbraEdit 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._ueLangPath = self._appPath / "assets" / "i18n"
self._qtLangPath = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
hasLocale = safeExists(self._ueLangPath / f"ue_{QLocale.system().name()}.qm")
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] = {}
try:
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
except Exception:
logException()
# User Settings
# =============
self._recentProjects = RecentProjects(self)
self._recentPaths = RecentPaths(self)
self._lastAuthor = ""
# General GUI Settings
self.guiLocale = self._qLocale.name()
self.guiForceRTL = False # User setting to force RTL layout
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.goalInFocusMode = False # Show project goal when in focus mode
self.useCharCount = False # Use character count as primary count
self.countUnit = "words" # Primary count unit
self.vimMode = False # Enable Vim mode
self.rtlMode = False # RTL layout, from the language or the forced setting
# 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.fontWinSize = [700, 550] # Last size of the Font dialog
self.mainPanePos = [300, 800] # Last position of the main window splitter
self.viewPanePos = [500, 150] # Last position of the document viewer splitter
self.searchPanePos = [150, 500] # Last position of the project search splitter
self.storyPanePos = [300, 800] # Last position of the story panel splitter
self.moveMainWin = True # Move main window to the screen middle on startup
# 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.backupInterval = "session" # Backup interval
self.askBeforeBackup = True # Flag for asking before running automatic backup
self.askBeforeExit = True # Flag for asking before exiting the app
self.forceUnixLineEndings = False # Always use LF line endings, never CRLF
# Text Document Settings
self.textFont = QFont() # Editor font
self.textWidth = 700 # Editor text width
self.textMargin = 40 # Editor/viewer text margin
self.tabSize = 2.0 # Text tab size in em
self.lineHeight = 1.15 # Text line height
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.cleanFocusHeader = False # Hide most header content 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.showMultiSpaces = False # Highlight multiple spaces in the text
self.scaleHeadings = True # Use a larger size for headings
self.singleStarBold = False # Allow single asterisk bold
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.allowOpenDialog = 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.dottedModCodes = False # Add dotted lines under codes and modifiers
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.novelViewSubCounts = True # Count notes of sub elements in novel view
self.showDetailsPanel = True # The panel for the item details is visible
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
self.viewFirstLineIndent = False # Enable first line indent in the viewer
self.editShowTabsNSpaces = False # Show tabs and spaces in the editor
self.editShowLineEndings = False # Show line endings in the editor
# Search Box States
self.searchAuto = False
self.searchCase = False
self.searchWord = False
self.searchRegEx = False
self.searchLoop = False
self.searchNextFile = False
self.searchMatchCap = False
self.searchProjAuto = 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") or 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
# Build Meta
self.buildTime = ""
self.buildType = ""
self.buildFormat = ""
self.installSource = ""
self._parseBuildMeta()
# Packages
self.hasEnchant = False # The pyenchant package
##
# Properties
##
@property
def hasError(self) -> bool:
"""Return True if the config class encountered an error."""
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 the path to the UmbraEdit language files."""
return self._ueLangPath
@property
def locale(self) -> QLocale:
"""Return the current GUI locale."""
return self._dLocale
@property
def recentProjects(self) -> RecentProjects:
"""Return the recent projects list."""
return self._recentProjects
@property
def lastAuthor(self) -> str:
"""Return the last author name used."""
return self._lastAuthor
@property
def lineEnding(self) -> str | None:
"""Return the project file line ending."""
return "\n" if self.forceUnixLineEndings else None
##
# 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, geometry: QRect) -> 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.
"""
width = geometry.width()
height = geometry.height()
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, geometry: QRect) -> None:
"""Set the size of the Preferences dialog window."""
self.welcomeWinSize = [geometry.width(), geometry.height()]
def setPreferencesWinSize(self, geometry: QRect) -> None:
"""Set the size of the Preferences dialog window."""
self.prefsWinSize = [geometry.width(), geometry.height()]
def setFontWinSize(self, geometry: QRect) -> None:
"""Set the size of the Font dialog window."""
self.fontWinSize = [geometry.width(), geometry.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 safeIsDir(path):
path = path.parent
if safeIsDir(path):
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()
if self.checkMinQtVersion(0x060B00): # Qt 6.11+
font.fromString(value)
else: # pragma: no cover
font.fromString(",".join(value.split(",")[:16]))
self.guiFont = fontMatcher(font)
else:
font = QFont()
if self.osWindows and "Segoe UI" in QFontDatabase.families():
# On Windows we default to Segoe UI if possible
font.setFamily("Segoe UI")
font.setPointSize(9)
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()
if self.checkMinQtVersion(0x060B00): # Qt 6.11+
font.fromString(value)
else: # pragma: no cover
font.fromString(",".join(value.split(",")[:16]))
self.textFont = fontMatcher(font)
else:
fontFam = QFontDatabase.families()
if self.osWindows and "Segoe UI" in fontFam:
font = QFont()
font.setFamily("Segoe UI")
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))
# Copy to avoid changing the font passed in, and skip pixel sized fonts
if (size := self.textFont.pointSizeF()) > 0.0:
self.textFont = QFont(self.textFont)
self.textFont.setPointSizeF(minmax(size, nwConst.TEXT_FONT_MIN_SIZE, nwConst.TEXT_FONT_MAX_SIZE))
def setPrimaryCount(self, useCharCount: bool) -> None:
"""Set the primary count unit. This also updates the unit label."""
self.useCharCount = useCharCount
self.countUnit = trStats("Characters" if useCharCount else "Words").lower()
##
# 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)) and safeIsDir(asPath := Path(path)):
return asPath
return self._homePath
def backupPath(self) -> Path:
"""Return the backup path."""
if isinstance(self._backupPath, Path) and safeIsDir(self._backupPath):
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 = joinLines(self._errData, "<br>")
self._hasError = False
self._errData = []
return message
def checkMinQtVersion(self, version: int) -> bool:
"""Check if a minimum Qt version is satisfied."""
return self.verPyQtValue >= version and self.verQtValue >= version
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 = "ue_"
fExt = ".qm"
langList = {"en_GB": languageName("en_GB")}
elif lngSet == self.LANG_PROJ:
fPre = "project_"
fExt = ".json"
langList = {"en_GB": languageName("en_GB")}
else:
return []
try:
for qmFile in self._ueLangPath.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 = languageName(qmLang)
if qmLang and qmName and qmLang != "en_GB":
langList[qmLang] = qmName
except Exception as exc:
logger.error("Failed to load additional language files", exc_info=exc)
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
# Also create the themes and icons folders if possible
try:
self._confPath.mkdir(exist_ok=True)
self._dataPath.mkdir(exist_ok=True)
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)
except Exception:
logException()
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 = safeExists(self._ueLangPath / f"ue_{self._qLocale.name()}.qm")
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._ueLangPath), "ue"), # UmbraEdit
]
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 and qTrans.load(lngFile, lngPath):
logger.debug("Loaded: %s.qm", lngFile)
nwApp.installTranslator(qTrans)
self._qtTrans[lngFile] = qTrans
# Refresh translated values
self.setPrimaryCount(self.useCharCount)
if self.guiForceRTL:
nwApp.setLayoutDirection(Qt.LayoutDirection.RightToLeft)
self.rtlMode = nwApp.layoutDirection() == Qt.LayoutDirection.RightToLeft
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")
convertOld = False
if safeExists(path := self._confPath / nwFiles.CONF_FILE):
confPath = path
parser = NTomlParser(confMap=MOVED_CONFIGS)
elif safeExists(path := self._confPath / nwFiles.CONF_FILE_OLD):
confPath = path
parser = NConfigParser(confMap=MOVED_CONFIGS)
convertOld = True
else:
# Initial file, so we just create one from defaults
self.setGuiFont(None)
self.setTextFont(None)
self.saveConfig()
return True
try:
parser.read(confPath)
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(parser.getStr(sec, "font", ""))
self.lightTheme = parser.getStr(sec, "lightTheme", self.lightTheme)
self.darkTheme = parser.getStr(sec, "darkTheme", self.darkTheme)
self.themeMode = parser.getEnum(sec, "themeMode", self.themeMode)
self.iconTheme = parser.getStr(sec, "icons", self.iconTheme)
self.iconColTree = parser.getStr(sec, "iconColTree", self.iconColTree)
self.iconColDocs = parser.getBool(sec, "iconColDocs", self.iconColDocs)
self.guiLocale = parser.getStr(sec, "localisation", self.guiLocale)
self.guiForceRTL = parser.getBool(sec, "forceRTL", self.guiForceRTL)
self.hideVScroll = parser.getBool(sec, "hideVScroll", self.hideVScroll)
self.hideHScroll = parser.getBool(sec, "hideHScroll", self.hideHScroll)
self.lastNotes = parser.getStr(sec, "lastNotes", self.lastNotes)
self.nativeFont = parser.getBool(sec, "nativeFont", self.nativeFont)
self.hideFocusFooter = parser.getBool(sec, "hideFocusFooter", self.hideFocusFooter)
self.cleanFocusHeader = parser.getBool(sec, "cleanFocusHeader", self.cleanFocusHeader)
self.goalInFocusMode = parser.getBool(sec, "goalInFocusMode", self.goalInFocusMode)
self.useCharCount = parser.getBool(sec, "useCharCount", self.useCharCount)
self.vimMode = parser.getBool(sec, "vimMode", self.vimMode)
# Sizes
sec = "Sizes"
self.mainWinSize = parser.getIntList(sec, "mainWindow", self.mainWinSize)
self.welcomeWinSize = parser.getIntList(sec, "welcome", self.welcomeWinSize)
self.prefsWinSize = parser.getIntList(sec, "preferences", self.prefsWinSize)
self.fontWinSize = parser.getIntList(sec, "fontSelect", self.fontWinSize)
self.mainPanePos = parser.getIntList(sec, "mainPane", self.mainPanePos)
self.viewPanePos = parser.getIntList(sec, "viewPane", self.viewPanePos)
self.searchPanePos = parser.getIntList(sec, "searchPane", self.searchPanePos)
self.storyPanePos = parser.getIntList(sec, "storyPane", self.storyPanePos)
self.moveMainWin = parser.getBool(sec, "moveMainWin", self.moveMainWin)
# Project
sec = "Project"
self.autoSaveProj = parser.getInt(sec, "autoSaveProject", self.autoSaveProj)
self.autoSaveDoc = parser.getInt(sec, "autoSaveDoc", self.autoSaveDoc)
self.emphLabels = parser.getBool(sec, "emphLabels", self.emphLabels)
self._backupPath = parser.getPath(sec, "backupPath", self._backupPath)
self.backupOnClose = parser.getBool(sec, "backupOnClose", self.backupOnClose)
self.backupInterval = parser.getStr(sec, "backupInterval", self.backupInterval)
self.askBeforeBackup = parser.getBool(sec, "askBeforeBackup", self.askBeforeBackup)
self.askBeforeExit = parser.getBool(sec, "askBeforeExit", self.askBeforeExit)
self.forceUnixLineEndings = parser.getBool(sec, "forceUnixLineEndings", self.forceUnixLineEndings)
self._lastAuthor = simplified(parser.getStr(sec, "lastAuthor", self._lastAuthor))
# Editor
sec = "Editor"
self.setTextFont(parser.getStr(sec, "textFont", ""))
self.textWidth = parser.getInt(sec, "width", self.textWidth)
self.textMargin = parser.getInt(sec, "margin", self.textMargin)
self.tabSize = parser.getFloat(sec, "tabSize", self.tabSize)
self.lineHeight = parser.getFloat(sec, "lineHeight", self.lineHeight)
self.cursorWidth = parser.getInt(sec, "cursorWidth", self.cursorWidth)
self.lineHighlight = parser.getBool(sec, "lineHighlight", self.lineHighlight)
self.focusWidth = parser.getInt(sec, "focusWidth", self.focusWidth)
self.doJustify = parser.getBool(sec, "justify", self.doJustify)
self.autoSelect = parser.getBool(sec, "autoSelect", self.autoSelect)
self.doReplace = parser.getBool(sec, "autoReplace", self.doReplace)
self.doReplaceSQuote = parser.getBool(sec, "repSQuotes", self.doReplaceSQuote)
self.doReplaceDQuote = parser.getBool(sec, "repDQuotes", self.doReplaceDQuote)
self.doReplaceDash = parser.getBool(sec, "repDash", self.doReplaceDash)
self.doReplaceDots = parser.getBool(sec, "repDots", self.doReplaceDots)
self.autoScroll = parser.getBool(sec, "autoScroll", self.autoScroll)
self.autoScrollPos = parser.getInt(sec, "autoScrollPos", self.autoScrollPos)
self.scrollPastEnd = parser.getBool(sec, "scrollPastEnd", self.scrollPastEnd)
self.fmtSQuoteOpen = compact(parser.getStr(sec, "fmtSQuoteOpen", self.fmtSQuoteOpen))
self.fmtSQuoteClose = compact(parser.getStr(sec, "fmtSQuoteClose", self.fmtSQuoteClose))
self.fmtDQuoteOpen = compact(parser.getStr(sec, "fmtDQuoteOpen", self.fmtDQuoteOpen))
self.fmtDQuoteClose = compact(parser.getStr(sec, "fmtDQuoteClose", self.fmtDQuoteClose))
self.fmtPadBefore = uniqueCompact(parser.getStr(sec, "fmtPadBefore", self.fmtPadBefore))
self.fmtPadAfter = uniqueCompact(parser.getStr(sec, "fmtPadAfter", self.fmtPadAfter))
self.fmtPadThin = parser.getBool(sec, "fmtPadThin", self.fmtPadThin)
self.spellLanguage = parser.getStr(sec, "spellCheck", self.spellLanguage)
self.showMultiSpaces = parser.getBool(sec, "showMultiSpaces", self.showMultiSpaces)
self.scaleHeadings = parser.getBool(sec, "scaleHeadings", self.scaleHeadings)
self.singleStarBold = parser.getBool(sec, "singleStarBold", self.singleStarBold)
self.incNotesWCount = parser.getBool(sec, "incNotesWCount", self.incNotesWCount)
self.showFullPath = parser.getBool(sec, "showFullPath", self.showFullPath)
self.dialogStyle = parser.getInt(sec, "dialogStyle", self.dialogStyle)
self.allowOpenDialog = parser.getBool(sec, "allowOpenDialog", self.allowOpenDialog)
dialogLine = parser.getStr(sec, "dialogLine", self.dialogLine)
narratorBreak = parser.getStr(sec, "narratorBreak", self.narratorBreak)
narratorDialog = parser.getStr(sec, "narratorDialog", self.narratorDialog)
self.altDialogOpen = compact(parser.getStr(sec, "altDialogOpen", self.altDialogOpen))
self.altDialogClose = compact(parser.getStr(sec, "altDialogClose", self.altDialogClose))
self.highlightEmph = parser.getBool(sec, "highlightEmph", self.highlightEmph)
self.dottedModCodes = parser.getBool(sec, "dottedModCodes", self.dottedModCodes)
self.stopWhenIdle = parser.getBool(sec, "stopWhenIdle", self.stopWhenIdle)
self.userIdleTime = parser.getInt(sec, "userIdleTime", self.userIdleTime)
# State
sec = "State"
self.novelViewSubCounts = parser.getBool(sec, "novelViewSubCounts", self.novelViewSubCounts)
self.showDetailsPanel = parser.getBool(sec, "showDetailsPanel", self.showDetailsPanel)
self.showViewerPanel = parser.getBool(sec, "showViewerPanel", self.showViewerPanel)
self.showEditToolBar = parser.getBool(sec, "showEditToolBar", self.showEditToolBar)
self.showSessionTime = parser.getBool(sec, "showSessionTime", self.showSessionTime)
self.viewComments = parser.getBool(sec, "viewComments", self.viewComments)
self.viewSynopsis = parser.getBool(sec, "viewSynopsis", self.viewSynopsis)
self.viewNotes = parser.getBool(sec, "viewNotes", self.viewNotes)
self.viewFirstLineIndent = parser.getBool(sec, "viewFirstLineIndent", self.viewFirstLineIndent)
self.editShowTabsNSpaces = parser.getBool(sec, "editShowTabsNSpaces", self.editShowTabsNSpaces)
self.editShowLineEndings = parser.getBool(sec, "editShowLineEndings", self.editShowLineEndings)
self.searchAuto = parser.getBool(sec, "searchAuto", self.searchAuto)
self.searchCase = parser.getBool(sec, "searchCase", self.searchCase)
self.searchWord = parser.getBool(sec, "searchWord", self.searchWord)
self.searchRegEx = parser.getBool(sec, "searchRegEx", self.searchRegEx)
self.searchLoop = parser.getBool(sec, "searchLoop", self.searchLoop)
self.searchNextFile = parser.getBool(sec, "searchNextFile", self.searchNextFile)
self.searchMatchCap = parser.getBool(sec, "searchMatchCap", self.searchMatchCap)
self.searchProjAuto = parser.getBool(sec, "searchProjAuto", self.searchProjAuto)
self.searchProjCase = parser.getBool(sec, "searchProjCase", self.searchProjCase)
self.searchProjWord = parser.getBool(sec, "searchProjWord", self.searchProjWord)
self.searchProjRegEx = parser.getBool(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 ""
if convertOld:
logger.info("Old config file detected, converting to new format")
self.saveConfig()
return True
def saveConfig(self) -> bool:
"""Save the current preferences to file."""
logger.debug("Saving config file")
config: T_TomlConfig = {}
config["Meta"] = {
"timeStamp": formatTimeStamp(time()),
}
config["Main"] = {
"font": self.guiFont,
"lightTheme": self.lightTheme,
"darkTheme": self.darkTheme,
"themeMode": self.themeMode,
"icons": self.iconTheme,
"iconColTree": self.iconColTree,
"iconColDocs": self.iconColDocs,
"localisation": self.guiLocale,
"forceRTL": self.guiForceRTL,
"hideVScroll": self.hideVScroll,
"hideHScroll": self.hideHScroll,
"lastNotes": self.lastNotes,
"nativeFont": self.nativeFont,
"hideFocusFooter": self.hideFocusFooter,
"cleanFocusHeader": self.cleanFocusHeader,
"goalInFocusMode": self.goalInFocusMode,
"useCharCount": self.useCharCount,
"vimMode": self.vimMode,
}
config["Sizes"] = {
"mainWindow": self.mainWinSize,
"welcome": self.welcomeWinSize,
"preferences": self.prefsWinSize,
"fontSelect": self.fontWinSize,
"mainPane": self.mainPanePos,
"viewPane": self.viewPanePos,
"searchPane": self.searchPanePos,
"storyPane": self.storyPanePos,
"moveMainWin": self.moveMainWin,
}
config["Project"] = {
"autoSaveProject": self.autoSaveProj,
"autoSaveDoc": self.autoSaveDoc,
"emphLabels": self.emphLabels,
"backupPath": self._backupPath,
"backupOnClose": self.backupOnClose,
"backupInterval": self.backupInterval,
"askBeforeBackup": self.askBeforeBackup,
"askBeforeExit": self.askBeforeExit,
"forceUnixLineEndings": self.forceUnixLineEndings,
"lastAuthor": self._lastAuthor,
}
config["Editor"] = {
"textFont": self.textFont,
"width": self.textWidth,
"margin": self.textMargin,
"tabSize": self.tabSize,
"lineHeight": self.lineHeight,
"cursorWidth": self.cursorWidth,
"lineHighlight": self.lineHighlight,
"focusWidth": self.focusWidth,
"justify": self.doJustify,
"autoSelect": self.autoSelect,
"autoReplace": self.doReplace,
"repSQuotes": self.doReplaceSQuote,
"repDQuotes": self.doReplaceDQuote,
"repDash": self.doReplaceDash,
"repDots": self.doReplaceDots,
"autoScroll": self.autoScroll,
"autoScrollPos": self.autoScrollPos,
"scrollPastEnd": self.scrollPastEnd,
"fmtSQuoteOpen": self.fmtSQuoteOpen,
"fmtSQuoteClose": self.fmtSQuoteClose,
"fmtDQuoteOpen": self.fmtDQuoteOpen,
"fmtDQuoteClose": self.fmtDQuoteClose,
"fmtPadBefore": self.fmtPadBefore,
"fmtPadAfter": self.fmtPadAfter,
"fmtPadThin": self.fmtPadThin,
"spellCheck": self.spellLanguage,
"showMultiSpaces": self.showMultiSpaces,
"scaleHeadings": self.scaleHeadings,
"singleStarBold": self.singleStarBold,
"incNotesWCount": self.incNotesWCount,
"showFullPath": self.showFullPath,
"dialogStyle": self.dialogStyle,
"allowOpenDialog": self.allowOpenDialog,
"dialogLine": self.dialogLine,
"narratorBreak": self.narratorBreak,
"narratorDialog": self.narratorDialog,
"altDialogOpen": self.altDialogOpen,
"altDialogClose": self.altDialogClose,
"highlightEmph": self.highlightEmph,
"dottedModCodes": self.dottedModCodes,
"stopWhenIdle": self.stopWhenIdle,
"userIdleTime": self.userIdleTime,
}
config["State"] = {
"novelViewSubCounts": self.novelViewSubCounts,
"showDetailsPanel": self.showDetailsPanel,
"showViewerPanel": self.showViewerPanel,
"showEditToolBar": self.showEditToolBar,
"showSessionTime": self.showSessionTime,
"viewComments": self.viewComments,
"viewSynopsis": self.viewSynopsis,
"viewNotes": self.viewNotes,
"viewFirstLineIndent": self.viewFirstLineIndent,
"editShowTabsNSpaces": self.editShowTabsNSpaces,
"editShowLineEndings": self.editShowLineEndings,
"searchAuto": self.searchAuto,
"searchCase": self.searchCase,
"searchWord": self.searchWord,
"searchRegEx": self.searchRegEx,
"searchLoop": self.searchLoop,
"searchNextFile": self.searchNextFile,
"searchMatchCap": self.searchMatchCap,
"searchProjAuto": self.searchProjAuto,
"searchProjCase": self.searchProjCase,
"searchProjWord": self.searchProjWord,
"searchProjRegEx": self.searchProjRegEx,
}
# Write config file
cnfPath = self._confPath / nwFiles.CONF_FILE
try:
parser = NTomlParser()
parser.write(cnfPath, config)
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 _parseBuildMeta(self) -> None:
"""Parse the build meta file and set the build information."""
try:
with open(self.assetPath("meta.toml"), mode="rb") as fileObj:
data = tomllib.load(fileObj).get("Build", {})
self.buildTime = str(data.get("timestamp", ""))
self.buildType = str(data.get("type", ""))
self.buildFormat = str(data.get("format", ""))
self.installSource = str(data.get("install_source", ""))
except Exception:
logException()
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] = {}
##
# Methods
##
def loadCache(self) -> bool:
"""Load the cache file for recent projects."""
self._data = {}
self._map = {}
try:
cacheFile = self._conf.dataPath(nwFiles.RECENT_FILE)
if cacheFile.is_file():
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 findCopy(self, path: str | Path, puuid: str) -> str | None:
"""Return the path of another existing project with the same UUID."""
if (other := self._otherPath(str(path), puuid)) and safeIsFile(Path(other) / nwFiles.PROJ_FILE):
return other
return None
def update(self, path: str | Path, data: ProjectData, saved: float | int) -> None:
"""Add or update recent cache information on a given project."""
try:
# A project with the same UUID at another path has been moved, unless it still exists
if (other := self._otherPath(str(path), data.uuid)) and not safeIsFile(Path(other) / nwFiles.PROJ_FILE):
self.remove(other)
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()
##
# Internal Functions
##
def _otherPath(self, path: str, puuid: str) -> str | None:
"""Return another recorded path for the same project UUID."""
if (other := self._map.get(puuid)) and other != path and self._data.get(other, {}).get("uuid") == puuid:
return other
return None
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", "story", "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 = {}
try:
cacheFile = self._conf.dataPath(nwFiles.RECENT_PATH)
if cacheFile.is_file():
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