diff --git a/docs/FileFormatSpec-1.5.fodt b/docs/FileFormatSpec-1.5.fodt index a57c8627..4e2d66ef 100644 --- a/docs/FileFormatSpec-1.5.fodt +++ b/docs/FileFormatSpec-1.5.fodt @@ -1,31 +1,30 @@ - Veronica Olsen2022-11-05T18:25:44.8874731592023-06-03T18:35:47.268848685Veronica OlsenPT4H19S53LibreOffice/7.4.5.1$Linux_X86_64 LibreOffice_project/40$Build-1 + Veronica Olsen2022-11-05T18:25:44.8874731592024-01-26T22:59:54.491819741Veronica OlsenPT4H2M7S55LibreOffice/7.4.7.2$Linux_X86_64 LibreOffice_project/40$Build-2 - 0 + 35971 0 - 35645 + 26083 20394 true false view2 - 15145 - 7216 + 4692 + 47456 0 - 0 - 35643 - 20392 + 35971 + 26081 + 56363 0 1 false 140 false false - false false @@ -91,7 +90,7 @@ true true - 805029 + 917862 true false @@ -117,7 +116,7 @@ false false - true + false false false false @@ -159,7 +158,7 @@ - + @@ -180,7 +179,7 @@ - + @@ -480,15 +479,6 @@ - - - - - - - - - @@ -1108,153 +1098,148 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1271,78 +1256,81 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + @@ -1420,17 +1408,20 @@ - novelWriter - Project File Format 1.5 Specification + novelWriter + Project File Format 1.5 Specification - This document covers the file format specification for the 1.5 file format for novelWriter project files. See the documentation1 - See: https://docs.novelwriter.io for a full description of the app’s functionality. The file format was intrioduced in Release 2.0. - The target audience of this document is developers who intend to write a tool or script that generates valid novelWriter project files from data from other applications, or for templating purposes. - Note that flags are generally written out as yes/no values, but also true/false and on/off are understood by the application. The None value that occasionally occurs in actual project files is the text representation of the Python None value, and indicates that there is no value set. - Revisions + This document covers the file format specification for the 1.5 file format for novelWriter project files. See the documentation1 + See: https://docs.novelwriter.io for a full description of the app’s functionality. The file format was intrioduced in Release 2.0. + The target audience of this document is developers who intend to write a tool or script that generates valid novelWriter project files from data from other applications, or for templating purposes. + Note that flags are generally written out as yes/no values, but also true/false and on/off are understood by the application. The None value that occasionally occurs in actual project files is the text representation of the Python None value, and indicates that there is no value set. + Revisions - From Release 2.1 Beta 1: Removed the titleFormat node from settings. + From Release 2.1 Beta 1: Removed the titleFormat node from settings. + + + From Release 2.3 Beta 1: Removed the title node from project. XML Root @@ -1454,7 +1445,7 @@ - appVersion + appVersion No @@ -1465,7 +1456,7 @@ - hexVersion + hexVersion No @@ -1476,29 +1467,29 @@ - fileVersion + fileVersion Yes - The file format version used when writing the project file. This determines how the file is parsed, so it is important that it is set correctly. Valid values are “1.0”, “1.1”, “1.2”, “1.3”, “1.4”, and “1.5”. + The file format version used when writing the project file. This determines how the file is parsed, so it is important that it is set correctly. Valid values are “1.0”, “1.1”, “1.2”, “1.3”, “1.4”, and “1.5”. - fileRevision + fileRevision - No + No - The revision of the file format used for non-breaking changes. This is an interger value. + The revision of the file format used for non-breaking changes. This is an interger value. - timeStamp + timeStamp No @@ -1509,12 +1500,12 @@ Example - <novelWriterXML appVersion="2.0-rc1" hexVersion="0x020000c1" - fileVersion="1.5" fileRevision="1" - timeStamp="2022-11-05 17:46:51"> + <novelWriterXML appVersion="2.0-rc1" hexVersion="0x020000c1" + fileVersion="1.5" fileRevision="1" + timeStamp="2022-11-05 17:46:51"> Project Node Tag: project - The project section is a level one node under the root. It stores the primary settings for the project. The only required values are the id attribute and the name value. All other entries are set to default values during parsing if ommitted from the XML. + The project section is a level one node under the root. It stores the primary settings for the project. The only required values are the id attribute and the name value. All other entries are set to default values during parsing if ommitted from the XML. Attributes @@ -1533,18 +1524,18 @@ - id + id - Yes + Yes - A UUID that is unique for the project and does not change during the lifetime of the project. It is used by novelWriter to keep track of temporary files and to associate cross-project settings with specific projects without relying on the project name. + A UUID that is unique for the project and does not change during the lifetime of the project. It is used by novelWriter to keep track of temporary files and to associate cross-project settings with specific projects without relying on the project name. - saveCount + saveCount No @@ -1555,7 +1546,7 @@ - autoCount + autoCount No @@ -1566,7 +1557,7 @@ - editTime + editTime No @@ -1594,49 +1585,37 @@ - name + name - Yes + Yes - The name the user has given to the project. + The name the user has given to the project. - title + author No - The title the user has given to the project, which may differ from the project name. The value is used in some places in the user interface, and can be added to manuscript builds. - - - - - author - - - No - - - The name of the novel author or authors. The value is used in some places in the user interface, and can be added to manuscript builds. + The name of the novel author or authors. The value is used in some places in the user interface, and can be added to manuscript builds. Example - <project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" - saveCount="5" autoCount="10" editTime="1000"> - <name>Sample Project</name> - <title>Sample Project</title> - <author>Jane Smith</author> + <project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" + saveCount="5" autoCount="10" editTime="1000"> + <name>Sample Project</name> + <author>Jane Smith</author> </project> - - Settings Node + + Settings Node Tag: settings - The settings section is a level one node under the root. It stores the various runtime values for the project, and the settings available from the Project Settings panel. None of these settings are required, and if missing, will be set to default values. The entire settings section can thus be ommitted. + The settings section is a level one node under the root. It stores the various runtime values for the project, and the settings available from the Project Settings panel. None of these settings are required, and if missing, will be set to default values. The entire settings section can thus be ommitted. Attributes None Values @@ -1657,18 +1636,18 @@ - doBackup + doBackup - No + No - User-controlled setting for whether or not to run backup when the project is closed. + User-controlled setting for whether or not to run backup when the project is closed. - language + language No @@ -1679,7 +1658,7 @@ - spellChecking + spellChecking No @@ -1692,18 +1671,18 @@ - lastHandle + lastHandle No - An auto-generated list of the last documents open in the editor and viewer, and which novel root folder was last viewed in the Novel Tree and Outline View. These are saved as key/value pairs. See separate section for how they are stored as XML. + An auto-generated list of the last documents open in the editor and viewer, and which novel root folder was last viewed in the Novel Tree and Outline View. These are saved as key/value pairs. See separate section for how they are stored as XML. - autoReplace + autoReplace No @@ -1714,50 +1693,50 @@ - titleFormat + titleFormat No - Removed in Revision 1. + Removed in Revision 1. - status + status No - The status labels as defined by the user in Project Settings. These are saved as special key/value pars. See separate section for how they are stored as XML. + The status labels as defined by the user in Project Settings. These are saved as special key/value pars. See separate section for how they are stored as XML. - importance + importance No - The importance labels as defined by the user in Project Settings. These are saved as special key/value pars. See separate section for how they are stored as XML. + The importance labels as defined by the user in Project Settings. These are saved as special key/value pars. See separate section for how they are stored as XML. Example - <settings> - <doBackup>yes</doBackup> - <language>en_GB</language> - <spellChecking auto="yes">en_GB</spellChecking> - <lastHandle /> - <autoReplace /> - <status /> - <importance /> - </settings> - - Key/Value Nodes + <settings> + <doBackup>yes</doBackup> + <language>en_GB</language> + <spellChecking auto="yes">en_GB</spellChecking> + <lastHandle /> + <autoReplace /> + <status /> + <importance /> + </settings> + + Key/Value Nodes The key/value nodes are used for lastHandle, autoReplace and titleFormat settings. The lookup key is stored as an attribute, and the value is the text of the node. Attributes @@ -1777,25 +1756,25 @@ - key + key - Yes + Yes - The lookup key. + The lookup key. Example - <lastHandle> - <entry key="editor">636b6aa9b697b</entry> - <entry key="viewer">636b6aa9b697b</entry> - <entry key="novelTree">7031beac91f75</entry> - <entry key="outline">7031beac91f75</entry> - </lastHandle> + <lastHandle> + <entry key="editor">636b6aa9b697b</entry> + <entry key="viewer">636b6aa9b697b</entry> + <entry key="novelTree">7031beac91f75</entry> + <entry key="outline">7031beac91f75</entry> + </lastHandle> Status/Importance Key/Value Nodes - The status and importance settings are stored as key/value nodes with additional data attributes. The label of the status or importance setting is saved as the node text value. + The status and importance settings are stored as key/value nodes with additional data attributes. The label of the status or importance setting is saved as the node text value. Attributes @@ -1814,18 +1793,18 @@ - key + key - Yes + Yes - The lookup key. For status labels, it must consist of an “s” followed by six hexadecimal numbers. For importance labels it must consist of an “i” followed by six hexadecimal numbers. Each key string must be unique. Internally, they are random numbers, but also sequential values are valid. + The lookup key. For status labels, it must consist of an “s” followed by six hexadecimal numbers. For importance labels it must consist of an “i” followed by six hexadecimal numbers. Each key string must be unique. Internally, they are random numbers, but also sequential values are valid. - count + count No @@ -1836,7 +1815,7 @@ - red + red Yes @@ -1847,48 +1826,48 @@ - green + green Yes - A number between 0 and 255 representing the green component of the label colour. + A number between 0 and 255 representing the green component of the label colour. - blue + blue Yes - A number between 0 and 255 representing the blue component of the label colour. + A number between 0 and 255 representing the blue component of the label colour. Example - <status> - <entry key="sf12341" count="4" - red="100" green="100" blue="100">New</entry> - <entry key="sd51c5b" count="0" - red="193" green="129" blue="0">Draft</entry> - <entry key="s78ea90" count="1" - red="58" green="180" blue="58">Finished</entry> - </status> - <importance> - <entry key="ia857f0" count="5" - red="100" green="100" blue="100">None</entry> - <entry key="icfb3a5" count="2" - red="0" green="122" blue="188">Minor</entry> - <entry key="i2d7a54" count="2" - red="21" green="0" blue="180">Major</entry> - </importance> - + <status> + <entry key="sf12341" count="4" + red="100" green="100" blue="100">New</entry> + <entry key="sd51c5b" count="0" + red="193" green="129" blue="0">Draft</entry> + <entry key="s78ea90" count="1" + red="58" green="180" blue="58">Finished</entry> + </status> + <importance> + <entry key="ia857f0" count="5" + red="100" green="100" blue="100">None</entry> + <entry key="icfb3a5" count="2" + red="0" green="122" blue="188">Minor</entry> + <entry key="i2d7a54" count="2" + red="21" green="0" blue="180">Major</entry> + </importance> + Content Node Tag: content - The content section is a level one node under the root. It stores all the project items of the project. The items are stored in the order in which they appear in the project tree, so altering the order will affect the project structure. The attribute with the order number is not used when processing the data. It is written to the XML for debugging purposes. + The content section is a level one node under the root. It stores all the project items of the project. The items are stored in the order in which they appear in the project tree, so altering the order will affect the project structure. The attribute with the order number is not used when processing the data. It is written to the XML for debugging purposes. Attributes @@ -1907,35 +1886,35 @@ - items + items No - The number of project items in the content section. + The number of project items in the content section. - novelWords + novelWords No - The number of words in total for all document nodes with layout DOCUMENT. The value defaults to 0, but this number forms the basis of computing writing statistics during a session, so the number should be properly set to ensure correct statistics. + The number of words in total for all document nodes with layout DOCUMENT. The value defaults to 0, but this number forms the basis of computing writing statistics during a session, so the number should be properly set to ensure correct statistics. - notesWords + notesWords No - The number of words in total for all document nodes with layout NOTE. The value defaults to 0, but this number forms the basis of computing writing statistics during a session, so the number should be properly set to ensure correct statistics. + The number of words in total for all document nodes with layout NOTE. The value defaults to 0, but this number forms the basis of computing writing statistics during a session, so the number should be properly set to ensure correct statistics. @@ -1957,31 +1936,31 @@ - item + item - No + No - A node representing a project item. + A node representing a project item. Example - <content items="27" novelWords="954" notesWords="409"> - <item handle="7031beac91f75" parent="None" root="7031beac91f75" - order="0" type="ROOT" class="NOVEL"> - <meta expanded="yes"/> - <name status="sc24b8f" import="ia857f0">Novel</name> - </item> - ... - </content> - Item Nodes - The item nodes make up the actual project content of the project. Each node represents either a root folder, a regular folder, or a document. Each node has a type, class and layout setting that determine its category. Each item is given a handle that is a random hexadecimal string of length 13. - For the items that are document files, the handle corresponds to its expected filename. It is therefore important that they match in generated projects. Each document node is expected to correspond to a file in the contents folder of the project named “content/7031beac91f75.nwd” for the item handle “7031beac91f75”. If there is no such file, the document item is assumed to contain no text, and a file is created when the user tries to save text to it. - Each item node has a meta data node and a name node. The meta data node contains only collected information, and is thus not strictly required. However, an accurate word count and setting the correct icon in the project tree depend on these values. These values are set by the indexer class, so rebuilding the index should restore the data. - The name node contains the primary user defined settings for an item, and is required for the entry to be valid. - + <content items="27" novelWords="954" notesWords="409"> + <item handle="7031beac91f75" parent="None" root="7031beac91f75" + order="0" type="ROOT" class="NOVEL"> + <meta expanded="yes"/> + <name status="sc24b8f" import="ia857f0">Novel</name> + </item> + ... + </content> + Item Nodes + The item nodes make up the actual project content of the project. Each node represents either a root folder, a regular folder, or a document. Each node has a type, class and layout setting that determine its category. Each item is given a handle that is a random hexadecimal string of length 13. + For the items that are document files, the handle corresponds to its expected filename. It is therefore important that they match in generated projects. Each document node is expected to correspond to a file in the contents folder of the project named “content/7031beac91f75.nwd” for the item handle “7031beac91f75”. If there is no such file, the document item is assumed to contain no text, and a file is created when the user tries to save text to it. + Each item node has a meta data node and a name node. The meta data node contains only collected information, and is thus not strictly required. However, an accurate word count and setting the correct icon in the project tree depend on these values. These values are set by the indexer class, so rebuilding the index should restore the data. + The name node contains the primary user defined settings for an item, and is required for the entry to be valid. + Attributes @@ -2000,79 +1979,79 @@ - handle + handle - Yes + Yes - The 13 value hexadecimal string that represents the item in the project. This is the primary identifier of a project item, and is required. It must be unique withing a project. Internally, it is generated as a random value, but it can also be sequential. + The 13 value hexadecimal string that represents the item in the project. This is the primary identifier of a project item, and is required. It must be unique withing a project. Internally, it is generated as a random value, but it can also be sequential. - parent + parent - Yes + Yes - The handle of the parent item in the tree. This value should only be None for root folders. All other items must have a parent handle set. If the parent handle is None for an item that isn’t a root folder, the item will be treated as orphaned during project loading. + The handle of the parent item in the tree. This value should only be None for root folders. All other items must have a parent handle set. If the parent handle is None for an item that isn’t a root folder, the item will be treated as orphaned during project loading. - root + root - No + No - The handle of the top of its hierarchy of parent items. That is, the root folder which it ultimately sits under. If the attribute is not set, it will be computed during loading. It is saved to the XML file for efficiency reasons. + The handle of the top of its hierarchy of parent items. That is, the root folder which it ultimately sits under. If the attribute is not set, it will be computed during loading. It is saved to the XML file for efficiency reasons. - order + order - No + No - The numerical order of the item under its parent item. This value is not used during the loading process as the physical item order in the content node is used instead. It is primarily saved to the XML for debugging purposes. + The numerical order of the item under its parent item. This value is not used during the loading process as the physical item order in the content node is used instead. It is primarily saved to the XML for debugging purposes. - type + type - Yes + Yes - The item type of the item node. Allowed values are ROOT, FOLDER and FILE. + The item type of the item node. Allowed values are ROOT, FOLDER and FILE. - class + class - Yes + Yes - The item class of the item node. There are a number of item classes available in the app, all corresponding to a specific type of root folder. Each item in a root folder should have the same class set as the root folder itself. The attribute is not strictly required for items that aren’t root folders as it will be automatically set to match during loading. Allowed values are NOVEL, PLOT, CHARACTER, WORLD, TIMELINE, OBJECT, ENTITY, CUSTOM, ARCHIVE and TRASH. + The item class of the item node. There are a number of item classes available in the app, all corresponding to a specific type of root folder. Each item in a root folder should have the same class set as the root folder itself. The attribute is not strictly required for items that aren’t root folders as it will be automatically set to match during loading. Allowed values are NOVEL, PLOT, CHARACTER, WORLD, TIMELINE, OBJECT, ENTITY, CUSTOM, ARCHIVE and TRASH. - layout + layout - Yes + Yes - The item layout of the item node. Allowed values are DOCUMENT and NOTE. This is the attribute that determines if a FILE type item is a Novel Document or a Project Note. + The item layout of the item node. Allowed values are DOCUMENT and NOTE. This is the attribute that determines if a FILE type item is a Novel Document or a Project Note. @@ -2094,29 +2073,29 @@ - meta + meta - No + No - A node of meta data attributes for the item. + A node of meta data attributes for the item. - name + name - Yes + Yes - A node of user settings for the item. + A node of user settings for the item. - Meta Nodes - The meta data collected for the current item. This data either represents the item’s last state in the app, or consists of data collected by the indexer. It can be restored by rebuilding the index, so the data is not essential. + Meta Nodes + The meta data collected for the current item. This data either represents the item’s last state in the app, or consists of data collected by the indexer. It can be restored by rebuilding the index, so the data is not essential. Attributes @@ -2135,73 +2114,73 @@ - expanded + expanded - No + No - Whether the tree node in the project tree was expanded or collapsed during the last session. Applies to all item types, but is ignored for items without child items. + Whether the tree node in the project tree was expanded or collapsed during the last session. Applies to all item types, but is ignored for items without child items. - heading + heading - No + No - The heading level of the first heading of the text of the item. Only applies to FILE item types. Allowed values are H0, H1, H2, H3, and H4. Other values are reset to H0. + The heading level of the first heading of the text of the item. Only applies to FILE item types. Allowed values are H0, H1, H2, H3, and H4. Other values are reset to H0. - charCount + charCount - No + No - The number of characters in the text of the item. Only applies to FILE item types. + The number of characters in the text of the item. Only applies to FILE item types. - wordCount + wordCount - No + No - The number of words in the text of the item. Only applies to FILE item types. + The number of words in the text of the item. Only applies to FILE item types. - paraCount + paraCount - No + No - The number of paragraphs in the text of the item. Only applies to FILE item types. + The number of paragraphs in the text of the item. Only applies to FILE item types. - cursorPos + cursorPos - No + No - The last cursor position in the text of the item from the last editing session. Only applies to FILE item types. The value is used to restore the cursor position when the document is opened in the editor. + The last cursor position in the text of the item from the last editing session. Only applies to FILE item types. The value is used to restore the cursor position when the document is opened in the editor. - Name Nodes - The name node contains information about an item that is set by the user. Including its label, which is the text value of the node. This is the label that is displayed in the project tree. + Name Nodes + The name node contains information about an item that is set by the user. Including its label, which is the text value of the node. This is the label that is displayed in the project tree. Attributes @@ -2220,45 +2199,45 @@ - status + status - No + No - The ID of the status label that has been set for this item. Defaults to the first status item defined in the status section of the settings node. + The ID of the status label that has been set for this item. Defaults to the first status item defined in the status section of the settings node. - import + import - No + No - The ID of the importance label that has been set for this item. Defaults to the first importance item defined in the importance section of the settings node. + The ID of the importance label that has been set for this item. Defaults to the first importance item defined in the importance section of the settings node. - active + active - No + No - The active/inactive status of the document. Only applies to FILE item types. + The active/inactive status of the document. Only applies to FILE item types. Example - <item handle="53b69b83cdafc" parent="7031beac91f75" root="7031beac91f75" - order="0" type="FILE" class="NOVEL" layout="DOCUMENT"> - <meta expanded="no" heading="H1" charCount="93" wordCount="19" - paraCount="2" cursorPos="119"/> - <name status="sc24b8f" import="ia857f0" active="yes">Title Page</name> - </item> + <item handle="53b69b83cdafc" parent="7031beac91f75" root="7031beac91f75" + order="0" type="FILE" class="NOVEL" layout="DOCUMENT"> + <meta expanded="no" heading="H1" charCount="93" wordCount="19" + paraCount="2" cursorPos="119"/> + <name status="sc24b8f" import="ia857f0" active="yes">Title Page</name> + </item> \ No newline at end of file diff --git a/docs/source/fileformatspec15.pdf b/docs/source/fileformatspec15.pdf index 5115b5b1..58b5be9d 100644 Binary files a/docs/source/fileformatspec15.pdf and b/docs/source/fileformatspec15.pdf differ diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py index 4ce39cc6..a91f0b7f 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -42,9 +42,9 @@ __license__ = "GPLv3" __author__ = "Veronica Berglyd Olsen" __maintainer__ = "Veronica Berglyd Olsen" __email__ = "code@vkbo.net" -__version__ = "2.3-alpha1" -__hexversion__ = "0x020300a1" -__date__ = "2023-12-17" +__version__ = "2.3a2" +__hexversion__ = "0x020300a2" +__date__ = "2024-01-26" __status__ = "Stable" __domain__ = "novelwriter.io" @@ -131,7 +131,7 @@ def main(sysArgs: list | None = None): elif inOpt == "--debug": CONFIG.isDebug = True logLevel = logging.DEBUG - logFormat = "[{asctime:}] {filename:>17}:{lineno:<4d} {levelname:8} {message:}" + logFormat = "[{asctime:}] {filename:>18}:{lineno:<4d} {levelname:8} {message:}" elif inOpt == "--style": qtStyle = inArg elif inOpt == "--config": diff --git a/novelwriter/common.py b/novelwriter/common.py index 988b6c78..59b6bbb5 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -250,6 +250,11 @@ def formatTime(t: int) -> str: return "ERROR" +def formatVersion(value: str) -> str: + """Format a version number into a more human readable form.""" + return value.lower().replace("a", " Alpha ").replace("b", " Beta ").replace("rc", " RC ") + + # =============================================================================================== # # String Functions # =============================================================================================== # diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 029bf22d..c88369e8 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -374,7 +374,6 @@ class ProjectBuilder: project.data.setUuid(None) project.data.setName(projName) - project.data.setTitle(projName) project.data.setAuthor(projAuthor) project.data.setLanguage(projLang) project.setDefaultStatusImport() @@ -383,7 +382,7 @@ class ProjectBuilder: # Add Root Folders hNovelRoot = project.newRoot(nwItemClass.NOVEL) hTitlePage = project.newFile(lblTitlePage, hNovelRoot) - novelTitle = project.data.title if project.data.title else project.data.name + novelTitle = project.data.name titlePage = f"#! {novelTitle}\n\n" if project.data.author: diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index f58145f9..7f1598b6 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -528,17 +528,20 @@ class NWIndex: yield f"{tHandle}:{sTitle}", tHandle, sTitle, hItem return - def getNovelWordCount(self, activeOnly: bool = True) -> int: - """Count the number of words in the novel project.""" - wCount = 0 - for _, _, hItem in self._itemIndex.iterNovelStructure(activeOnly=activeOnly): - wCount += hItem.wordCount - return wCount + def getNovelWordCount(self, rootHandle: str | None = None, activeOnly: bool = True) -> int: + """Count the number of words in one or all novel roots.""" + return sum(hItem.wordCount for _, _, hItem in self._itemIndex.iterNovelStructure( + rHandle=rootHandle, activeOnly=activeOnly + )) - def getNovelTitleCounts(self, activeOnly: bool = True) -> list[int]: - """Count the number of titles in the novel project.""" + def getNovelTitleCounts( + self, rootHandle: str | None = None, activeOnly: bool = True + ) -> list[int]: + """Count the number of titles in one or all novel roots.""" hCount = [0, 0, 0, 0, 0] - for _, _, hItem in self._itemIndex.iterNovelStructure(activeOnly=activeOnly): + for _, _, hItem in self._itemIndex.iterNovelStructure( + rHandle=rootHandle, activeOnly=activeOnly + ): iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0) hCount[iLevel] += 1 return hCount diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index 8e50c30e..c48aaa13 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -53,10 +53,6 @@ VALID_MAP: dict[str, set[str]] = { "GuiProjectSettings": { "winWidth", "winHeight", "replaceColW", "statusColW", "importColW", }, - "GuiProjectDetails": { - "winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2", - "widthCol3", "widthCol4", "wordsPerPage", "countFrom", "clearDouble", - }, "GuiWordList": {"winWidth", "winHeight"}, "GuiNovelView": {"lastCol", "lastColSize"}, "GuiBuildSettings": { @@ -71,7 +67,12 @@ VALID_MAP: dict[str, set[str]] = { }, "GuiDocViewerPanel": { "colWidths", "hideInactive", - } + }, + "GuiNovelDetails": { + "winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2", + "widthCol3", "widthCol4", "wordsPerPage", "countFrom", "clearDouble", + "novelRoot", + }, } diff --git a/novelwriter/core/projectdata.py b/novelwriter/core/projectdata.py index 4a6fcec1..07c16909 100644 --- a/novelwriter/core/projectdata.py +++ b/novelwriter/core/projectdata.py @@ -53,7 +53,6 @@ class NWProjectData: # Project Meta self._uuid = "" self._name = "" - self._title = "" self._author = "" self._saveCount = 0 self._autoCount = 0 @@ -102,11 +101,6 @@ class NWProjectData: """Return the project name.""" return self._name - @property - def title(self) -> str: - """Return the project title.""" - return self._title - @property def author(self) -> str: """Return the project author.""" @@ -228,16 +222,9 @@ class NWProjectData: self._project.setProjectChanged(True) return - def setTitle(self, value: str | None) -> None: - """Set a new novel title.""" - if value != self._title: - self._title = simplified(str(value or "")) - self._project.setProjectChanged(True) - return - def setAuthor(self, value: str | None) -> None: """Set the author value.""" - if value != self._title: + if value != self._author: self._author = simplified(str(value or "")) self._project.setProjectChanged(True) return diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index 642a8f10..c64d290f 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -46,7 +46,7 @@ if TYPE_CHECKING: # pragma: no cover logger = logging.getLogger(__name__) FILE_VERSION = "1.5" # The current project file format version -FILE_REVISION = "1" # The current project file format revision +FILE_REVISION = "2" # The current project file format revision HEX_VERSION = 0x0105 NUM_VERSION = { @@ -105,7 +105,8 @@ class ProjectXMLReader: the project or the content into their respective section nodes as attributes. The id attribute was also added to the project. - Rev 1: Drops the titleFormat section of settings. + Rev 1: Drops the titleFormat node from settings. 2.1 Beta 1. + Rev 2: Drops the title node from project. 2.3 Beta 1. """ def __init__(self, path: str | Path) -> None: @@ -232,8 +233,6 @@ class ProjectXMLReader: for xItem in xSection: if xItem.tag == "name": data.setName(xItem.text) - elif xItem.tag == "title": - data.setTitle(xItem.text) elif xItem.tag == "author": data.setAuthor(xItem.text) else: @@ -505,7 +504,6 @@ class ProjectXMLWriter: xProject = ET.SubElement(xRoot, "project", attrib=projAttr) self._packSingleValue(xProject, "name", data.name) - self._packSingleValue(xProject, "title", data.title) self._packSingleValue(xProject, "author", data.author) # Save Project Settings diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py index c509a547..5598ece7 100644 --- a/novelwriter/core/tohtml.py +++ b/novelwriter/core/tohtml.py @@ -338,7 +338,6 @@ class ToHtml(Tokenizer): data = { "meta": { "projectName": self._project.data.name, - "novelTitle": self._project.data.title, "novelAuthor": self._project.data.author, "buildTime": int(timeStamp), "buildTimeStr": formatTimeStamp(timeStamp), diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index f1421db1..5b222e73 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -759,7 +759,6 @@ class Tokenizer(ABC): data = { "meta": { "projectName": self._project.data.name, - "novelTitle": self._project.data.title, "novelAuthor": self._project.data.author, "buildTime": int(timeStamp), "buildTimeStr": formatTimeStamp(timeStamp), diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index b34a56bf..4409b595 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -283,9 +283,7 @@ class ToOdt(Tokenizer): # =============== if self._headerText == "": - theTitle = self._project.data.title or self._project.data.name - theAuth = self._project.data.author - self._headerText = f"{theTitle} / {theAuth} /" + self._headerText = f"{self._project.data.name} / {self._project.data.author} /" # Create Roots # ============ @@ -373,7 +371,7 @@ class ToOdt(Tokenizer): # Dublin Core Meta Data xMeta = ET.SubElement(self._xMeta, _mkTag("dc", "title")) - xMeta.text = self._project.data.title or self._project.data.name + xMeta.text = self._project.data.name xMeta = ET.SubElement(self._xMeta, _mkTag("dc", "date")) xMeta.text = timeStamp diff --git a/novelwriter/dialogs/about.py b/novelwriter/dialogs/about.py index 69e19785..1329b06e 100644 --- a/novelwriter/dialogs/about.py +++ b/novelwriter/dialogs/about.py @@ -220,23 +220,25 @@ class GuiAbout(QDialog): def _setStyleSheet(self) -> None: """Set stylesheet for all browser tabs.""" + colHead = SHARED.theme.colHead + colKey = SHARED.theme.colKey styleSheet = ( "h1, h2, h3, h4 {{" - " color: rgb({hColR},{hColG},{hColB});" + " color: rgb({hColR}, {hColG}, {hColB});" "}}\n" "a {{" - " color: rgb({hColR},{hColG},{hColB});" + " color: rgb({hColR}, {hColG}, {hColB});" "}}\n" ".alt {{" - " color: rgb({kColR},{kColG},{kColB});" + " color: rgb({kColR}, {kColG}, {kColB});" "}}\n" ).format( - hColR=SHARED.theme.colHead[0], - hColG=SHARED.theme.colHead[1], - hColB=SHARED.theme.colHead[2], - kColR=SHARED.theme.colKey[0], - kColG=SHARED.theme.colKey[1], - kColB=SHARED.theme.colKey[2], + hColR=colHead.red(), + hColG=colHead.green(), + hColB=colHead.blue(), + kColR=colKey.red(), + kColG=colKey.green(), + kColB=colKey.blue(), ) self.pageAbout.document().setDefaultStyleSheet(styleSheet) self.pageNotes.document().setDefaultStyleSheet(styleSheet) diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py index 15b54660..a0c43611 100644 --- a/novelwriter/dialogs/docmerge.py +++ b/novelwriter/dialogs/docmerge.py @@ -35,7 +35,7 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG, SHARED from novelwriter.extensions.switch import NSwitch -from novelwriter.extensions.configlayout import NHelpLabel +from novelwriter.extensions.configlayout import NColourLabel logger = logging.getLogger(__name__) @@ -54,9 +54,10 @@ class GuiDocMerge(QDialog): self._data = {} self.headLabel = QLabel("{0}".format(self.tr("Documents to Merge"))) - self.helpLabel = NHelpLabel(self.tr( - "Drag and drop items to change the order, or uncheck to exclude." - ), SHARED.theme.helpText) + self.helpLabel = NColourLabel( + self.tr("Drag and drop items to change the order, or uncheck to exclude."), + SHARED.theme.helpText, parent=self, wrap=True + ) iPx = SHARED.theme.baseIconSize hSp = CONFIG.pxInt(12) diff --git a/novelwriter/dialogs/docsplit.py b/novelwriter/dialogs/docsplit.py index 602b860c..b8add4c0 100644 --- a/novelwriter/dialogs/docsplit.py +++ b/novelwriter/dialogs/docsplit.py @@ -35,7 +35,7 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG, SHARED from novelwriter.extensions.switch import NSwitch -from novelwriter.extensions.configlayout import NHelpLabel +from novelwriter.extensions.configlayout import NColourLabel logger = logging.getLogger(__name__) @@ -58,9 +58,9 @@ class GuiDocSplit(QDialog): self.setWindowTitle(self.tr("Split Document")) self.headLabel = QLabel("{0}".format(self.tr("Document Headers"))) - self.helpLabel = NHelpLabel( + self.helpLabel = NColourLabel( self.tr("Select the maximum level to split into files."), - SHARED.theme.helpText + SHARED.theme.helpText, parent=self, wrap=True ) # Values diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index 49e58e00..7653734e 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -26,11 +26,11 @@ from __future__ import annotations import logging -from PyQt5.QtGui import QCloseEvent, QColor, QFont, QKeyEvent, QKeySequence, QPalette +from PyQt5.QtGui import QCloseEvent, QFont, QKeyEvent, QKeySequence from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QAbstractButton, QComboBox, QCompleter, QDialog, QDialogButtonBox, - QDoubleSpinBox, QFileDialog, QFontDialog, QHBoxLayout, QLabel, QLineEdit, + QDoubleSpinBox, QFileDialog, QFontDialog, QHBoxLayout, QLineEdit, QPushButton, QSpinBox, QToolButton, QVBoxLayout, QWidget, qApp ) @@ -38,7 +38,7 @@ from novelwriter import CONFIG, SHARED from novelwriter.constants import nwConst, nwUnicode from novelwriter.dialogs.quotes import GuiQuoteSelect from novelwriter.extensions.switch import NSwitch -from novelwriter.extensions.configlayout import NScrollableForm +from novelwriter.extensions.configlayout import NColourLabel, NScrollableForm from novelwriter.extensions.pagedsidebar import NPagedSideBar logger = logging.getLogger(__name__) @@ -58,21 +58,10 @@ class GuiPreferences(QDialog): self.resize(*CONFIG.preferencesWinSize) # Title - font = self.font() - font.setPointSizeF(1.25*SHARED.theme.fontPointSize) - - palette = self.palette() - palette.setColor(QPalette.ColorRole.WindowText, QColor(*SHARED.theme.helpText)) - - self.titleLabel = QLabel(self.tr("Preferences"), self) - self.titleLabel.setFont(font) - self.titleLabel.setPalette(palette) - self.titleLabel.setIndent(CONFIG.pxInt(4)) - - # SideBar - self.sidebar = NPagedSideBar(self) - self.sidebar.setLabelColor(SHARED.theme.helpText) - self.sidebar.buttonClicked.connect(self._sidebarClicked) + self.titleLabel = NColourLabel( + self.tr("Preferences"), SHARED.theme.helpText, + parent=self, scale=NColourLabel.HEADER_SCALE, indent=CONFIG.pxInt(4) + ) # Search Box self.searchText = QLineEdit(self) @@ -83,10 +72,10 @@ class GuiPreferences(QDialog): ) self.searchAction.triggered.connect(self._gotoSearch) - self.searchBox = QHBoxLayout() - self.searchBox.addWidget(self.titleLabel) - self.searchBox.addStretch(1) - self.searchBox.addWidget(self.searchText, 1) + # SideBar + self.sidebar = NPagedSideBar(self) + self.sidebar.setLabelColor(SHARED.theme.helpText) + self.sidebar.buttonClicked.connect(self._sidebarClicked) # Form self.mainForm = NScrollableForm(self) @@ -101,6 +90,11 @@ class GuiPreferences(QDialog): self.buttonBox.clicked.connect(self._dialogButtonClicked) # Assemble + self.searchBox = QHBoxLayout() + self.searchBox.addWidget(self.titleLabel) + self.searchBox.addStretch(1) + self.searchBox.addWidget(self.searchText, 1) + self.mainBox = QHBoxLayout() self.mainBox.addWidget(self.sidebar) self.mainBox.addWidget(self.mainForm) diff --git a/novelwriter/dialogs/projdetails.py b/novelwriter/dialogs/projdetails.py deleted file mode 100644 index a6e12163..00000000 --- a/novelwriter/dialogs/projdetails.py +++ /dev/null @@ -1,518 +0,0 @@ -""" -novelWriter – GUI Project Details -================================= - -File History: -Created: 2021-01-03 [1.1rc1] GuiProjectDetails - -This file is a part of novelWriter -Copyright 2018–2024, Veronica Berglyd Olsen - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, but -WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" -from __future__ import annotations - -import math -import logging - -from PyQt5.QtGui import QCloseEvent, QFont -from PyQt5.QtCore import Qt, QSize, pyqtSlot -from PyQt5.QtWidgets import ( - QAbstractItemView, QDialogButtonBox, QGridLayout, QHBoxLayout, QLabel, - QLineEdit, QSpinBox, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget -) - -from novelwriter import CONFIG, SHARED -from novelwriter.common import formatTime, numberToRoman -from novelwriter.constants import nwUnicode -from novelwriter.extensions.switch import NSwitch -from novelwriter.extensions.pageddialog import NPagedDialog -from novelwriter.extensions.novelselector import NovelSelector - -logger = logging.getLogger(__name__) - - -class GuiProjectDetails(NPagedDialog): - - def __init__(self, parent: QWidget) -> None: - super().__init__(parent=parent) - - logger.debug("Create: GuiProjectDetails") - self.setObjectName("GuiProjectDetails") - - self.setWindowTitle(self.tr("Project Details")) - - wW = CONFIG.pxInt(600) - wH = CONFIG.pxInt(400) - pOptions = SHARED.project.options - - self.setMinimumWidth(wW) - self.setMinimumHeight(wH) - self.resize( - CONFIG.pxInt(pOptions.getInt("GuiProjectDetails", "winWidth", wW)), - CONFIG.pxInt(pOptions.getInt("GuiProjectDetails", "winHeight", wH)) - ) - - self.tabMain = GuiProjectDetailsMain(self) - self.tabContents = GuiProjectDetailsContents(self) - - self.addTab(self.tabMain, self.tr("Overview")) - self.addTab(self.tabContents, self.tr("Contents")) - - self.buttonBox = QDialogButtonBox(QDialogButtonBox.Close) - self.buttonBox.rejected.connect(self.close) - self.rejected.connect(self.close) - self.addControls(self.buttonBox) - - logger.debug("Ready: GuiProjectDetails") - - return - - def __del__(self) -> None: # pragma: no cover - logger.debug("Delete: GuiProjectDetails") - return - - def updateValues(self) -> None: - """Set all the values of the pages.""" - self.tabMain.updateValues() - self.tabContents.updateValues() - return - - ## - # Events - ## - - def closeEvent(self, event: QCloseEvent) -> None: - """Capture the close event and perform cleanup.""" - self._saveGuiSettings() - event.accept() - self.deleteLater() - return - - ## - # Internal Functions - ## - - def _saveGuiSettings(self) -> None: - """Save GUI settings.""" - winWidth = CONFIG.rpxInt(self.width()) - winHeight = CONFIG.rpxInt(self.height()) - - cColWidth = self.tabContents.getColumnSizes() - widthCol0 = CONFIG.rpxInt(cColWidth[0]) - widthCol1 = CONFIG.rpxInt(cColWidth[1]) - widthCol2 = CONFIG.rpxInt(cColWidth[2]) - widthCol3 = CONFIG.rpxInt(cColWidth[3]) - widthCol4 = CONFIG.rpxInt(cColWidth[4]) - - wordsPerPage = self.tabContents.wpValue.value() - countFrom = self.tabContents.poValue.value() - clearDouble = self.tabContents.dblValue.isChecked() - - logger.debug("Saving State: GuiProjectDetails") - pOptions = SHARED.project.options - pOptions.setValue("GuiProjectDetails", "winWidth", winWidth) - pOptions.setValue("GuiProjectDetails", "winHeight", winHeight) - pOptions.setValue("GuiProjectDetails", "widthCol0", widthCol0) - pOptions.setValue("GuiProjectDetails", "widthCol1", widthCol1) - pOptions.setValue("GuiProjectDetails", "widthCol2", widthCol2) - pOptions.setValue("GuiProjectDetails", "widthCol3", widthCol3) - pOptions.setValue("GuiProjectDetails", "widthCol4", widthCol4) - pOptions.setValue("GuiProjectDetails", "wordsPerPage", wordsPerPage) - pOptions.setValue("GuiProjectDetails", "countFrom", countFrom) - pOptions.setValue("GuiProjectDetails", "clearDouble", clearDouble) - - return - -# END Class GuiProjectDetails - - -class GuiProjectDetailsMain(QWidget): - - def __init__(self, parent: QWidget) -> None: - super().__init__(parent=parent) - - fPx = SHARED.theme.fontPixelSize - fPt = SHARED.theme.fontPointSize - vPx = CONFIG.pxInt(4) - hPx = CONFIG.pxInt(12) - - # Header - # ====== - - self.bookTitle = QLabel("") - bookFont = self.bookTitle.font() - bookFont.setPointSizeF(2.2*fPt) - bookFont.setWeight(QFont.Bold) - self.bookTitle.setFont(bookFont) - self.bookTitle.setAlignment(Qt.AlignHCenter) - self.bookTitle.setWordWrap(True) - - self.projName = QLabel("") - workFont = self.projName.font() - workFont.setPointSizeF(0.8*fPt) - workFont.setItalic(True) - self.projName.setFont(workFont) - self.projName.setAlignment(Qt.AlignHCenter) - self.projName.setWordWrap(True) - - self.bookAuthors = QLabel("") - authFont = self.bookAuthors.font() - authFont.setPointSizeF(1.2*fPt) - self.bookAuthors.setFont(authFont) - self.bookAuthors.setAlignment(Qt.AlignHCenter) - self.bookAuthors.setWordWrap(True) - - # Stats - # ===== - - self.wordCountLbl = QLabel("%s:" % self.tr("Words")) - self.wordCountVal = QLabel("") - - self.chapCountLbl = QLabel("%s:" % self.tr("Chapters")) - self.chapCountVal = QLabel("") - - self.sceneCountLbl = QLabel("%s:" % self.tr("Scenes")) - self.sceneCountVal = QLabel("") - - self.revCountLbl = QLabel("%s:" % self.tr("Revisions")) - self.revCountVal = QLabel("") - - self.editTimeLbl = QLabel("%s:" % self.tr("Editing Time")) - self.editTimeVal = QLabel("") - - self.statsGrid = QGridLayout() - self.statsGrid.addWidget(self.wordCountLbl, 0, 0, 1, 1, Qt.AlignRight) - self.statsGrid.addWidget(self.wordCountVal, 0, 1, 1, 1, Qt.AlignLeft) - self.statsGrid.addWidget(self.chapCountLbl, 1, 0, 1, 1, Qt.AlignRight) - self.statsGrid.addWidget(self.chapCountVal, 1, 1, 1, 1, Qt.AlignLeft) - self.statsGrid.addWidget(self.sceneCountLbl, 2, 0, 1, 1, Qt.AlignRight) - self.statsGrid.addWidget(self.sceneCountVal, 2, 1, 1, 1, Qt.AlignLeft) - self.statsGrid.addWidget(self.revCountLbl, 3, 0, 1, 1, Qt.AlignRight) - self.statsGrid.addWidget(self.revCountVal, 3, 1, 1, 1, Qt.AlignLeft) - self.statsGrid.addWidget(self.editTimeLbl, 4, 0, 1, 1, Qt.AlignRight) - self.statsGrid.addWidget(self.editTimeVal, 4, 1, 1, 1, Qt.AlignLeft) - self.statsGrid.setHorizontalSpacing(hPx) - self.statsGrid.setVerticalSpacing(vPx) - - # Meta - # ==== - - self.projPathLbl = QLabel("%s:" % self.tr("Path")) - self.projPathVal = QLineEdit() - self.projPathVal.setReadOnly(True) - - self.projPathBox = QHBoxLayout() - self.projPathBox.addWidget(self.projPathLbl) - self.projPathBox.addWidget(self.projPathVal) - self.projPathBox.setSpacing(hPx) - - # Assemble - # ======== - - self.outerBox = QVBoxLayout() - self.outerBox.addSpacing(fPx) - self.outerBox.addWidget(self.bookTitle) - self.outerBox.addWidget(self.projName) - self.outerBox.addWidget(self.bookAuthors) - self.outerBox.addSpacing(2*fPx) - self.outerBox.addLayout(self.statsGrid) - self.outerBox.addSpacing(fPx) - self.outerBox.addStretch(1) - self.outerBox.addLayout(self.projPathBox) - - self.setLayout(self.outerBox) - - return - - def updateValues(self) -> None: - """Set all the values.""" - project = SHARED.project - pIndex = project.index - hCounts = pIndex.getNovelTitleCounts() - nwCount = pIndex.getNovelWordCount() - edTime = project.currentEditTime - - self.bookTitle.setText(project.data.title or project.data.name) - self.projName.setText(self.tr("Project: {0}").format(project.data.name)) - self.bookAuthors.setText(self.tr("By {0}").format(project.data.author)) - - self.wordCountVal.setText(f"{nwCount:n}") - self.chapCountVal.setText(f"{hCounts[2]:n}") - self.sceneCountVal.setText(f"{hCounts[3]:n}") - self.revCountVal.setText(f"{project.data.saveCount:n}") - self.editTimeVal.setText(formatTime(edTime)) - - self.projPathVal.setText(str(project.storage.storagePath)) - - return - -# END Class GuiProjectDetailsMain - - -class GuiProjectDetailsContents(QWidget): - - C_TITLE = 0 - C_WORDS = 1 - C_PAGES = 2 - C_PAGE = 3 - C_PROG = 4 - - def __init__(self, parent: QWidget) -> None: - super().__init__(parent=parent) - - # Internal - self._theToC = [] - self._currentRoot = None - - iPx = SHARED.theme.baseIconSize - hPx = CONFIG.pxInt(12) - vPx = CONFIG.pxInt(4) - pOptions = SHARED.project.options - - # Header - # ====== - - self.tocLabel = QLabel("%s" % self.tr("Table of Contents")) - - self.novelValue = NovelSelector(self) - self.novelValue.setMinimumWidth(CONFIG.pxInt(200)) - self.novelValue.novelSelectionChanged.connect(self._novelValueChanged) - - self.headBox = QHBoxLayout() - self.headBox.addWidget(self.tocLabel) - self.headBox.addWidget(self.novelValue) - - # Contents Tree - # ============= - - self.tocTree = QTreeWidget() - self.tocTree.setIconSize(QSize(iPx, iPx)) - self.tocTree.setIndentation(0) - self.tocTree.setColumnCount(6) - self.tocTree.setSelectionMode(QAbstractItemView.NoSelection) - self.tocTree.setHeaderLabels([ - self.tr("Title"), - self.tr("Words"), - self.tr("Pages"), - self.tr("Page"), - self.tr("Progress"), - "" - ]) - - treeHeadItem = self.tocTree.headerItem() - if treeHeadItem: - treeHeadItem.setTextAlignment(self.C_WORDS, Qt.AlignRight) - treeHeadItem.setTextAlignment(self.C_PAGES, Qt.AlignRight) - treeHeadItem.setTextAlignment(self.C_PAGE, Qt.AlignRight) - treeHeadItem.setTextAlignment(self.C_PROG, Qt.AlignRight) - - treeHeader = self.tocTree.header() - treeHeader.setStretchLastSection(True) - treeHeader.setMinimumSectionSize(hPx) - - wCol0 = CONFIG.pxInt(pOptions.getInt("GuiProjectDetails", "widthCol0", 200)) - wCol1 = CONFIG.pxInt(pOptions.getInt("GuiProjectDetails", "widthCol1", 60)) - wCol2 = CONFIG.pxInt(pOptions.getInt("GuiProjectDetails", "widthCol2", 60)) - wCol3 = CONFIG.pxInt(pOptions.getInt("GuiProjectDetails", "widthCol3", 60)) - wCol4 = CONFIG.pxInt(pOptions.getInt("GuiProjectDetails", "widthCol4", 90)) - - self.tocTree.setColumnWidth(0, wCol0) - self.tocTree.setColumnWidth(1, wCol1) - self.tocTree.setColumnWidth(2, wCol2) - self.tocTree.setColumnWidth(3, wCol3) - self.tocTree.setColumnWidth(4, wCol4) - self.tocTree.setColumnWidth(5, hPx) - - # Options - # ======= - - wordsPerPage = pOptions.getInt("GuiProjectDetails", "wordsPerPage", 350) - countFrom = pOptions.getInt("GuiProjectDetails", "countFrom", 1) - clearDouble = pOptions.getBool("GuiProjectDetails", "clearDouble", True) - - wordsHelp = ( - self.tr("Typical word count for a 5 by 8 inch book page with 11 pt font is 350.") - ) - offsetHelp = ( - self.tr("Start counting page numbers from this page.") - ) - dblHelp = ( - self.tr("Assume a new chapter or partition always start on an odd numbered page.") - ) - - self.wpLabel = QLabel(self.tr("Words per page")) - self.wpLabel.setToolTip(wordsHelp) - - self.wpValue = QSpinBox() - self.wpValue.setMinimum(10) - self.wpValue.setMaximum(1000) - self.wpValue.setSingleStep(10) - self.wpValue.setValue(wordsPerPage) - self.wpValue.setToolTip(wordsHelp) - self.wpValue.valueChanged.connect(self._populateTree) - - self.poLabel = QLabel(self.tr("Count pages from")) - self.poLabel.setToolTip(offsetHelp) - - self.poValue = QSpinBox() - self.poValue.setMinimum(1) - self.poValue.setMaximum(9999) - self.poValue.setSingleStep(1) - self.poValue.setValue(countFrom) - self.poValue.setToolTip(offsetHelp) - self.poValue.valueChanged.connect(self._populateTree) - - self.dblLabel = QLabel(self.tr("Clear double pages")) - self.dblLabel.setToolTip(dblHelp) - - self.dblValue = NSwitch(self, 2*iPx, iPx) - self.dblValue.setChecked(clearDouble) - self.dblValue.setToolTip(dblHelp) - self.dblValue.clicked.connect(self._populateTree) - - self.optionsBox = QGridLayout() - self.optionsBox.addWidget(self.wpLabel, 0, 0) - self.optionsBox.addWidget(self.wpValue, 0, 1) - self.optionsBox.addWidget(self.dblLabel, 0, 3) - self.optionsBox.addWidget(self.dblValue, 0, 4) - self.optionsBox.addWidget(self.poLabel, 1, 0) - self.optionsBox.addWidget(self.poValue, 1, 1) - self.optionsBox.setHorizontalSpacing(hPx) - self.optionsBox.setVerticalSpacing(vPx) - self.optionsBox.setColumnStretch(2, 1) - - # Assemble - # ======== - - self.outerBox = QVBoxLayout() - self.outerBox.addLayout(self.headBox) - self.outerBox.addWidget(self.tocTree) - self.outerBox.addLayout(self.optionsBox) - - self.setLayout(self.outerBox) - - return - - def getColumnSizes(self) -> list[int]: - """Return the column widths for the tree columns.""" - retVals = [ - self.tocTree.columnWidth(0), - self.tocTree.columnWidth(1), - self.tocTree.columnWidth(2), - self.tocTree.columnWidth(3), - self.tocTree.columnWidth(4), - ] - return retVals - - def updateValues(self) -> None: - """Populate the tree.""" - self._currentRoot = None - self.novelValue.updateList() - self.novelValue.setHandle(self.novelValue.firstHandle) - self._prepareData(self.novelValue.firstHandle) - self._populateTree() - return - - ## - # Internal Functions - ## - - def _prepareData(self, rootHandle: str | None) -> None: - """Extract the information from the project index.""" - logger.debug("Populating ToC from handle '%s'", rootHandle) - self._theToC = SHARED.project.index.getTableOfContents(rootHandle, 2) - self._theToC.append(("", 0, self.tr("END"), 0)) - return - - ## - # Slots - ## - - @pyqtSlot(str) - def _novelValueChanged(self, tHandle: str) -> None: - """Refresh the tree with another root item.""" - if tHandle != self._currentRoot: - self._prepareData(tHandle) - self._populateTree() - self._currentRoot = self.novelValue.handle - return - - @pyqtSlot() - def _populateTree(self) -> None: - """Set the content of the chapter/page tree.""" - dblPages = self.dblValue.isChecked() - wpPage = self.wpValue.value() - fstPage = self.poValue.value() - 1 - - pTotal = 0 - tPages = 1 - - theList = [] - for _, tLevel, tTitle, wCount in self._theToC: - pCount = math.ceil(wCount/wpPage) - if dblPages: - pCount += pCount%2 - - pTotal += pCount - theList.append((tLevel, tTitle, wCount, pCount)) - - pMax = pTotal - fstPage - - self.tocTree.clear() - for tLevel, tTitle, wCount, pCount in theList: - newItem = QTreeWidgetItem() - - if tPages <= fstPage: - progPage = numberToRoman(tPages, True) - progText = "" - else: - cPage = tPages - fstPage - pgProg = 100.0*(cPage - 1)/pMax if pMax > 0 else 0.0 - progPage = f"{cPage:n}" - progText = f"{pgProg:.1f}{nwUnicode.U_THSP}%" - - hDec = SHARED.theme.getHeaderDecoration(tLevel) - if tTitle.strip() == "": - tTitle = self.tr("Untitled") - - newItem.setData(self.C_TITLE, Qt.DecorationRole, hDec) - newItem.setText(self.C_TITLE, tTitle) - newItem.setText(self.C_WORDS, f"{wCount:n}") - newItem.setText(self.C_PAGES, f"{pCount:n}") - newItem.setText(self.C_PAGE, progPage) - newItem.setText(self.C_PROG, progText) - - newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight) - newItem.setTextAlignment(self.C_PAGES, Qt.AlignRight) - newItem.setTextAlignment(self.C_PAGE, Qt.AlignRight) - newItem.setTextAlignment(self.C_PROG, Qt.AlignRight) - - # Make pages and titles/partitions stand out - if tLevel < 2: - bFont = newItem.font(self.C_TITLE) - if tLevel == 0: - bFont.setItalic(True) - else: - bFont.setBold(True) - bFont.setUnderline(True) - newItem.setFont(self.C_TITLE, bFont) - - tPages += pCount - - self.tocTree.addTopLevelItem(newItem) - - return - -# END Class GuiProjectDetailsContents diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projectsettings.py similarity index 57% rename from novelwriter/dialogs/projsettings.py rename to novelwriter/dialogs/projectsettings.py index 8a9354a0..f6ff7730 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projectsettings.py @@ -3,7 +3,8 @@ novelWriter – GUI Project Settings ================================== File History: -Created: 2018-09-29 [0.0.1] GuiProjectSettings +Created: 2018-09-29 [0.0.1] GuiProjectSettings +Rewritten: 2024-01-26 [2.3b1] GuiProjectSettings This file is a part of novelWriter Copyright 2018–2024, Veronica Berglyd Olsen @@ -25,75 +26,101 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING - -from PyQt5.QtGui import QCloseEvent, QIcon, QPixmap, QColor +from PyQt5.QtGui import QCloseEvent, QColor, QIcon, QPixmap from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( - QColorDialog, QComboBox, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit, - QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, qApp + QColorDialog, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLineEdit, + QPushButton, QStackedWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, + QWidget, qApp ) from novelwriter import CONFIG, SHARED from novelwriter.common import simplified from novelwriter.extensions.switch import NSwitch -from novelwriter.extensions.pageddialog import NPagedDialog -from novelwriter.extensions.configlayout import NConfigLayout - -if TYPE_CHECKING: # pragma: no cover - from novelwriter.guimain import GuiMain +from novelwriter.extensions.configlayout import NColourLabel, NFixedPage, NScrollableForm +from novelwriter.extensions.pagedsidebar import NPagedSideBar logger = logging.getLogger(__name__) -class GuiProjectSettings(NPagedDialog): +class GuiProjectSettings(QDialog): - TAB_MAIN = 0 - TAB_STATUS = 1 - TAB_IMPORT = 2 - TAB_REPLACE = 3 + PAGE_SETTINGS = 0 + PAGE_STATUS = 1 + PAGE_IMPORT = 2 + PAGE_REPLACE = 3 - newProjectSettingsReady = pyqtSignal() + newProjectSettingsReady = pyqtSignal(bool) - def __init__(self, mainGui: GuiMain, focusTab: int = TAB_MAIN) -> None: - super().__init__(parent=mainGui) + def __init__(self, parent: QWidget, gotoPage: int = PAGE_SETTINGS) -> None: + super().__init__(parent=parent) logger.debug("Create: GuiProjectSettings") self.setObjectName("GuiProjectSettings") - - self.mainGui = mainGui - SHARED.project.countStatus() self.setWindowTitle(self.tr("Project Settings")) - wW = CONFIG.pxInt(570) - wH = CONFIG.pxInt(375) - pOptions = SHARED.project.options - - self.setMinimumWidth(wW) - self.setMinimumHeight(wH) + options = SHARED.project.options + self.setMinimumSize(CONFIG.pxInt(500), CONFIG.pxInt(400)) self.resize( - CONFIG.pxInt(pOptions.getInt("GuiProjectSettings", "winWidth", wW)), - CONFIG.pxInt(pOptions.getInt("GuiProjectSettings", "winHeight", wH)) + CONFIG.pxInt(options.getInt("GuiProjectSettings", "winWidth", CONFIG.pxInt(650))), + CONFIG.pxInt(options.getInt("GuiProjectSettings", "winHeight", CONFIG.pxInt(500))) ) - self.tabMain = GuiProjectEditMain(self) - self.tabStatus = GuiProjectEditStatus(self, True) - self.tabImport = GuiProjectEditStatus(self, False) - self.tabReplace = GuiProjectEditReplace(self) + # Title + self.titleLabel = NColourLabel( + self.tr("Project Settings"), SHARED.theme.helpText, + parent=self, scale=NColourLabel.HEADER_SCALE, indent=CONFIG.pxInt(4) + ) - self.addTab(self.tabMain, self.tr("Settings")) - self.addTab(self.tabStatus, self.tr("Status")) - self.addTab(self.tabImport, self.tr("Importance")) - self.addTab(self.tabReplace, self.tr("Auto-Replace")) + # SideBar + self.sidebar = NPagedSideBar(self) + self.sidebar.setLabelColor(SHARED.theme.helpText) + self.sidebar.addButton(self.tr("Settings"), self.PAGE_SETTINGS) + self.sidebar.addButton(self.tr("Status"), self.PAGE_STATUS) + self.sidebar.addButton(self.tr("Importance"), self.PAGE_IMPORT) + self.sidebar.addButton(self.tr("Auto-Replace"), self.PAGE_REPLACE) + self.sidebar.setSelected(gotoPage) + self.sidebar.buttonClicked.connect(self._sidebarClicked) - self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + # Buttons + self.buttonBox = QDialogButtonBox( + QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel + ) self.buttonBox.accepted.connect(self._doSave) self.buttonBox.rejected.connect(self.close) - self.rejected.connect(self.close) - self.addControls(self.buttonBox) - # Focus Tab - self._focusTab(focusTab) + # Content + SHARED.project.countStatus() + + self.settingsPage = _SettingsPage(self) + self.statusPage = _StatusPage(self, True) + self.importPage = _StatusPage(self, False) + self.replacePage = _ReplacePage(self) + + self.mainStack = QStackedWidget(self) + self.mainStack.addWidget(self.settingsPage) + self.mainStack.addWidget(self.statusPage) + self.mainStack.addWidget(self.importPage) + self.mainStack.addWidget(self.replacePage) + + # Assemble + self.topBox = QHBoxLayout() + self.topBox.addWidget(self.titleLabel) + self.topBox.addStretch(1) + + self.mainBox = QHBoxLayout() + self.mainBox.addWidget(self.sidebar) + self.mainBox.addWidget(self.mainStack) + self.mainBox.setContentsMargins(0, 0, 0, 0) + + self.outerBox = QVBoxLayout() + self.outerBox.addLayout(self.topBox) + self.outerBox.addLayout(self.mainBox) + self.outerBox.addWidget(self.buttonBox) + self.outerBox.setSpacing(CONFIG.pxInt(8)) + + self.setLayout(self.outerBox) + self.setSizeGripEnabled(True) logger.debug("Ready: GuiProjectSettings") @@ -103,9 +130,13 @@ class GuiProjectSettings(NPagedDialog): logger.debug("Delete: GuiProjectSettings") return + ## + # Events + ## + def closeEvent(self, event: QCloseEvent) -> None: - """Capture the close event and perform cleanup.""" - self._saveGuiSettings() + """Capture the user closing the window and save settings.""" + self._saveSettings() event.accept() self.deleteLater() return @@ -114,40 +145,52 @@ class GuiProjectSettings(NPagedDialog): # Private Slots ## + @pyqtSlot(int) + def _sidebarClicked(self, pageId: int) -> None: + """Process a user request to switch page.""" + if pageId == self.PAGE_SETTINGS: + self.mainStack.setCurrentWidget(self.settingsPage) + elif pageId == self.PAGE_STATUS: + self.mainStack.setCurrentWidget(self.statusPage) + elif pageId == self.PAGE_IMPORT: + self.mainStack.setCurrentWidget(self.importPage) + elif pageId == self.PAGE_REPLACE: + self.mainStack.setCurrentWidget(self.replacePage) + return + @pyqtSlot() def _doSave(self) -> None: """Save settings and close dialog.""" project = SHARED.project - projName = self.tabMain.editName.text() - bookTitle = self.tabMain.editTitle.text() - bookAuthor = self.tabMain.editAuthor.text() - projLang = self.tabMain.projLang.currentData() - spellLang = self.tabMain.spellLang.currentData() - doBackup = not self.tabMain.doBackup.isChecked() + projName = self.settingsPage.projName.text() + projAuthor = self.settingsPage.projAuthor.text() + projLang = self.settingsPage.projLang.currentData() + spellLang = self.settingsPage.spellLang.currentData() + doBackup = not self.settingsPage.doBackup.isChecked() project.data.setName(projName) - project.data.setTitle(bookTitle) - project.data.setAuthor(bookAuthor) + project.data.setAuthor(projAuthor) project.data.setDoBackup(doBackup) project.data.setSpellLang(spellLang) project.setProjectLang(projLang) - if self.tabStatus.colChanged: - newList, delList = self.tabStatus.getNewList() + rebuildTrees = False + + if self.statusPage.wasChanged: + newList, delList = self.statusPage.getNewList() project.setStatusColours(newList, delList) + rebuildTrees = True - if self.tabImport.colChanged: - newList, delList = self.tabImport.getNewList() + if self.importPage.wasChanged: + newList, delList = self.importPage.getNewList() project.setImportColours(newList, delList) + rebuildTrees = True - if self.tabStatus.colChanged or self.tabImport.colChanged: - self.mainGui.rebuildTrees() - - if self.tabReplace.arChanged: - newList = self.tabReplace.getNewList() + if self.replacePage.wasChanged: + newList = self.replacePage.getNewList() project.data.setAutoReplace(newList) - self.newProjectSettingsReady.emit() + self.newProjectSettingsReady.emit(rebuildTrees) qApp.processEvents() self.close() @@ -157,134 +200,103 @@ class GuiProjectSettings(NPagedDialog): # Internal Functions ## - def _focusTab(self, tab: int) -> None: - """Change which is the focused tab.""" - if tab == self.TAB_MAIN: - self.setCurrentWidget(self.tabMain) - elif tab == self.TAB_STATUS: - self.setCurrentWidget(self.tabStatus) - elif tab == self.TAB_IMPORT: - self.setCurrentWidget(self.tabImport) - elif tab == self.TAB_REPLACE: - self.setCurrentWidget(self.tabReplace) - return - - def _saveGuiSettings(self) -> None: + def _saveSettings(self) -> None: """Save GUI settings.""" winWidth = CONFIG.rpxInt(self.width()) winHeight = CONFIG.rpxInt(self.height()) - replaceColW = CONFIG.rpxInt(self.tabReplace.listBox.columnWidth(0)) - statusColW = CONFIG.rpxInt(self.tabStatus.listBox.columnWidth(0)) - importColW = CONFIG.rpxInt(self.tabImport.listBox.columnWidth(0)) + statusColW = CONFIG.rpxInt(self.statusPage.columnWidth()) + importColW = CONFIG.rpxInt(self.importPage.columnWidth()) + replaceColW = CONFIG.rpxInt(self.replacePage.columnWidth()) logger.debug("Saving State: GuiProjectSettings") - pOptions = SHARED.project.options - pOptions.setValue("GuiProjectSettings", "winWidth", winWidth) - pOptions.setValue("GuiProjectSettings", "winHeight", winHeight) - pOptions.setValue("GuiProjectSettings", "replaceColW", replaceColW) - pOptions.setValue("GuiProjectSettings", "statusColW", statusColW) - pOptions.setValue("GuiProjectSettings", "importColW", importColW) + options = SHARED.project.options + options.setValue("GuiProjectSettings", "winWidth", winWidth) + options.setValue("GuiProjectSettings", "winHeight", winHeight) + options.setValue("GuiProjectSettings", "statusColW", statusColW) + options.setValue("GuiProjectSettings", "importColW", importColW) + options.setValue("GuiProjectSettings", "replaceColW", replaceColW) return # END Class GuiProjectSettings -class GuiProjectEditMain(QWidget): +class _SettingsPage(NScrollableForm): def __init__(self, parent: QWidget) -> None: super().__init__(parent=parent) - # The Form - self.mainForm = NConfigLayout() - self.mainForm.setHelpTextStyle(SHARED.theme.helpText) - self.setLayout(self.mainForm) + xW = CONFIG.pxInt(200) + data = SHARED.project.data + self.setHelpTextStyle(SHARED.theme.helpText) + self.setRowIndent(0) - self.mainForm.addGroupLabel(self.tr("Project Settings")) - - xW = CONFIG.pxInt(250) - pData = SHARED.project.data - - self.editName = QLineEdit() - self.editName.setMaxLength(200) - self.editName.setMaximumWidth(xW) - self.editName.setText(pData.name) - self.mainForm.addRow( - self.tr("Project name"), - self.editName, - self.tr("Should be set only once.") + # Project Name + self.projName = QLineEdit(self) + self.projName.setMaxLength(200) + self.projName.setMinimumWidth(xW) + self.projName.setText(data.name) + self.addRow( + self.tr("Project name"), self.projName, + self.tr("Changing this will affect the backup path."), + stretch=(3, 2) ) - self.editTitle = QLineEdit() - self.editTitle.setMaxLength(200) - self.editTitle.setMaximumWidth(xW) - self.editTitle.setText(pData.title) - self.mainForm.addRow( - self.tr("Novel title"), - self.editTitle, - self.tr("Change whenever you want!") - ) - - self.editAuthor = QLineEdit() - self.editAuthor.setMaxLength(200) - self.editAuthor.setMaximumWidth(xW) - self.editAuthor.setText(pData.author) - self.mainForm.addRow( - self.tr("Author(s)"), - self.editAuthor, - self.tr("Change whenever you want!") + # Project Author + self.projAuthor = QLineEdit(self) + self.projAuthor.setMaxLength(200) + self.projAuthor.setMinimumWidth(xW) + self.projAuthor.setText(data.author) + self.addRow( + self.tr("Author(s)"), self.projAuthor, + self.tr("Only used when building the manuscript."), + stretch=(3, 2) ) + # Project Language self.projLang = QComboBox(self) - self.projLang.setMaximumWidth(xW) + self.projLang.setMinimumWidth(xW) for tag, language in CONFIG.listLanguages(CONFIG.LANG_PROJ): self.projLang.addItem(language, tag) - self.mainForm.addRow( - self.tr("Project language"), - self.projLang, - self.tr("Used when building the manuscript.") + self.addRow( + self.tr("Project language"), self.projLang, + self.tr("Only used when building the manuscript."), + stretch=(3, 2) ) + if (idx := self.projLang.findData(data.language)) != -1: + self.projLang.setCurrentIndex(idx) - langIdx = 0 - if pData.language is not None: - langIdx = self.projLang.findData(pData.language) - if langIdx == -1: - langIdx = self.projLang.findData("en_GB") - if langIdx != -1: - self.projLang.setCurrentIndex(langIdx) - + # Spell Check Language self.spellLang = QComboBox(self) - self.spellLang.setMaximumWidth(xW) + self.spellLang.setMinimumWidth(xW) self.spellLang.addItem(self.tr("Default"), "None") if CONFIG.hasEnchant: for tag, language in SHARED.spelling.listDictionaries(): self.spellLang.addItem(language, tag) - self.mainForm.addRow( - self.tr("Spell check language"), - self.spellLang, - self.tr("Overrides main preferences.") + self.addRow( + self.tr("Spell check language"), self.spellLang, + self.tr("Overrides main preferences."), + stretch=(3, 2) ) + if (idx := self.spellLang.findData(data.spellLang)) != -1: + self.spellLang.setCurrentIndex(idx) - langIdx = 0 - if pData.spellLang is not None: - langIdx = self.spellLang.findData(pData.spellLang) - if langIdx != -1: - self.spellLang.setCurrentIndex(langIdx) - + # Backup on Close self.doBackup = NSwitch(self) - self.doBackup.setChecked(not pData.doBackup) - self.mainForm.addRow( - self.tr("No backup on close"), - self.doBackup, + self.doBackup.setChecked(not data.doBackup) + self.addRow( + self.tr("Disable backup on close"), self.doBackup, self.tr("Overrides main preferences.") ) + self.finalise() + return -# END Class GuiProjectEditMain +# END Class _SettingsPage -class GuiProjectEditStatus(QWidget): +class _StatusPage(NFixedPage): COL_LABEL = 0 COL_USAGE = 1 @@ -297,56 +309,54 @@ class GuiProjectEditStatus(QWidget): super().__init__(parent=parent) if isStatus: - self.theStatus = SHARED.project.data.itemStatus - pageLabel = self.tr("Novel File Status Levels") + status = SHARED.project.data.itemStatus + pageLabel = self.tr("Novel Document Status Levels") colSetting = "statusColW" else: - self.theStatus = SHARED.project.data.itemImport - pageLabel = self.tr("Note File Importance Levels") + status = SHARED.project.data.itemImport + pageLabel = self.tr("Project Note Importance Levels") colSetting = "importColW" wCol0 = CONFIG.pxInt( SHARED.project.options.getInt("GuiProjectSettings", colSetting, 130) ) - self.colDeleted = [] - self.colChanged = False - self.selColour = QColor(100, 100, 100) + self._changed = False + self._colDeleted = [] + self._selColour = QColor(100, 100, 100) self.iPx = SHARED.theme.baseIconSize - # The List - # ======== + # Title + self.pageTitle = NColourLabel( + pageLabel, SHARED.theme.helpText, parent=self, + scale=NColourLabel.HEADER_SCALE + ) - self.listBox = QTreeWidget() - self.listBox.setHeaderLabels([ - self.tr("Label"), self.tr("Usage"), - ]) + # List Box + self.listBox = QTreeWidget(self) + self.listBox.setHeaderLabels([self.tr("Label"), self.tr("Usage")]) self.listBox.itemSelectionChanged.connect(self._selectedItem) self.listBox.setColumnWidth(self.COL_LABEL, wCol0) self.listBox.setIndentation(0) - for key, entry in self.theStatus.items(): + for key, entry in status.items(): self._addItem(key, entry["name"], entry["cols"], entry["count"]) # List Controls - # ============= - - self.addButton = QPushButton(SHARED.theme.getIcon("add"), "") + self.addButton = QPushButton(SHARED.theme.getIcon("add"), "", self) self.addButton.clicked.connect(self._newItem) - self.delButton = QPushButton(SHARED.theme.getIcon("remove"), "") + self.delButton = QPushButton(SHARED.theme.getIcon("remove"), "", self) self.delButton.clicked.connect(self._delItem) - self.upButton = QPushButton(SHARED.theme.getIcon("up"), "") + self.upButton = QPushButton(SHARED.theme.getIcon("up"), "", self) self.upButton.clicked.connect(lambda: self._moveItem(-1)) - self.dnButton = QPushButton(SHARED.theme.getIcon("down"), "") + self.dnButton = QPushButton(SHARED.theme.getIcon("down"), "", self) self.dnButton.clicked.connect(lambda: self._moveItem(1)) # Edit Form - # ========= - self.editName = QLineEdit() self.editName.setMaxLength(40) self.editName.setPlaceholderText(self.tr("Select item to edit")) @@ -364,8 +374,6 @@ class GuiProjectEditStatus(QWidget): self.saveButton.clicked.connect(self._saveItem) # Assemble - # ======== - self.listControls = QVBoxLayout() self.listControls.addWidget(self.addButton) self.listControls.addWidget(self.delButton) @@ -387,16 +395,25 @@ class GuiProjectEditStatus(QWidget): self.innerBox.addLayout(self.listControls) self.outerBox = QVBoxLayout() - self.outerBox.addWidget(QLabel("%s" % pageLabel)) + self.outerBox.addWidget(self.pageTitle) self.outerBox.addLayout(self.innerBox) - self.setLayout(self.outerBox) + self.setCentralLayout(self.outerBox) return + @property + def wasChanged(self) -> bool: + """The user changed these settings.""" + return self._changed + + ## + # Methods + ## + def getNewList(self) -> tuple[list, list]: """Return list of entries.""" - if self.colChanged: + if self._changed: newList = [] for n in range(self.listBox.topLevelItemCount()): item = self.listBox.topLevelItem(n) @@ -406,10 +423,13 @@ class GuiProjectEditStatus(QWidget): "name": item.text(self.COL_LABEL), "cols": item.data(self.COL_LABEL, self.COL_ROLE), }) - return newList, self.colDeleted - + return newList, self._colDeleted return [], [] + def columnWidth(self) -> int: + """Return the size of the header column.""" + return self.listBox.columnWidth(0) + ## # Private Slots ## @@ -417,12 +437,12 @@ class GuiProjectEditStatus(QWidget): @pyqtSlot() def _selectColour(self) -> None: """Open a dialog to select the status icon colour.""" - if self.selColour is not None: + if self._selColour is not None: newCol = QColorDialog.getColor( - self.selColour, self, self.tr("Select Colour") + self._selColour, self, self.tr("Select Colour") ) if newCol.isValid(): - self.selColour = newCol + self._selColour = newCol pixmap = QPixmap(self.iPx, self.iPx) pixmap.fill(newCol) self.colButton.setIcon(QIcon(pixmap)) @@ -433,7 +453,7 @@ class GuiProjectEditStatus(QWidget): def _newItem(self) -> None: """Create a new status item.""" self._addItem(None, self.tr("New Item"), (100, 100, 100), 0) - self.colChanged = True + self._changed = True return @pyqtSlot() @@ -446,8 +466,8 @@ class GuiProjectEditStatus(QWidget): SHARED.error(self.tr("Cannot delete a status item that is in use.")) else: self.listBox.takeTopLevelItem(iRow) - self.colDeleted.append(selItem.data(self.COL_LABEL, self.KEY_ROLE)) - self.colChanged = True + self._colDeleted.append(selItem.data(self.COL_LABEL, self.KEY_ROLE)) + self._changed = True return @pyqtSlot() @@ -458,9 +478,9 @@ class GuiProjectEditStatus(QWidget): selItem.setText(self.COL_LABEL, simplified(self.editName.text())) selItem.setIcon(self.COL_LABEL, self.colButton.icon()) selItem.setData(self.COL_LABEL, self.COL_ROLE, ( - self.selColour.red(), self.selColour.green(), self.selColour.blue() + self._selColour.red(), self._selColour.green(), self._selColour.blue() )) - self.colChanged = True + self._changed = True return @pyqtSlot() @@ -474,7 +494,7 @@ class GuiProjectEditStatus(QWidget): name = selItem.text(self.COL_LABEL) pixmap = QPixmap(self.iPx, self.iPx) pixmap.fill(QColor(*cols)) - self.selColour = QColor(*cols) + self._selColour = QColor(*cols) self.editName.setText(name) self.colButton.setIcon(QIcon(pixmap)) self.editName.selectAll() @@ -485,7 +505,7 @@ class GuiProjectEditStatus(QWidget): else: pixmap = QPixmap(self.iPx, self.iPx) pixmap.fill(QColor(100, 100, 100)) - self.selColour = QColor(100, 100, 100) + self._selColour = QColor(100, 100, 100) self.editName.setText("") self.colButton.setIcon(QIcon(pixmap)) self.editName.setEnabled(False) @@ -498,16 +518,16 @@ class GuiProjectEditStatus(QWidget): ## def _addItem(self, key: str | None, name: str, - cols: tuple[int, int, int], count: int) -> None: + colour: tuple[int, int, int], count: int) -> None: """Add a status item to the list.""" pixmap = QPixmap(self.iPx, self.iPx) - pixmap.fill(QColor(*cols)) + pixmap.fill(QColor(*colour)) item = QTreeWidgetItem() item.setText(self.COL_LABEL, name) item.setIcon(self.COL_LABEL, QIcon(pixmap)) item.setData(self.COL_LABEL, self.KEY_ROLE, key) - item.setData(self.COL_LABEL, self.COL_ROLE, cols) + item.setData(self.COL_LABEL, self.COL_ROLE, colour) item.setData(self.COL_LABEL, self.NUM_ROLE, count) item.setText(self.COL_USAGE, self._usageString(count)) @@ -533,30 +553,29 @@ class GuiProjectEditStatus(QWidget): if cItem is not None: cItem.setSelected(True) - self.colChanged = True + self._changed = True return def _getSelectedItem(self) -> QTreeWidgetItem | None: """Get the currently selected item.""" - selItem = self.listBox.selectedItems() - if len(selItem) > 0: - return selItem[0] + if items := self.listBox.selectedItems(): + return items[0] return None - def _usageString(self, nUse: int) -> str: + def _usageString(self, count: int) -> str: """Generate usage string.""" - if nUse == 0: + if count == 0: return self.tr("Not in use") - elif nUse == 1: + elif count == 1: return self.tr("Used once") else: - return self.tr("Used by {0} items").format(nUse) + return self.tr("Used by {0} items").format(count) -# END Class GuiProjectEditStatus +# END Class _StatusPage -class GuiProjectEditReplace(QWidget): +class _ReplacePage(NFixedPage): COL_KEY = 0 COL_REPL = 1 @@ -564,24 +583,24 @@ class GuiProjectEditReplace(QWidget): def __init__(self, parent: QWidget) -> None: super().__init__(parent=parent) - self.arChanged = False + self._changed = False wCol0 = CONFIG.pxInt( SHARED.project.options.getInt("GuiProjectSettings", "replaceColW", 130) ) - pageLabel = self.tr("Text Replace List for Preview and Export") + + # Title + self.pageTitle = NColourLabel( + self.tr("Text Auto-Replace for Preview and Build"), + SHARED.theme.helpText, parent=self, scale=NColourLabel.HEADER_SCALE + ) # List Box - # ======== - self.listBox = QTreeWidget() - self.listBox.setHeaderLabels([ - self.tr("Keyword"), - self.tr("Replace With"), - ]) - self.listBox.itemSelectionChanged.connect(self._selectedItem) + self.listBox.setHeaderLabels([self.tr("Keyword"), self.tr("Replace With")]) self.listBox.setColumnWidth(self.COL_KEY, wCol0) self.listBox.setIndentation(0) + self.listBox.itemSelectionChanged.connect(self._selectedItem) for aKey, aVal in SHARED.project.data.autoReplace.items(): newItem = QTreeWidgetItem(["<%s>" % aKey, aVal]) @@ -591,8 +610,6 @@ class GuiProjectEditReplace(QWidget): self.listBox.setSortingEnabled(True) # List Controls - # ============= - self.addButton = QPushButton(SHARED.theme.getIcon("add"), "") self.addButton.clicked.connect(self._addEntry) @@ -600,8 +617,6 @@ class GuiProjectEditReplace(QWidget): self.delButton.clicked.connect(self._delEntry) # Edit Form - # ========= - self.editKey = QLineEdit() self.editKey.setPlaceholderText(self.tr("Select item to edit")) self.editKey.setEnabled(False) @@ -615,8 +630,6 @@ class GuiProjectEditReplace(QWidget): self.saveButton.clicked.connect(self._saveEntry) # Assemble - # ======== - self.listControls = QVBoxLayout() self.listControls.addWidget(self.addButton) self.listControls.addWidget(self.delButton) @@ -636,50 +649,61 @@ class GuiProjectEditReplace(QWidget): self.innerBox.addLayout(self.listControls) self.outerBox = QVBoxLayout() - self.outerBox.addWidget(QLabel("%s" % pageLabel)) + self.outerBox.addWidget(self.pageTitle) self.outerBox.addLayout(self.innerBox) - self.setLayout(self.outerBox) + self.setCentralLayout(self.outerBox) return + @property + def wasChanged(self) -> bool: + """The user changed these settings.""" + return self._changed + + ## + # Methods + ## + def getNewList(self) -> dict: """Extract the list from the widget.""" new = {} for n in range(self.listBox.topLevelItemCount()): - tItem = self.listBox.topLevelItem(n) - if tItem is not None: + if tItem := self.listBox.topLevelItem(n): aKey = self._stripNotAllowed(tItem.text(0)) aVal = tItem.text(1) if len(aKey) > 0: new[aKey] = aVal return new + def columnWidth(self) -> int: + """Return the size of the header column.""" + return self.listBox.columnWidth(0) + ## - # Internal Functions + # Private Slots ## - def _selectedItem(self) -> bool: + @pyqtSlot() + def _selectedItem(self) -> None: """Extract the details from the selected item and populate the edit form. """ - selItem = self._getSelectedItem() - if selItem is None: - return False - editKey = self._stripNotAllowed(selItem.text(0)) - editVal = selItem.text(1) - self.editKey.setText(editKey) - self.editValue.setText(editVal) - self.editKey.setEnabled(True) - self.editValue.setEnabled(True) - self.editKey.selectAll() - self.editKey.setFocus() - return True + if selItem := self._getSelectedItem(): + editKey = self._stripNotAllowed(selItem.text(0)) + editVal = selItem.text(1) + self.editKey.setText(editKey) + self.editValue.setText(editVal) + self.editKey.setEnabled(True) + self.editValue.setEnabled(True) + self.editKey.selectAll() + self.editKey.setFocus() + return + @pyqtSlot() def _saveEntry(self) -> None: """Save the form data into the list widget.""" - selItem = self._getSelectedItem() - if selItem: + if selItem := self._getSelectedItem(): newKey = self.editKey.text() newVal = self.editValue.text() saveKey = self._stripNotAllowed(newKey) @@ -691,38 +715,36 @@ class GuiProjectEditReplace(QWidget): self.editKey.setEnabled(False) self.editValue.setEnabled(False) self.listBox.clearSelection() - self.arChanged = True + self._changed = True return + @pyqtSlot() def _addEntry(self) -> None: """Add a new list entry.""" saveKey = "" % (self.listBox.topLevelItemCount() + 1) - newVal = "" - newItem = QTreeWidgetItem([saveKey, newVal]) - self.listBox.addTopLevelItem(newItem) + self.listBox.addTopLevelItem(QTreeWidgetItem([saveKey, ""])) return + @pyqtSlot() def _delEntry(self) -> None: """Delete the selected entry.""" - selItem = self._getSelectedItem() - if selItem: + if selItem := self._getSelectedItem(): self.listBox.takeTopLevelItem(self.listBox.indexOfTopLevelItem(selItem)) - self.arChanged = True + self._changed = True return + ## + # Internal Functions + ## + def _getSelectedItem(self) -> QTreeWidgetItem | None: """Extract the currently selected item.""" - selItem = self.listBox.selectedItems() - if len(selItem) == 0: - return None - return selItem[0] + if items := self.listBox.selectedItems(): + return items[0] + return None def _stripNotAllowed(self, key: str) -> str: """Clean up the replace key string.""" - result = "" - for c in key: - if c.isalnum(): - result += c - return result + return "".join(c for c in key if c.isalnum()) -# END Class GuiProjectEditReplace +# END Class _ReplacePage diff --git a/novelwriter/extensions/configlayout.py b/novelwriter/extensions/configlayout.py index d714cf00..99d38c03 100644 --- a/novelwriter/extensions/configlayout.py +++ b/novelwriter/extensions/configlayout.py @@ -3,9 +3,11 @@ novelWriter – Custom Widget: Config Layout ========================================== File History: -Created: 2020-05-03 [0.4.5] NConfigLayout, NHelpLabel +Created: 2020-05-03 [0.4.5] NConfigLayout, NColourLabel Created: 2023-05-23 [2.1b1] NSimpleLayout Created: 2024-01-08 [2.3b1] NScrollableForm +Created: 2024-01-26 [2.3b1] NScrollablePage +Created: 2024-01-26 [2.3b1] NFixedPage This file is a part of novelWriter Copyright 2018–2024, Veronica Berglyd Olsen @@ -28,27 +30,74 @@ from __future__ import annotations from PyQt5.QtGui import QColor, QPalette from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( - QAbstractButton, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QScrollArea, QSizePolicy, - QVBoxLayout, QWidget + QAbstractButton, QFrame, QGridLayout, QHBoxLayout, QLabel, QLayout, + QScrollArea, QSizePolicy, QVBoxLayout, QWidget ) from novelwriter import CONFIG -FONT_SCALE = 0.9 +DEFAULT_SCALE = 0.9 RIGHT_TOP = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop LEFT_TOP = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop +class NFixedPage(QFrame): + """Extension: Fixed Page Widget + + A custom widget that holds a layout. This is just a wrapper around a + QFrame that sets the same frame style as the other Page widgets. + """ + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + self.setFrameShadow(QFrame.Shadow.Sunken) + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setCentralLayout = self.setLayout + return + +# END Class NFixedPage + + +class NScrollablePage(QScrollArea): + """Extension: Scrollable Page Widget + + A custom widget that holds a layout within a scrollable area. + """ + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + self._widget = QWidget(self) + self.setWidget(self._widget) + self.setWidgetResizable(True) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setFrameShadow(QFrame.Shadow.Sunken) + self.setFrameShape(QFrame.Shape.StyledPanel) + return + + def setCentralLayout(self, layout: QLayout) -> None: + """Set the central layout of the scroll page.""" + self._widget.setLayout(layout) + return + +# END Class NScrollablePage + + class NScrollableForm(QScrollArea): + """Extension: Scrollable Form Widget + + A custom widget that creates a form within a scrollable area. + """ def __init__(self, parent: QWidget) -> None: super().__init__(parent=parent) self._helpCol = QColor(0, 0, 0) - self._fontScale = FONT_SCALE + self._fontScale = DEFAULT_SCALE self._first = True + self._indent = CONFIG.pxInt(12) self._sections: dict[int, QLabel] = {} - self._editable: dict[str, NHelpLabel] = {} + self._editable: dict[str, NColourLabel] = {} self._index: dict[str, QWidget] = {} self._layout = QVBoxLayout() @@ -61,6 +110,8 @@ class NScrollableForm(QScrollArea): self.setWidgetResizable(True) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setFrameShadow(QFrame.Shadow.Sunken) + self.setFrameShape(QFrame.Shape.StyledPanel) return @@ -76,9 +127,9 @@ class NScrollableForm(QScrollArea): # Setters ## - def setHelpTextStyle(self, color: QColor | list | tuple, scale: float = FONT_SCALE) -> None: + def setHelpTextStyle(self, color: QColor, scale: float = DEFAULT_SCALE) -> None: """Set the text color for the help text.""" - self._helpCol = color if isinstance(color, QColor) else QColor(*color) + self._helpCol = color self._fontScale = scale return @@ -88,6 +139,11 @@ class NScrollableForm(QScrollArea): qHelp.setText(text) return + def setRowIndent(self, indent: int) -> None: + """Set the indentation of each row.""" + self._indent = max(indent, 0) + return + ## # Methods ## @@ -119,37 +175,43 @@ class NScrollableForm(QScrollArea): return def addRow(self, label: str, widget: QWidget, helpText: str = "", unit: str | None = None, - button: QWidget | None = None, editable: str | None = None) -> None: + button: QWidget | None = None, editable: str | None = None, + stretch: tuple[int, int] = (0, 0)) -> None: """Add a label and a widget as a new row of the form.""" row = QHBoxLayout() - row.setSpacing(CONFIG.pxInt(4)) + row.setSpacing(CONFIG.pxInt(12)) - mPx = CONFIG.pxInt(12) qLabel = QLabel(label, self) - qLabel.setIndent(mPx) + qLabel.setIndent(self._indent) qLabel.setBuddy(widget) if helpText: - qHelp = NHelpLabel(str(helpText), self._helpCol, self._fontScale) - qHelp.setIndent(mPx) + qHelp = NColourLabel( + str(helpText), self._helpCol, parent=self, + scale=self._fontScale, wrap=True, indent=self._indent + ) labelBox = QVBoxLayout() labelBox.addWidget(qLabel) labelBox.addWidget(qHelp) labelBox.setSpacing(0) - labelBox.addStretch(1) - row.addLayout(labelBox) + row.addLayout(labelBox, stretch[0]) if editable: self._editable[editable] = qHelp else: - row.addWidget(qLabel) - - row.addSpacing(mPx) - row.addWidget(widget) + row.addWidget(qLabel, stretch[0]) if isinstance(unit, str): - row.addWidget(QLabel(unit, self)) + box = QHBoxLayout() + box.addWidget(widget) + box.addWidget(QLabel(unit, self)) + row.addLayout(box, stretch[1]) elif isinstance(button, QAbstractButton): - row.addWidget(button) + box = QHBoxLayout() + box.addWidget(widget) + box.addWidget(button) + row.addLayout(box, stretch[1]) + else: + row.addWidget(widget, stretch[1]) self._layout.addLayout(row) self._index[label.strip()] = widget @@ -173,7 +235,7 @@ class NConfigLayout(QGridLayout): self._nextRow = 0 self._helpCol = QColor(0, 0, 0) - self._fontScale = FONT_SCALE + self._fontScale = DEFAULT_SCALE self._itemMap = {} wSp = CONFIG.pxInt(8) @@ -183,24 +245,6 @@ class NConfigLayout(QGridLayout): return - ## - # Getters and Setters - ## - - def setHelpTextStyle(self, color: QColor | list | tuple, scale: float = FONT_SCALE) -> None: - """Set the text color for the help text.""" - self._helpCol = color if isinstance(color, QColor) else QColor(*color) - self._fontScale = scale - return - - def setHelpText(self, row: int, text: str) -> None: - """Set the text for the help label.""" - if row in self._itemMap: - qHelp = self._itemMap[row][1] - if isinstance(qHelp, NHelpLabel): - qHelp.setText(text) - return - ## # Class Methods ## @@ -216,8 +260,8 @@ class NConfigLayout(QGridLayout): self._nextRow += 1 return - def addRow(self, label: str, widget: QWidget, helpText: str | None = None, - unit: str | None = None, button: QWidget | None = None) -> int: + def addRow(self, label: str, widget: QWidget, unit: str | None = None, + button: QWidget | None = None) -> int: """Add a label and a widget as a new row of the grid.""" wSp = CONFIG.pxInt(8) qLabel = QLabel(label) @@ -225,17 +269,7 @@ class NConfigLayout(QGridLayout): qLabel.setBuddy(widget) qHelp = None - if helpText is not None: - qHelp = NHelpLabel(str(helpText), self._helpCol, self._fontScale) - qHelp.setIndent(wSp) - labelBox = QVBoxLayout() - labelBox.addWidget(qLabel) - labelBox.addWidget(qHelp) - labelBox.setSpacing(0) - labelBox.addStretch(1) - self.addLayout(labelBox, self._nextRow, 0, 1, 1, LEFT_TOP) - else: - self.addWidget(qLabel, self._nextRow, 0, 1, 1, LEFT_TOP) + self.addWidget(qLabel, self._nextRow, 0, 1, 1, LEFT_TOP) if isinstance(unit, str): controlBox = QHBoxLayout() @@ -252,12 +286,7 @@ class NConfigLayout(QGridLayout): self.addLayout(controlBox, self._nextRow, 1, 1, 1, RIGHT_TOP) else: - if isinstance(widget, QLineEdit): - qLayout = QHBoxLayout() - qLayout.addWidget(widget) - self.addLayout(qLayout, self._nextRow, 1, 1, 1, RIGHT_TOP) - else: - self.addWidget(widget, self._nextRow, 1, 1, 1, RIGHT_TOP) + self.addWidget(widget, self._nextRow, 1, 1, 1, RIGHT_TOP) self.setRowStretch(self._nextRow, 0) self.setRowStretch(self._nextRow+1, 1) @@ -306,17 +335,9 @@ class NSimpleLayout(QGridLayout): wSp = CONFIG.pxInt(8) qLabel = QLabel(label) qLabel.setIndent(wSp) - self.addWidget(qLabel, self._nextRow, 0, 1, 1, LEFT_TOP) - - if isinstance(widget, QLineEdit): - qLayout = QHBoxLayout() - qLayout.addWidget(widget) - self.addLayout(qLayout, self._nextRow, 1, 1, 1, RIGHT_TOP) - else: - self.addWidget(widget, self._nextRow, 1, 1, 1, RIGHT_TOP) - qLabel.setBuddy(widget) - + self.addWidget(qLabel, self._nextRow, 0, 1, 1, LEFT_TOP) + self.addWidget(widget, self._nextRow, 1, 1, 1, RIGHT_TOP) self.setRowStretch(self._nextRow, 0) self.setRowStretch(self._nextRow+1, 1) self._nextRow += 1 @@ -326,25 +347,33 @@ class NSimpleLayout(QGridLayout): # END Class NSimpleLayout -class NHelpLabel(QLabel): +class NColourLabel(QLabel): + """Extension: A Coloured Label - def __init__(self, text: str, color: QColor | list | tuple, - fontSize: float = FONT_SCALE) -> None: - super().__init__(text) + A custom widget that draws a label in a specific colour, and + optionally at a specific size, and word wrapped. + """ - qCol = color if isinstance(color, QColor) else QColor(*color) + HELP_SCALE = DEFAULT_SCALE + HEADER_SCALE = 1.25 - lblCol = self.palette() - lblCol.setColor(QPalette.WindowText, qCol) - self.setPalette(lblCol) + def __init__(self, text: str, color: QColor, parent: QWidget | None = None, + scale: float = HELP_SCALE, wrap: bool = False, indent: int = 0) -> None: + super().__init__(text, parent=parent) - lblFont = self.font() - lblFont.setPointSizeF(fontSize*lblFont.pointSizeF()) - self.setFont(lblFont) + font = self.font() + font.setPointSizeF(scale*font.pointSizeF()) + colour = self.palette() + colour.setColor(QPalette.WindowText, color) - self.setWordWrap(True) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setPalette(colour) + self.setFont(font) + self.setIndent(indent) + + if wrap: + self.setWordWrap(True) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) return -# END Class NHelpLabel +# END Class NColourLabel diff --git a/novelwriter/extensions/novelselector.py b/novelwriter/extensions/novelselector.py index 035ab570..44e5bf23 100644 --- a/novelwriter/extensions/novelselector.py +++ b/novelwriter/extensions/novelselector.py @@ -3,7 +3,7 @@ novelWriter – Custom Widget: Novel Selector =========================================== File History: -Created: 2022-11-17 [2.0] +Created: 2022-11-17 [2.0] NovelSelector This file is a part of novelWriter Copyright 2018–2024, Veronica Berglyd Olsen @@ -43,6 +43,8 @@ class NovelSelector(QComboBox): super().__init__(parent=parent) self._blockSignal = False self._firstHandle = None + self._includeAll = False + self._listFormat = None self.currentIndexChanged.connect(self._indexChanged) return @@ -64,17 +66,29 @@ class NovelSelector(QComboBox): def setHandle(self, tHandle: str | None, blockSignal: bool = True) -> None: """Set the currently selected handle.""" - self._blockSignal = blockSignal - if tHandle is None: - index = self.count() - 1 - else: - index = self.findData(tHandle) - if index >= 0: + if (index := self.findData(tHandle) if tHandle else (self.count() - 1)) >= 0: + self._blockSignal = blockSignal self.setCurrentIndex(index) - self._blockSignal = False + self._blockSignal = False return - def updateList(self, includeAll: bool = False, prefix: str | None = None) -> None: + def setIncludeAll(self, value: bool) -> None: + """Set flag to add an "All Novel Folders" option.""" + self._includeAll = value + return + + def setListFormat(self, value: str | None) -> None: + """Set a format string for the list entries.""" + if value is None or "{0}" in value: + self._listFormat = value + return + + ## + # Public Slots + ## + + @pyqtSlot() + def refreshNovelList(self) -> None: """Rebuild the list of novel items.""" self._blockSignal = True self._firstHandle = None @@ -83,8 +97,8 @@ class NovelSelector(QComboBox): icon = SHARED.theme.getIcon(nwLabels.CLASS_ICON[nwItemClass.NOVEL]) handle = self.currentData() for tHandle, nwItem in SHARED.project.tree.iterRoots(nwItemClass.NOVEL): - if prefix: - name = prefix.format(nwItem.itemName) + if self._listFormat: + name = self._listFormat.format(nwItem.itemName) self.addItem(name, tHandle) else: name = nwItem.itemName @@ -92,7 +106,7 @@ class NovelSelector(QComboBox): if self._firstHandle is None: self._firstHandle = tHandle - if includeAll: + if self._includeAll: self.insertSeparator(self.count()) self.addItem(icon, self.tr("All Novel Folders"), "") diff --git a/novelwriter/extensions/pageddialog.py b/novelwriter/extensions/pageddialog.py deleted file mode 100644 index a620cb74..00000000 --- a/novelwriter/extensions/pageddialog.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -novelWriter – Custom Widget: Paged Dialog -========================================= - -File History: -Created: 2020-05-17 [0.5.1] - -This file is a part of novelWriter -Copyright 2018–2024, Veronica Berglyd Olsen - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, but -WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" -from __future__ import annotations - -from PyQt5.QtGui import QPaintEvent -from PyQt5.QtCore import QRect, QPoint, QSize -from PyQt5.QtWidgets import ( - QDialog, QHBoxLayout, QStyle, QStyleOptionTab, QStylePainter, QTabBar, - QTabWidget, QVBoxLayout, QWidget -) - -from novelwriter import CONFIG - - -class NPagedDialog(QDialog): - - def __init__(self, parent: QWidget) -> None: - super().__init__(parent=parent) - - self._tabBar = NVerticalTabBar(self) - self._tabBar.setExpanding(False) - - self._tabBox = QTabWidget(self) - self._tabBox.setTabBar(self._tabBar) - self._tabBox.setTabPosition(QTabWidget.West) - - self._buttonBox = QHBoxLayout() - - self._outerBox = QVBoxLayout() - self._outerBox.addWidget(self._tabBox) - self._outerBox.addLayout(self._buttonBox) - - # Default Margins - thisStyle = self.style() - mL = thisStyle.pixelMetric(QStyle.PM_LayoutLeftMargin) - mR = thisStyle.pixelMetric(QStyle.PM_LayoutRightMargin) - mT = thisStyle.pixelMetric(QStyle.PM_LayoutLeftMargin) - mB = thisStyle.pixelMetric(QStyle.PM_LayoutBottomMargin) - - # Set Margins - self.setContentsMargins(0, 0, 0, 0) - self._outerBox.setContentsMargins(0, 0, 0, mB) - self._buttonBox.setContentsMargins(mL, 0, mR, 0) - self._outerBox.setSpacing(mT) - - self.setLayout(self._outerBox) - - return - - def addTab(self, widget: QWidget, label: str) -> None: - """Forward the adding of tabs to the QTabWidget.""" - self._tabBox.addTab(widget, label) - return - - def addControls(self, buttonBar: QWidget) -> None: - """Add a button bar to the dialog.""" - self._buttonBox.addWidget(buttonBar) - return - - def setCurrentWidget(self, widget: QWidget) -> None: - """Forward the changing of tab to the QTabWidget.""" - self._tabBox.setCurrentWidget(widget) - return - -# END Class NPagedDialog - - -class NVerticalTabBar(QTabBar): - - def __init__(self, parent: QWidget) -> None: - super().__init__(parent=parent) - self._mW = CONFIG.pxInt(150) - return - - def tabSizeHint(self, index: int) -> QSize: - """Return a transposed size hint for the rotated bar.""" - tSize = super().tabSizeHint(index) - tSize.transpose() - tSize.setWidth(min(tSize.width(), self._mW)) - return tSize - - def paintEvent(self, event: QPaintEvent) -> None: - """Custom implementation of the label painter that rotates the - label 90 degrees. - """ - pObj = QStylePainter(self) - oObj = QStyleOptionTab() - - for i in range(self.count()): - self.initStyleOption(oObj, i) - pObj.drawControl(QStyle.CE_TabBarTabShape, oObj) - pObj.save() - - oSize = oObj.rect.size() - oSize.transpose() - oRect = QRect(QPoint(), oSize) - oRect.moveCenter(oObj.rect.center()) - oObj.rect = oRect - - oCenter = self.tabRect(i).center() - pObj.translate(oCenter) - pObj.rotate(90) - pObj.translate(-oCenter) - pObj.drawControl(QStyle.CE_TabBarTabLabel, oObj) - pObj.restore() - - return - -# END Class NVerticalTabBar diff --git a/novelwriter/extensions/pagedsidebar.py b/novelwriter/extensions/pagedsidebar.py index 2f417b44..082e8a2a 100644 --- a/novelwriter/extensions/pagedsidebar.py +++ b/novelwriter/extensions/pagedsidebar.py @@ -45,10 +45,9 @@ class NPagedSideBar(QToolBar): def __init__(self, parent: QWidget) -> None: super().__init__(parent=parent) - self._buttons = [] - self._actions = [] self._labelCol = None self._spacerHeight = self.fontMetrics().height() // 2 + self._buttons: dict[int, _NPagedToolButton] = {} self._group = QButtonGroup(self) self._group.setExclusive(True) @@ -67,9 +66,9 @@ class NPagedSideBar(QToolBar): """Return a specific button.""" return self._buttons[buttonId] - def setLabelColor(self, color: list | QColor) -> None: + def setLabelColor(self, color: QColor) -> None: """Set the text color for the labels.""" - self._labelCol = color if isinstance(color, QColor) else QColor(*color) + self._labelCol = color return def addSeparator(self) -> None: @@ -95,8 +94,7 @@ class NPagedSideBar(QToolBar): action = self.insertWidget(self._stretchAction, button) self._group.addButton(button, id=buttonId) - self._buttons.append(button) - self._actions.append(action) + self._buttons[buttonId] = button return action diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index c7554591..6baddf67 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -276,14 +276,14 @@ class GuiDocEditor(QPlainTextEdit): def updateSyntaxColours(self) -> None: """Update the syntax highlighting theme.""" mainPalette = self.palette() - mainPalette.setColor(QPalette.ColorRole.Window, QColor(*SHARED.theme.colBack)) - mainPalette.setColor(QPalette.ColorRole.Base, QColor(*SHARED.theme.colBack)) - mainPalette.setColor(QPalette.ColorRole.Text, QColor(*SHARED.theme.colText)) + mainPalette.setColor(QPalette.ColorRole.Window, SHARED.theme.colBack) + mainPalette.setColor(QPalette.ColorRole.Base, SHARED.theme.colBack) + mainPalette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText) self.setPalette(mainPalette) docPalette = self.viewport().palette() - docPalette.setColor(QPalette.ColorRole.Base, QColor(*SHARED.theme.colBack)) - docPalette.setColor(QPalette.ColorRole.Text, QColor(*SHARED.theme.colText)) + docPalette.setColor(QPalette.ColorRole.Base, SHARED.theme.colBack) + docPalette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText) self.viewport().setPalette(docPalette) self.docHeader.matchColours() @@ -2326,9 +2326,9 @@ class GuiDocToolBar(QWidget): def updateTheme(self) -> None: """Initialise GUI elements that depend on specific settings.""" palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor(*SHARED.theme.colBack)) - palette.setColor(QPalette.ColorRole.WindowText, QColor(*SHARED.theme.colText)) - palette.setColor(QPalette.ColorRole.Text, QColor(*SHARED.theme.colText)) + palette.setColor(QPalette.ColorRole.Window, SHARED.theme.colBack) + palette.setColor(QPalette.ColorRole.WindowText, SHARED.theme.colText) + palette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText) self.setPalette(palette) self.tbBoldMD.setIcon(SHARED.theme.getIcon("fmt_bold-md")) @@ -2866,10 +2866,11 @@ class GuiDocEditHeader(QWidget): self.minmaxButton.setIcon(SHARED.theme.getIcon("maximise")) self.closeButton.setIcon(SHARED.theme.getIcon("close")) + colText = SHARED.theme.colText buttonStyle = ( "QToolButton {{border: none; background: transparent;}} " - "QToolButton:hover {{border: none; background: rgba({0},{1},{2},0.2);}}" - ).format(*SHARED.theme.colText) + "QToolButton:hover {{border: none; background: rgba({0}, {1}, {2}, 0.2);}}" + ).format(colText.red(), colText.green(), colText.blue()) self.tbButton.setStyleSheet(buttonStyle) self.searchButton.setStyleSheet(buttonStyle) @@ -2885,9 +2886,9 @@ class GuiDocEditHeader(QWidget): theme rather than the main GUI. """ palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor(*SHARED.theme.colBack)) - palette.setColor(QPalette.ColorRole.WindowText, QColor(*SHARED.theme.colText)) - palette.setColor(QPalette.ColorRole.Text, QColor(*SHARED.theme.colText)) + palette.setColor(QPalette.ColorRole.Window, SHARED.theme.colBack) + palette.setColor(QPalette.ColorRole.WindowText, SHARED.theme.colText) + palette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText) self.setPalette(palette) self.itemTitle.setPalette(palette) @@ -3093,9 +3094,9 @@ class GuiDocEditFooter(QWidget): theme rather than the main GUI. """ palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor(*SHARED.theme.colBack)) - palette.setColor(QPalette.ColorRole.WindowText, QColor(*SHARED.theme.colText)) - palette.setColor(QPalette.ColorRole.Text, QColor(*SHARED.theme.colText)) + palette.setColor(QPalette.ColorRole.Window, SHARED.theme.colBack) + palette.setColor(QPalette.ColorRole.WindowText, SHARED.theme.colText) + palette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText) self.setPalette(palette) self.statusText.setPalette(palette) diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index 5dcece0f..3b4dc684 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -48,6 +48,8 @@ SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) class GuiDocHighlighter(QSyntaxHighlighter): + __slots__ = ("_tItem", "_tHandle", "_spellCheck", "_spellErr", "_hRules", "_hStyles") + BLOCK_NONE = 0 BLOCK_TEXT = 1 BLOCK_META = 2 @@ -58,29 +60,14 @@ class GuiDocHighlighter(QSyntaxHighlighter): logger.debug("Create: GuiDocHighlighter") - self._tItem = None - self._tHandle = None + self._tItem = None + self._tHandle = None self._spellCheck = False + self._spellErr = QTextCharFormat() self._hRules: list[tuple[str, dict]] = [] self._hStyles: dict[str, QTextCharFormat] = {} - self._colHead = QColor(0, 0, 0) - self._colHeadH = QColor(0, 0, 0) - self._colEmph = QColor(0, 0, 0) - self._colDialN = QColor(0, 0, 0) - self._colDialD = QColor(0, 0, 0) - self._colDialS = QColor(0, 0, 0) - self._colHidden = QColor(0, 0, 0) - self._colCode = QColor(0, 0, 0) - self._colKey = QColor(0, 0, 0) - self._colVal = QColor(0, 0, 0) - self._colSpell = QColor(0, 0, 0) - self._colError = QColor(0, 0, 0) - self._colRepTag = QColor(0, 0, 0) - self._colMod = QColor(0, 0, 0) - self._colBreak = QColor(0, 0, 0) - self.initHighlighter() logger.debug("Ready: GuiDocHighlighter") @@ -93,54 +80,42 @@ class GuiDocHighlighter(QSyntaxHighlighter): """ logger.debug("Setting up highlighting rules") - self._colHead = QColor(*SHARED.theme.colHead) - self._colHeadH = QColor(*SHARED.theme.colHeadH) - self._colDialN = QColor(*SHARED.theme.colDialN) - self._colDialD = QColor(*SHARED.theme.colDialD) - self._colDialS = QColor(*SHARED.theme.colDialS) - self._colHidden = QColor(*SHARED.theme.colHidden) - self._colCode = QColor(*SHARED.theme.colCode) - self._colKey = QColor(*SHARED.theme.colKey) - self._colVal = QColor(*SHARED.theme.colVal) - self._colSpell = QColor(*SHARED.theme.colSpell) - self._colError = QColor(*SHARED.theme.colError) - self._colRepTag = QColor(*SHARED.theme.colRepTag) - self._colMod = QColor(*SHARED.theme.colMod) - self._colBreak = QColor(*SHARED.theme.colEmph) - self._colBreak.setAlpha(64) - - self._colEmph = None - if CONFIG.highlightEmph: - self._colEmph = QColor(*SHARED.theme.colEmph) + colEmph = SHARED.theme.colEmph if CONFIG.highlightEmph else None + colBreak = QColor(SHARED.theme.colEmph) + colBreak.setAlpha(64) + self._hRules = [] self._hStyles = { - "header1": self._makeFormat(self._colHead, "bold", 1.8), - "header2": self._makeFormat(self._colHead, "bold", 1.6), - "header3": self._makeFormat(self._colHead, "bold", 1.4), - "header4": self._makeFormat(self._colHead, "bold", 1.2), - "header1h": self._makeFormat(self._colHeadH, "bold", 1.8), - "header2h": self._makeFormat(self._colHeadH, "bold", 1.6), - "header3h": self._makeFormat(self._colHeadH, "bold", 1.4), - "header4h": self._makeFormat(self._colHeadH, "bold", 1.2), - "bold": self._makeFormat(self._colEmph, "bold"), - "italic": self._makeFormat(self._colEmph, "italic"), - "strike": self._makeFormat(self._colHidden, "strike"), - "mspaces": self._makeFormat(self._colError, "errline"), - "nobreak": self._makeFormat(self._colBreak, "background"), - "dialogue1": self._makeFormat(self._colDialN), - "dialogue2": self._makeFormat(self._colDialD), - "dialogue3": self._makeFormat(self._colDialS), - "replace": self._makeFormat(self._colRepTag), - "hidden": self._makeFormat(self._colHidden), - "code": self._makeFormat(self._colCode), - "keyword": self._makeFormat(self._colKey), - "modifier": self._makeFormat(self._colMod), - "value": self._makeFormat(self._colVal, "underline"), - "codevalue": self._makeFormat(self._colVal), + "header1": self._makeFormat(SHARED.theme.colHead, "bold", 1.8), + "header2": self._makeFormat(SHARED.theme.colHead, "bold", 1.6), + "header3": self._makeFormat(SHARED.theme.colHead, "bold", 1.4), + "header4": self._makeFormat(SHARED.theme.colHead, "bold", 1.2), + "header1h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.8), + "header2h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.6), + "header3h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.4), + "header4h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.2), + "bold": self._makeFormat(colEmph, "bold"), + "italic": self._makeFormat(colEmph, "italic"), + "strike": self._makeFormat(SHARED.theme.colHidden, "strike"), + "mspaces": self._makeFormat(SHARED.theme.colError, "errline"), + "nobreak": self._makeFormat(colBreak, "background"), + "dialogue1": self._makeFormat(SHARED.theme.colDialN), + "dialogue2": self._makeFormat(SHARED.theme.colDialD), + "dialogue3": self._makeFormat(SHARED.theme.colDialS), + "replace": self._makeFormat(SHARED.theme.colRepTag), + "hidden": self._makeFormat(SHARED.theme.colHidden), + "code": self._makeFormat(SHARED.theme.colCode), + "keyword": self._makeFormat(SHARED.theme.colKey), + "modifier": self._makeFormat(SHARED.theme.colMod), + "value": self._makeFormat(SHARED.theme.colVal, "underline"), + "codevalue": self._makeFormat(SHARED.theme.colVal), "codeinval": self._makeFormat(None, "errline"), } - self._hRules = [] + # Cache Spell Error Format + self._spellErr = QTextCharFormat() + self._spellErr.setUnderlineColor(SHARED.theme.colSpell) + self._spellErr.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) # Multiple or Trailing Spaces if CONFIG.showMultiSpaces: @@ -305,12 +280,12 @@ class GuiDocHighlighter(QSyntaxHighlighter): self.setCurrentBlockState(self.BLOCK_META) if self._tItem: pIndex = SHARED.project.index - isValid, theBits, thePos = pIndex.scanThis(text) - isGood = pIndex.checkThese(theBits, self._tItem) + isValid, bits, pos = pIndex.scanThis(text) + isGood = pIndex.checkThese(bits, self._tItem) if isValid: - for n, theBit in enumerate(theBits): - xPos = thePos[n] - xLen = len(theBit) + for n, bit in enumerate(bits): + xPos = pos[n] + xLen = len(bit) if isGood[n]: if n == 0: self.setFormat(xPos, xLen, self._hStyles["keyword"]) @@ -318,8 +293,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): self.setFormat(xPos, xLen, self._hStyles["value"]) else: kwFmt = self.format(xPos) - kwFmt.setUnderlineColor(self._colError) - kwFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) + kwFmt.merge(self._hStyles["codeinval"]) self.setFormat(xPos, xLen, kwFmt) # We never want to run the spell checker on keyword/values, @@ -402,8 +376,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): for xPos, xLen in data.spellCheck(text): for x in range(xPos, xPos+xLen): spFmt = self.format(x) - spFmt.setUnderlineColor(self._colSpell) - spFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) + spFmt.merge(self._spellErr) self.setFormat(x, 1, spFmt) return @@ -431,7 +404,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): if "strike" in styles: charFormat.setFontStrikeOut(True) if "errline" in styles: - charFormat.setUnderlineColor(self._colError) + charFormat.setUnderlineColor(SHARED.theme.colError) charFormat.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) if "underline" in styles: charFormat.setFontUnderline(True) diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 5a066ccc..3a567668 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -33,7 +33,7 @@ from typing import TYPE_CHECKING from PyQt5.QtCore import pyqtSignal, pyqtSlot, QPoint, QSize, Qt, QUrl from PyQt5.QtGui import ( - QColor, QCursor, QFont, QMouseEvent, QPalette, QResizeEvent, QTextCursor, + QCursor, QFont, QMouseEvent, QPalette, QResizeEvent, QTextCursor, QTextOption ) from PyQt5.QtWidgets import ( @@ -149,14 +149,14 @@ class GuiDocViewer(QTextBrowser): # Set the widget colours to match syntax theme mainPalette = self.palette() - mainPalette.setColor(QPalette.ColorRole.Window, QColor(*SHARED.theme.colBack)) - mainPalette.setColor(QPalette.ColorRole.Base, QColor(*SHARED.theme.colBack)) - mainPalette.setColor(QPalette.ColorRole.Text, QColor(*SHARED.theme.colText)) + mainPalette.setColor(QPalette.ColorRole.Window, SHARED.theme.colBack) + mainPalette.setColor(QPalette.ColorRole.Base, SHARED.theme.colBack) + mainPalette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText) self.setPalette(mainPalette) docPalette = self.viewport().palette() - docPalette.setColor(QPalette.ColorRole.Base, QColor(*SHARED.theme.colBack)) - docPalette.setColor(QPalette.ColorRole.Text, QColor(*SHARED.theme.colText)) + docPalette.setColor(QPalette.ColorRole.Base, SHARED.theme.colBack) + docPalette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText) self.viewport().setPalette(docPalette) self.docHeader.matchColours() @@ -463,7 +463,13 @@ class GuiDocViewer(QTextBrowser): """Generate an appropriate style sheet for the document viewer, based on the current syntax highlighter theme, """ - pTheme = SHARED.theme + colText = SHARED.theme.colText + colHead = SHARED.theme.colHead + colVal = SHARED.theme.colVal + colEmph = SHARED.theme.colEmph + colKey = SHARED.theme.colKey + colHidden = SHARED.theme.colHidden + colMod = SHARED.theme.colMod styleSheet = ( "body {{" " color: rgb({tColR}, {tColG}, {tColB});" @@ -490,27 +496,27 @@ class GuiDocViewer(QTextBrowser): " text-align: center;" "}}\n" ).format( - tColR=pTheme.colText[0], - tColG=pTheme.colText[1], - tColB=pTheme.colText[2], - hColR=pTheme.colHead[0], - hColG=pTheme.colHead[1], - hColB=pTheme.colHead[2], - aColR=pTheme.colVal[0], - aColG=pTheme.colVal[1], - aColB=pTheme.colVal[2], - eColR=pTheme.colEmph[0], - eColG=pTheme.colEmph[1], - eColB=pTheme.colEmph[2], - kColR=pTheme.colKey[0], - kColG=pTheme.colKey[1], - kColB=pTheme.colKey[2], - cColR=pTheme.colHidden[0], - cColG=pTheme.colHidden[1], - cColB=pTheme.colHidden[2], - mColR=pTheme.colMod[0], - mColG=pTheme.colMod[1], - mColB=pTheme.colMod[2], + tColR=colText.red(), + tColG=colText.green(), + tColB=colText.blue(), + hColR=colHead.red(), + hColG=colHead.green(), + hColB=colHead.blue(), + aColR=colVal.red(), + aColG=colVal.green(), + aColB=colVal.blue(), + eColR=colEmph.red(), + eColG=colEmph.green(), + eColB=colEmph.blue(), + kColR=colKey.red(), + kColG=colKey.green(), + kColB=colKey.blue(), + cColR=colHidden.red(), + cColG=colHidden.green(), + cColB=colHidden.blue(), + mColR=colMod.red(), + mColG=colMod.green(), + mColB=colMod.blue(), ) self.document().setDefaultStyleSheet(styleSheet) @@ -744,10 +750,11 @@ class GuiDocViewHeader(QWidget): self.refreshButton.setIcon(SHARED.theme.getIcon("refresh")) self.closeButton.setIcon(SHARED.theme.getIcon("close")) + colText = SHARED.theme.colText buttonStyle = ( "QToolButton {{border: none; background: transparent;}} " - "QToolButton:hover {{border: none; background: rgba({0},{1},{2},0.2);}}" - ).format(*SHARED.theme.colText) + "QToolButton:hover {{border: none; background: rgba({0}, {1}, {2}, 0.2);}}" + ).format(colText.red(), colText.green(), colText.blue()) self.backButton.setStyleSheet(buttonStyle) self.forwardButton.setStyleSheet(buttonStyle) @@ -763,9 +770,9 @@ class GuiDocViewHeader(QWidget): theme rather than the main GUI. """ palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor(*SHARED.theme.colBack)) - palette.setColor(QPalette.ColorRole.WindowText, QColor(*SHARED.theme.colText)) - palette.setColor(QPalette.ColorRole.Text, QColor(*SHARED.theme.colText)) + palette.setColor(QPalette.ColorRole.Window, SHARED.theme.colBack) + palette.setColor(QPalette.ColorRole.WindowText, SHARED.theme.colText) + palette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText) self.setPalette(palette) self.docTitle.setPalette(palette) return @@ -937,11 +944,11 @@ class GuiDocViewFooter(QWidget): self.showComments.setIcon(bulletIcon) self.showSynopsis.setIcon(bulletIcon) - # StyleSheets + colText = SHARED.theme.colText buttonStyle = ( "QToolButton {{border: none; background: transparent;}} " - "QToolButton:hover {{border: none; background: rgba({0},{1},{2},0.2);}}" - ).format(*SHARED.theme.colText) + "QToolButton:hover {{border: none; background: rgba({0}, {1}, {2}, 0.2);}}" + ).format(colText.red(), colText.green(), colText.blue()) self.showHide.setStyleSheet(buttonStyle) self.showComments.setStyleSheet(buttonStyle) @@ -956,9 +963,9 @@ class GuiDocViewFooter(QWidget): theme rather than the main GUI. """ palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor(*SHARED.theme.colBack)) - palette.setColor(QPalette.ColorRole.WindowText, QColor(*SHARED.theme.colText)) - palette.setColor(QPalette.ColorRole.Text, QColor(*SHARED.theme.colText)) + palette.setColor(QPalette.ColorRole.Window, SHARED.theme.colBack) + palette.setColor(QPalette.ColorRole.WindowText, SHARED.theme.colText) + palette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText) self.setPalette(palette) return diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 189cd72d..279fce07 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -155,10 +155,10 @@ class GuiMainMenu(QMenuBar): self.aProjectSettings.setShortcut("Ctrl+Shift+,") self.aProjectSettings.triggered.connect(self.mainGui.showProjectSettingsDialog) - # Project > Project Details - self.aProjectDetails = self.projMenu.addAction(self.tr("Project Details")) - self.aProjectDetails.setShortcut("Shift+F6") - self.aProjectDetails.triggered.connect(self.mainGui.showProjectDetailsDialog) + # Project > Novel Details + self.aNovelDetails = self.projMenu.addAction(self.tr("Novel Details")) + self.aNovelDetails.setShortcut("Shift+F6") + self.aNovelDetails.triggered.connect(self.mainGui.showNovelDetailsDialog) # Project > Separator self.projMenu.addSeparator() diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index 1569262b..959ec7ae 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -207,9 +207,10 @@ class GuiNovelToolBar(QWidget): # Novel Selector selFont = self.font() selFont.setWeight(QFont.Weight.Bold) - self.novelPrefix = self.tr("Outline of {0}") + self.novelValue = NovelSelector(self) self.novelValue.setFont(selFont) + self.novelValue.setListFormat(self.tr("Outline of {0}")) self.novelValue.setMinimumWidth(CONFIG.pxInt(150)) self.novelValue.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.novelValue.novelSelectionChanged.connect(self.setCurrentRoot) @@ -294,7 +295,7 @@ class GuiNovelToolBar(QWidget): "QComboBox {border-style: none; padding-left: 0;} " "QComboBox::drop-down {border-style: none}" ) - self.novelValue.updateList(prefix=self.novelPrefix) + self.novelValue.refreshNovelList() self.tbNovel.setVisible(self.novelValue.count() > 1) return @@ -307,7 +308,7 @@ class GuiNovelToolBar(QWidget): def buildNovelRootMenu(self) -> None: """Build the novel root menu.""" - self.novelValue.updateList(prefix=self.novelPrefix) + self.novelValue.refreshNovelList() self.tbNovel.setVisible(self.novelValue.count() > 1) return diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index db066cee..cd441bae 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -220,6 +220,7 @@ class GuiOutlineToolBar(QToolBar): self.novelLabel.setContentsMargins(0, 0, mPx, 0) self.novelValue = NovelSelector(self) + self.novelValue.setIncludeAll(True) self.novelValue.setMinimumWidth(CONFIG.pxInt(200)) self.novelValue.novelSelectionChanged.connect(self._novelValueChanged) @@ -258,7 +259,7 @@ class GuiOutlineToolBar(QToolBar): def updateTheme(self) -> None: """Update theme elements.""" self.setStyleSheet("QToolBar {border: 0px;}") - self.novelValue.updateList(includeAll=True) + self.novelValue.refreshNovelList() self.aRefresh.setIcon(SHARED.theme.getIcon("refresh")) self.tbColumns.setIcon(SHARED.theme.getIcon("menu")) self.tbColumns.setStyleSheet("QToolButton::menu-indicator {image: none;}") @@ -266,7 +267,7 @@ class GuiOutlineToolBar(QToolBar): def populateNovelList(self) -> None: """Reload the content of the novel list.""" - self.novelValue.updateList(includeAll=True) + self.novelValue.refreshNovelList() return def setCurrentRoot(self, rootHandle: str | None) -> None: diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 8c42a107..da050876 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -48,7 +48,7 @@ from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter from novelwriter.dialogs.docmerge import GuiDocMerge from novelwriter.dialogs.docsplit import GuiDocSplit from novelwriter.dialogs.editlabel import GuiEditLabel -from novelwriter.dialogs.projsettings import GuiProjectSettings +from novelwriter.dialogs.projectsettings import GuiProjectSettings from novelwriter.enum import ( nwDocMode, nwItemType, nwItemClass, nwItemLayout, nwWidget ) @@ -1748,7 +1748,7 @@ class _TreeContextMenu(QMenu): menu.addSeparator() action = menu.addAction(self.tr("Manage Labels ...")) action.triggered.connect( - lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_STATUS) + lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.PAGE_STATUS) ) else: menu = self.addMenu(self.tr("Set Importance to ...")) @@ -1765,7 +1765,7 @@ class _TreeContextMenu(QMenu): menu.addSeparator() action = menu.addAction(self.tr("Manage Labels ...")) action.triggered.connect( - lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_IMPORT) + lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.PAGE_IMPORT) ) return diff --git a/novelwriter/gui/sidebar.py b/novelwriter/gui/sidebar.py index 92522542..d425ccf8 100644 --- a/novelwriter/gui/sidebar.py +++ b/novelwriter/gui/sidebar.py @@ -79,9 +79,9 @@ class GuiSideBar(QWidget): self.tbBuild.clicked.connect(self.mainGui.showBuildManuscriptDialog) self.tbDetails = QToolButton(self) - self.tbDetails.setToolTip("{0} [Shift+F6]".format(self.tr("Project Details"))) + self.tbDetails.setToolTip("{0} [Shift+F6]".format(self.tr("Novel Details"))) self.tbDetails.setIconSize(iconSize) - self.tbDetails.clicked.connect(self.mainGui.showProjectDetailsDialog) + self.tbDetails.clicked.connect(self.mainGui.showNovelDetailsDialog) self.tbStats = QToolButton(self) self.tbStats.setToolTip("{0} [F6]".format(self.tr("Writing Statistics"))) diff --git a/novelwriter/gui/statusbar.py b/novelwriter/gui/statusbar.py index fd95be74..61168f20 100644 --- a/novelwriter/gui/statusbar.py +++ b/novelwriter/gui/statusbar.py @@ -29,7 +29,6 @@ from time import time from typing import TYPE_CHECKING, Literal from datetime import datetime -from PyQt5.QtGui import QColor from PyQt5.QtCore import pyqtSlot, QLocale from PyQt5.QtWidgets import qApp, QStatusBar, QLabel @@ -55,9 +54,9 @@ class GuiMainStatus(QStatusBar): self._userIdle = False self._debugInfo = False - colNone = QColor(*SHARED.theme.statNone) - colSaved = QColor(*SHARED.theme.statSaved) - colUnsaved = QColor(*SHARED.theme.statUnsaved) + colNone = SHARED.theme.statNone + colSaved = SHARED.theme.statSaved + colUnsaved = SHARED.theme.statUnsaved iPx = SHARED.theme.baseIconSize diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 2e3e8b94..5f8dbfac 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -70,10 +70,10 @@ class GuiTheme: self.isLightTheme = True # GUI - self.statNone = [120, 120, 120] - self.statUnsaved = [200, 15, 39] - self.statSaved = [2, 133, 37] - self.helpText = [0, 0, 0] + self.statNone = QColor(120, 120, 120) + self.statUnsaved = QColor(200, 15, 39) + self.statSaved = QColor(2, 133, 37) + self.helpText = QColor(0, 0, 0) # Loaded Syntax Settings # ====================== @@ -88,23 +88,23 @@ class GuiTheme: self.syntaxLicenseUrl = "" # Colours - self.colBack = [255, 255, 255] - self.colText = [0, 0, 0] - self.colLink = [0, 0, 0] - self.colHead = [0, 0, 0] - self.colHeadH = [0, 0, 0] - self.colEmph = [0, 0, 0] - self.colDialN = [0, 0, 0] - self.colDialD = [0, 0, 0] - self.colDialS = [0, 0, 0] - self.colHidden = [0, 0, 0] - self.colCode = [0, 0, 0] - self.colKey = [0, 0, 0] - self.colVal = [0, 0, 0] - self.colSpell = [0, 0, 0] - self.colError = [0, 0, 0] - self.colRepTag = [0, 0, 0] - self.colMod = [0, 0, 0] + self.colBack = QColor(255, 255, 255) + self.colText = QColor(0, 0, 0) + self.colLink = QColor(0, 0, 0) + self.colHead = QColor(0, 0, 0) + self.colHeadH = QColor(0, 0, 0) + self.colEmph = QColor(0, 0, 0) + self.colDialN = QColor(0, 0, 0) + self.colDialD = QColor(0, 0, 0) + self.colDialS = QColor(0, 0, 0) + self.colHidden = QColor(0, 0, 0) + self.colCode = QColor(0, 0, 0) + self.colKey = QColor(0, 0, 0) + self.colVal = QColor(0, 0, 0) + self.colSpell = QColor(0, 0, 0) + self.colError = QColor(0, 0, 0) + self.colRepTag = QColor(0, 0, 0) + self.colMod = QColor(0, 0, 0) # Class Setup # =========== @@ -256,12 +256,12 @@ class GuiTheme: backLNess = backCol.lightnessF() textLNess = textCol.lightnessF() self.isLightTheme = backLNess > textLNess - if self.helpText == [0, 0, 0]: + if self.helpText == QColor(0, 0, 0): if self.isLightTheme: helpLCol = textLNess + 0.35*(backLNess - textLNess) else: helpLCol = backLNess + 0.65*(textLNess - backLNess) - self.helpText = [int(255*helpLCol)]*3 + self.helpText = QColor.fromHsl(0, 0, int(255*helpLCol)) # Icons defaultIcons = "typicons_light" if backLNess >= 0.5 else "typicons_dark" @@ -398,29 +398,14 @@ class GuiTheme: return True - def _parseColour(self, parser: NWConfigParser, section: str, name: str) -> list[int]: + def _parseColour(self, parser: NWConfigParser, section: str, name: str) -> QColor: """Parse a colour value from a config string.""" - if parser.has_option(section, name): - values = parser.get(section, name).split(",") - result = [] - try: - result.append(minmax(int(values[0]), 0, 255)) - result.append(minmax(int(values[1]), 0, 255)) - result.append(minmax(int(values[2]), 0, 255)) - except Exception: - logger.error("Could not load theme colours for '%s' from config file", name) - result = [0, 0, 0] - else: - logger.warning("Could not find theme colours for '%s' in config file", name) - result = [0, 0, 0] - return result + return QColor(*parser.rdIntList(section, name, [0, 0, 0, 255])) def _setPalette(self, parser: NWConfigParser, section: str, name: str, value: QPalette.ColorRole) -> None: """Set a palette colour value from a config string.""" - self._guiPalette.setColor( - value, QColor(*self._parseColour(parser, section, name)) - ) + self._guiPalette.setColor(value, self._parseColour(parser, section, name)) return # End Class GuiTheme diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 6717351a..0282b8d7 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -53,11 +53,11 @@ from novelwriter.dialogs.about import GuiAbout from novelwriter.dialogs.updates import GuiUpdates from novelwriter.dialogs.wordlist import GuiWordList from novelwriter.dialogs.preferences import GuiPreferences -from novelwriter.dialogs.projdetails import GuiProjectDetails -from novelwriter.dialogs.projsettings import GuiProjectSettings +from novelwriter.dialogs.projectsettings import GuiProjectSettings from novelwriter.tools.welcome import GuiWelcome from novelwriter.tools.manuscript import GuiManuscript from novelwriter.tools.dictionaries import GuiDictionaries +from novelwriter.tools.noveldetails import GuiNovelDetails from novelwriter.tools.writingstats import GuiWritingStats from novelwriter.enum import ( @@ -79,13 +79,6 @@ class GuiMain(QMainWindow): function. Also, the project instance and theme instance are created here. These should be passed around to all other objects who need them and new instances of them should generally not be created. - - * All other GUI classes that depend on any components from the - main GUI should be passed a reference to the instance of this - class. - * All non-GUI classes can be passed a reference to the NWProject - instance if the Main GUI is not needed (which it generally - shouldn't need). """ def __init__(self) -> None: @@ -816,19 +809,19 @@ class GuiMain(QMainWindow): @pyqtSlot() @pyqtSlot(int) - def showProjectSettingsDialog(self, focusTab: int = GuiProjectSettings.TAB_MAIN) -> None: + def showProjectSettingsDialog(self, focusTab: int = GuiProjectSettings.PAGE_SETTINGS) -> None: """Open the project settings dialog.""" if SHARED.hasProject: - dialog = GuiProjectSettings(self, focusTab=focusTab) + dialog = GuiProjectSettings(self, gotoPage=focusTab) dialog.newProjectSettingsReady.connect(self._processProjectSettingsChanges) dialog.exec_() return @pyqtSlot() - def showProjectDetailsDialog(self) -> None: - """Open the project details dialog.""" + def showNovelDetailsDialog(self) -> None: + """Open the novel details dialog.""" if SHARED.hasProject: - dialog = GuiProjectDetails(self) + dialog = GuiNovelDetails(self) dialog.setModal(True) dialog.show() dialog.raise_() @@ -1118,13 +1111,15 @@ class GuiMain(QMainWindow): return - @pyqtSlot() - def _processProjectSettingsChanges(self) -> None: + @pyqtSlot(bool) + def _processProjectSettingsChanges(self, rebuildTrees: bool) -> None: """Refresh data dependent on project settings.""" logger.debug("Applying new project settings") SHARED.updateSpellCheckLanguage() self.itemDetails.refreshDetails() self._updateWindowTitle(SHARED.project.data.name) + if rebuildTrees: + self.rebuildTrees() return @pyqtSlot() diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index fb0324d1..e0c034af 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -27,9 +27,7 @@ import logging from typing import TYPE_CHECKING -from PyQt5.QtGui import ( - QColor, QFont, QIcon, QSyntaxHighlighter, QTextCharFormat, QTextDocument -) +from PyQt5.QtGui import QFont, QIcon, QSyntaxHighlighter, QTextCharFormat, QTextDocument from PyQt5.QtCore import QEvent, QSize, Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QAbstractButton, QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, @@ -862,9 +860,9 @@ class _HeadingSyntaxHighlighter(QSyntaxHighlighter): def __init__(self, document: QTextDocument) -> None: super().__init__(document) self._fmtSymbol = QTextCharFormat() - self._fmtSymbol.setForeground(QColor(*SHARED.theme.colHead)) + self._fmtSymbol.setForeground(SHARED.theme.colHead) self._fmtFormat = QTextCharFormat() - self._fmtFormat.setForeground(QColor(*SHARED.theme.colEmph)) + self._fmtFormat.setForeground(SHARED.theme.colEmph) return def highlightBlock(self, text: str) -> None: diff --git a/novelwriter/tools/noveldetails.py b/novelwriter/tools/noveldetails.py new file mode 100644 index 00000000..71375d7f --- /dev/null +++ b/novelwriter/tools/noveldetails.py @@ -0,0 +1,525 @@ +""" +novelWriter – GUI Novel Info +============================ + +File History: +Created: 2024-01-18 [2.3b1] GuiNovelDetails + +This file is a part of novelWriter +Copyright 2018–2024, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +import math +import logging + +from PyQt5.QtGui import QCloseEvent +from PyQt5.QtCore import QSize, Qt, pyqtSlot +from PyQt5.QtWidgets import ( + QAbstractItemView, QDialog, QDialogButtonBox, QFormLayout, QGridLayout, + QHBoxLayout, QLabel, QSpinBox, QStackedWidget, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget +) + +from novelwriter import CONFIG, SHARED +from novelwriter.common import formatTime, numberToRoman +from novelwriter.constants import nwUnicode +from novelwriter.extensions.switch import NSwitch +from novelwriter.extensions.configlayout import NColourLabel, NFixedPage, NScrollablePage +from novelwriter.extensions.pagedsidebar import NPagedSideBar +from novelwriter.extensions.novelselector import NovelSelector + +logger = logging.getLogger(__name__) + + +class GuiNovelDetails(QDialog): + + PAGE_OVERVIEW = 1 + PAGE_CONTENTS = 2 + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + + logger.debug("Create: GuiNovelDetails") + self.setObjectName("GuiNovelDetails") + self.setWindowTitle(self.tr("Novel Details")) + + options = SHARED.project.options + self.setMinimumSize(CONFIG.pxInt(500), CONFIG.pxInt(400)) + self.resize( + CONFIG.pxInt(options.getInt("GuiNovelDetails", "winWidth", CONFIG.pxInt(650))), + CONFIG.pxInt(options.getInt("GuiNovelDetails", "winHeight", CONFIG.pxInt(500))) + ) + + # Title + self.titleLabel = NColourLabel( + self.tr("Novel Details"), SHARED.theme.helpText, + parent=self, scale=NColourLabel.HEADER_SCALE, indent=CONFIG.pxInt(4) + ) + + # Novel Selector + self.novelSelector = NovelSelector(self) + self.novelSelector.refreshNovelList() + self.novelSelector.setHandle( + options.getString("GuiNovelDetails", "novelRoot", self.novelSelector.firstHandle or "") + ) + + # SideBar + self.sidebar = NPagedSideBar(self) + self.sidebar.setLabelColor(SHARED.theme.helpText) + self.sidebar.addButton(self.tr("Overview"), self.PAGE_OVERVIEW) + self.sidebar.addButton(self.tr("Contents"), self.PAGE_CONTENTS) + self.sidebar.setSelected(self.PAGE_OVERVIEW) + self.sidebar.buttonClicked.connect(self._sidebarClicked) + + # Content + self.overviewPage = _OverviewPage(self) + self.contentsPage = _ContentsPage(self) + + self.mainStack = QStackedWidget(self) + self.mainStack.addWidget(self.overviewPage) + self.mainStack.addWidget(self.contentsPage) + + # Buttons + self.buttonBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + self.buttonBox.rejected.connect(self.close) + + # Assemble + self.topBox = QHBoxLayout() + self.topBox.addWidget(self.titleLabel) + self.topBox.addStretch(1) + self.topBox.addWidget(self.novelSelector, 1) + + self.mainBox = QHBoxLayout() + self.mainBox.addWidget(self.sidebar) + self.mainBox.addWidget(self.mainStack) + self.mainBox.setContentsMargins(0, 0, 0, 0) + + self.outerBox = QVBoxLayout() + self.outerBox.addLayout(self.topBox) + self.outerBox.addLayout(self.mainBox) + self.outerBox.addWidget(self.buttonBox) + self.outerBox.setSpacing(CONFIG.pxInt(8)) + + self.setLayout(self.outerBox) + self.setSizeGripEnabled(True) + + # Connect Signals + self.novelSelector.novelSelectionChanged.connect(self.overviewPage.novelValueChanged) + self.novelSelector.novelSelectionChanged.connect(self.contentsPage.novelValueChanged) + + logger.debug("Ready: GuiNovelDetails") + + return + + def __del__(self) -> None: # pragma: no cover + logger.debug("Delete: GuiNovelDetails") + return + + ## + # Methods + ## + + def updateValues(self) -> None: + """Load the dialogs initial values.""" + self.overviewPage.updateProjectData() + self.overviewPage.novelValueChanged(self.novelSelector.handle) + self.contentsPage.novelValueChanged(self.novelSelector.handle) + return + + ## + # Events + ## + + def closeEvent(self, event: QCloseEvent) -> None: + """Capture the user closing the window and save settings.""" + self._saveSettings() + event.accept() + self.deleteLater() + return + + ## + # Private Slots + ## + + @pyqtSlot(int) + def _sidebarClicked(self, pageId: int) -> None: + """Process a user request to switch page.""" + if pageId == self.PAGE_OVERVIEW: + self.mainStack.setCurrentWidget(self.overviewPage) + elif pageId == self.PAGE_CONTENTS: + self.mainStack.setCurrentWidget(self.contentsPage) + return + + ## + # Internal Functions + ## + + def _saveSettings(self) -> None: + """Save the user GUI settings.""" + winWidth = CONFIG.rpxInt(self.width()) + winHeight = CONFIG.rpxInt(self.height()) + novelRoot = self.novelSelector.handle + + logger.debug("Saving State: GuiNovelDetails") + options = SHARED.project.options + options.setValue("GuiNovelDetails", "winWidth", winWidth) + options.setValue("GuiNovelDetails", "winHeight", winHeight) + options.setValue("GuiNovelDetails", "novelRoot", novelRoot) + + self.contentsPage.saveSettings() + + return + +# END Class GuiNovelDetails + + +class _OverviewPage(NScrollablePage): + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + + mPx = CONFIG.pxInt(8) + sPx = CONFIG.pxInt(16) + hPx = CONFIG.pxInt(24) + vPx = CONFIG.pxInt(4) + + # Project Info + self.projLabel = NColourLabel( + self.tr("Project"), SHARED.theme.helpText, + parent=self, scale=NColourLabel.HEADER_SCALE + ) + + self.projName = QLabel("", self) + self.projWords = QLabel("", self) + self.projNovels = QLabel("", self) + self.projNotes = QLabel("", self) + self.projRevisions = QLabel("", self) + self.projEditTime = QLabel("", self) + + self.projForm = QFormLayout() + self.projForm.addRow("{0}".format(self.tr("Name")), self.projName) + self.projForm.addRow("{0}".format(self.tr("Revisions")), self.projRevisions) + self.projForm.addRow("{0}".format(self.tr("Editing Time")), self.projEditTime) + self.projForm.addRow("{0}".format(self.tr("Word Count")), self.projWords) + self.projForm.addRow("\u2026 {0}".format(self.tr("In Novels")), self.projNovels) + self.projForm.addRow("\u2026 {0}".format(self.tr("In Notes ")), self.projNotes) + self.projForm.setContentsMargins(mPx, 0, 0, 0) + self.projForm.setHorizontalSpacing(hPx) + self.projForm.setVerticalSpacing(vPx) + + # Novel Info + self.novelLabel = NColourLabel( + self.tr("Selected Novel"), SHARED.theme.helpText, + parent=self, scale=NColourLabel.HEADER_SCALE + ) + + self.novelName = QLabel("", self) + self.novelWords = QLabel("", self) + self.novelChapters = QLabel("", self) + self.novelScenes = QLabel("", self) + + self.novelForm = QFormLayout() + self.novelForm.addRow("{0}".format(self.tr("Name")), self.novelName) + self.novelForm.addRow("{0}".format(self.tr("Word Count")), self.novelWords) + self.novelForm.addRow("{0}".format(self.tr("Chapters")), self.novelChapters) + self.novelForm.addRow("{0}".format(self.tr("Scenes")), self.novelScenes) + self.novelForm.setContentsMargins(mPx, 0, 0, 0) + self.novelForm.setHorizontalSpacing(hPx) + self.novelForm.setVerticalSpacing(vPx) + + # Assemble + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.projLabel) + self.outerBox.addLayout(self.projForm) + self.outerBox.addWidget(self.novelLabel) + self.outerBox.addLayout(self.novelForm) + self.outerBox.setSpacing(sPx) + self.outerBox.addStretch(1) + + self.setCentralLayout(self.outerBox) + + return + + ## + # Methods + ## + + def updateProjectData(self) -> None: + """Load information about the project.""" + project = SHARED.project + project.updateWordCounts() + wcNovel, wcNotes = project.data.currCounts + + self.projName.setText(project.data.name) + self.projRevisions.setText(f"{project.data.saveCount:n}") + self.projEditTime.setText(formatTime(project.currentEditTime)) + self.projWords.setText(f"{wcNovel + wcNotes:n}") + self.projNovels.setText(f"{wcNovel:n}") + self.projNotes.setText(f"{wcNotes:n}") + return + + ## + # Public Slots + ## + + @pyqtSlot(str) + def novelValueChanged(self, tHandle: str) -> None: + """Refresh the data for the selected novel.""" + project = SHARED.project + if nwItem := project.tree[tHandle]: + self.novelName.setText(nwItem.itemName) + + nwCount = project.index.getNovelWordCount(rootHandle=tHandle) + self.novelWords.setText(f"{nwCount:n}") + + hCounts = project.index.getNovelTitleCounts(rootHandle=tHandle) + self.novelChapters.setText(f"{hCounts[2]:n}") + self.novelScenes.setText(f"{hCounts[3]:n}") + + return + +# END Class _OverviewPage + + +class _ContentsPage(NFixedPage): + + C_TITLE = 0 + C_WORDS = 1 + C_PAGES = 2 + C_PAGE = 3 + C_PROG = 4 + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + + self._data = [] + self._currentRoot = None + + iPx = SHARED.theme.baseIconSize + hPx = CONFIG.pxInt(12) + vPx = CONFIG.pxInt(4) + options = SHARED.project.options + + # Title + self.contentLabel = NColourLabel( + self.tr("Table of Contents"), SHARED.theme.helpText, + parent=self, scale=NColourLabel.HEADER_SCALE + ) + + # Contents Tree + self.tocTree = QTreeWidget(self) + self.tocTree.setIconSize(QSize(iPx, iPx)) + self.tocTree.setIndentation(0) + self.tocTree.setColumnCount(6) + self.tocTree.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.tocTree.setHeaderLabels([ + self.tr("Title"), + self.tr("Words"), + self.tr("Pages"), + self.tr("Page"), + self.tr("Progress"), + "", + ]) + + treeHeadItem = self.tocTree.headerItem() + if treeHeadItem: + treeHeadItem.setTextAlignment(self.C_WORDS, Qt.AlignmentFlag.AlignRight) + treeHeadItem.setTextAlignment(self.C_PAGES, Qt.AlignmentFlag.AlignRight) + treeHeadItem.setTextAlignment(self.C_PAGE, Qt.AlignmentFlag.AlignRight) + treeHeadItem.setTextAlignment(self.C_PROG, Qt.AlignmentFlag.AlignRight) + + treeHeader = self.tocTree.header() + treeHeader.setStretchLastSection(True) + treeHeader.setMinimumSectionSize(hPx) + + wCol0 = CONFIG.pxInt(options.getInt("GuiNovelDetails", "widthCol0", 200)) + wCol1 = CONFIG.pxInt(options.getInt("GuiNovelDetails", "widthCol1", 60)) + wCol2 = CONFIG.pxInt(options.getInt("GuiNovelDetails", "widthCol2", 60)) + wCol3 = CONFIG.pxInt(options.getInt("GuiNovelDetails", "widthCol3", 60)) + wCol4 = CONFIG.pxInt(options.getInt("GuiNovelDetails", "widthCol4", 90)) + + self.tocTree.setColumnWidth(0, wCol0) + self.tocTree.setColumnWidth(1, wCol1) + self.tocTree.setColumnWidth(2, wCol2) + self.tocTree.setColumnWidth(3, wCol3) + self.tocTree.setColumnWidth(4, wCol4) + self.tocTree.setColumnWidth(5, hPx) + + # Options + wordsPerPage = options.getInt("GuiNovelDetails", "wordsPerPage", 350) + countFrom = options.getInt("GuiNovelDetails", "countFrom", 1) + clearDouble = options.getBool("GuiNovelDetails", "clearDouble", True) + + self.wpLabel = QLabel(self.tr("Words per page")) + + self.wpValue = QSpinBox(self) + self.wpValue.setMinimum(10) + self.wpValue.setMaximum(1000) + self.wpValue.setSingleStep(10) + self.wpValue.setValue(wordsPerPage) + self.wpValue.valueChanged.connect(self._populateTree) + + self.poLabel = QLabel(self.tr("First page offset")) + + self.poValue = QSpinBox(self) + self.poValue.setMinimum(1) + self.poValue.setMaximum(9999) + self.poValue.setSingleStep(1) + self.poValue.setValue(countFrom) + self.poValue.valueChanged.connect(self._populateTree) + + self.dblLabel = QLabel(self.tr("Chapters on odd pages")) + + self.dblValue = NSwitch(self, 2*iPx, iPx) + self.dblValue.setChecked(clearDouble) + self.dblValue.clicked.connect(self._populateTree) + + self.optionsBox = QGridLayout() + self.optionsBox.addWidget(self.wpLabel, 0, 0) + self.optionsBox.addWidget(self.wpValue, 0, 1) + self.optionsBox.addWidget(self.dblLabel, 0, 3) + self.optionsBox.addWidget(self.dblValue, 0, 4) + self.optionsBox.addWidget(self.poLabel, 1, 0) + self.optionsBox.addWidget(self.poValue, 1, 1) + self.optionsBox.setHorizontalSpacing(hPx) + self.optionsBox.setVerticalSpacing(vPx) + self.optionsBox.setColumnStretch(2, 1) + + # Assemble + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.contentLabel) + self.outerBox.addWidget(self.tocTree) + self.outerBox.addLayout(self.optionsBox) + + self.setCentralLayout(self.outerBox) + + return + + def saveSettings(self) -> None: + """Save the user GUI settings.""" + widthCol0 = CONFIG.rpxInt(self.tocTree.columnWidth(0)) + widthCol1 = CONFIG.rpxInt(self.tocTree.columnWidth(1)) + widthCol2 = CONFIG.rpxInt(self.tocTree.columnWidth(2)) + widthCol3 = CONFIG.rpxInt(self.tocTree.columnWidth(3)) + widthCol4 = CONFIG.rpxInt(self.tocTree.columnWidth(4)) + + options = SHARED.project.options + options.setValue("GuiNovelDetails", "widthCol0", widthCol0) + options.setValue("GuiNovelDetails", "widthCol1", widthCol1) + options.setValue("GuiNovelDetails", "widthCol2", widthCol2) + options.setValue("GuiNovelDetails", "widthCol3", widthCol3) + options.setValue("GuiNovelDetails", "widthCol4", widthCol4) + options.setValue("GuiNovelDetails", "wordsPerPage", self.wpValue.value()) + options.setValue("GuiNovelDetails", "countFrom", self.poValue.value()) + options.setValue("GuiNovelDetails", "clearDouble", self.dblValue.isChecked()) + return + + ## + # Public Slots + ## + + @pyqtSlot(str) + def novelValueChanged(self, tHandle: str) -> None: + """Refresh the tree with another root item.""" + if tHandle != self._currentRoot: + self._prepareData(tHandle) + self._populateTree() + self._currentRoot = tHandle + return + + ## + # Private Slots + ## + + @pyqtSlot() + def _populateTree(self) -> None: + """Set the content of the chapter/page tree.""" + dblPages = self.dblValue.isChecked() + wpPage = self.wpValue.value() + fstPage = self.poValue.value() - 1 + + pTotal = 0 + tPages = 1 + + theList = [] + for _, tLevel, tTitle, wCount in self._data: + pCount = math.ceil(wCount/wpPage) + if dblPages: + pCount += pCount%2 + + pTotal += pCount + theList.append((tLevel, tTitle, wCount, pCount)) + + pMax = pTotal - fstPage + + self.tocTree.clear() + for tLevel, tTitle, wCount, pCount in theList: + newItem = QTreeWidgetItem() + + if tPages <= fstPage: + progPage = numberToRoman(tPages, True) + progText = "" + else: + cPage = tPages - fstPage + pgProg = 100.0*(cPage - 1)/pMax if pMax > 0 else 0.0 + progPage = f"{cPage:n}" + progText = f"{pgProg:.1f}{nwUnicode.U_THSP}%" + + hDec = SHARED.theme.getHeaderDecoration(tLevel) + if tTitle.strip() == "": + tTitle = self.tr("Untitled") + + newItem.setData(self.C_TITLE, Qt.DecorationRole, hDec) + newItem.setText(self.C_TITLE, tTitle) + newItem.setText(self.C_WORDS, f"{wCount:n}") + newItem.setText(self.C_PAGES, f"{pCount:n}") + newItem.setText(self.C_PAGE, progPage) + newItem.setText(self.C_PROG, progText) + + newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight) + newItem.setTextAlignment(self.C_PAGES, Qt.AlignRight) + newItem.setTextAlignment(self.C_PAGE, Qt.AlignRight) + newItem.setTextAlignment(self.C_PROG, Qt.AlignRight) + + # Make pages and titles/partitions stand out + if tLevel < 2: + bFont = newItem.font(self.C_TITLE) + if tLevel == 0: + bFont.setItalic(True) + else: + bFont.setBold(True) + bFont.setUnderline(True) + newItem.setFont(self.C_TITLE, bFont) + + tPages += pCount + + self.tocTree.addTopLevelItem(newItem) + + return + + ## + # Internal Functions + ## + + def _prepareData(self, rootHandle: str | None) -> None: + """Extract the information from the project index.""" + logger.debug("Populating ToC from handle '%s'", rootHandle) + self._data = SHARED.project.index.getTableOfContents(rootHandle, 2) + self._data.append(("", 0, self.tr("END"), 0)) + return + +# END Class _ContentsPage diff --git a/novelwriter/tools/welcome.py b/novelwriter/tools/welcome.py index d136cec6..385f7bd2 100644 --- a/novelwriter/tools/welcome.py +++ b/novelwriter/tools/welcome.py @@ -25,7 +25,6 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING from pathlib import Path from datetime import datetime @@ -43,14 +42,11 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG, SHARED, __version__, __date__ from novelwriter.enum import nwItemClass -from novelwriter.common import formatInt, makeFileNameSafe +from novelwriter.common import formatInt, formatVersion, makeFileNameSafe from novelwriter.constants import nwUnicode from novelwriter.core.coretools import ProjectBuilder from novelwriter.extensions.switch import NSwitch -if TYPE_CHECKING: # pragma: no cover - from novelwriter.guimain import GuiMain - logger = logging.getLogger(__name__) @@ -58,8 +54,8 @@ class GuiWelcome(QDialog): openProjectRequest = pyqtSignal(Path) - def __init__(self, mainGui: GuiMain) -> None: - super().__init__(parent=mainGui) + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) logger.debug("Create: GuiWelcome") self.setObjectName("GuiWelcome") @@ -91,7 +87,8 @@ class GuiWelcome(QDialog): self.nwLabel.setPixmap(self.nwImage) self.nwInfo = QLabel(self.tr("Version {0} {1} Released on {2}").format( - __version__, nwUnicode.U_ENDASH, datetime.strptime(__date__, "%Y-%m-%d").strftime("%x") + formatVersion(__version__), nwUnicode.U_ENDASH, + datetime.strptime(__date__, "%Y-%m-%d").strftime("%x") )) self.tabOpen = _OpenProjectPage(self) @@ -343,7 +340,7 @@ class _ProjectListItem(QStyledItemDelegate): self._dFont = qApp.font() self._dFont.setPointSizeF(fPt) - self._dPen = QPen(QColor(*SHARED.theme.helpText)) + self._dPen = QPen(SHARED.theme.helpText) self._icon = SHARED.theme.getPixmap("proj_nwx", (iPx, iPx)) diff --git a/pkgutils.py b/pkgutils.py index 2b821e1a..fe648e8f 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -23,6 +23,7 @@ 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 os import sys @@ -42,7 +43,7 @@ OS_DARWIN = 3 # Utilities # =============================================================================================== # -def extractVersion(beQuiet=False): +def extractVersion(beQuiet: bool = False) -> tuple[str, str, str]: """Extract the novelWriter version number without having to import anything else from the main package. """ @@ -73,34 +74,31 @@ def extractVersion(beQuiet=False): return numVers, hexVers, relDate -def compactVersion(version): - """Make the version number more compact.""" - return version.replace("-alpha", "a").replace("-beta", "b").replace("-rc", "rc") +def stripVersion(version: str) -> str: + """Strip the pre-release part from a version number.""" + if "a" in version: + return version.partition("a")[0] + elif "b" in version: + return version.partition("b")[0] + elif "rc" in version: + return version.partition("rc")[0] + else: + return version -def sysCall(callArgs, cwd=None): - """Wrapper function for system calls.""" - sysP = subprocess.Popen( - callArgs, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - shell=True, cwd=cwd - ) - stdOut, stdErr = sysP.communicate() - return stdOut.decode("utf-8"), stdErr.decode("utf-8"), sysP.returncode - - -def readFile(fileName): +def readFile(fileName: str) -> str: """Read an entire file and return as a string.""" with open(fileName, mode="r", encoding="utf-8") as inFile: return inFile.read() -def writeFile(fileName, writeText): +def writeFile(fileName: str, writeText: str) -> None: """Write string to file.""" with open(fileName, mode="w+", encoding="utf-8") as outFile: outFile.write(writeText) -def toUpload(srcPath, dstName=None): +def toUpload(srcPath: str, dstName: str | None = None) -> None: """Copy a file produced by one of the build functions to the upload directory. The file can optionally be given a new name. """ @@ -113,7 +111,7 @@ def toUpload(srcPath, dstName=None): return -def makeCheckSum(sumFile, cwd=None): +def makeCheckSum(sumFile: str, cwd: str | None = None) -> str: """Create a SHA256 checksum file.""" try: if cwd is None: @@ -139,7 +137,7 @@ def makeCheckSum(sumFile, cwd=None): # Package Installer (pip) ## -def installPackages(hostOS): +def installPackages(hostOS: int) -> None: """Install package dependencies both for this script and for running novelWriter itself. """ @@ -172,7 +170,7 @@ def installPackages(hostOS): # Clean Build and Dist Folders (build-clean) ## -def cleanBuildDirs(): +def cleanBuildDirs() -> None: """Recursively delete the 'build' and 'dist' folders.""" print("") print("Cleaning up build environment ...") @@ -208,7 +206,7 @@ def cleanBuildDirs(): # Build PDF Manual (manual) ## -def buildPdfManual(): +def buildPdfManual() -> None: """This function will build the documentation as manual.pdf.""" print("") print("Building PDF Manual") @@ -262,7 +260,7 @@ def buildPdfManual(): # Qt Linguist QM Builder (qtlrelease) ## -def buildQtI18n(): +def buildQtI18n() -> None: """Build the lang.qm files for Qt Linguist.""" print("") print("Building Qt Localisation Files") @@ -315,7 +313,7 @@ def buildQtI18n(): # Qt Linguist TS Builder (qtlupdate) ## -def buildQtI18nTS(sysArgs): +def buildQtI18nTS(sysArgs: list[str]) -> None: """Build the lang.ts files for Qt Linguist.""" print("") print("Building Qt Translation Files") @@ -392,16 +390,16 @@ def buildQtI18nTS(sysArgs): ## -# Generage MacOS PList +# Generate MacOS PList ## -def genMacOSPlist(): +def genMacOSPlist() -> None: """Set necessary values for .plist file for MacOS build.""" outDir = "setup/macos" - numVers = extractVersion()[0].partition("-")[0] + numVers = stripVersion(extractVersion()[0]) copyrightYear = datetime.datetime.now().year - # These keys are no longer used but are present for compatability + # These keys are no longer used but are present for compatibility pkgVersMaj, pkgVersMin = numVers.split(".")[:2] plistXML = readFile(f"{outDir}/Info.plist.template").format( @@ -422,7 +420,7 @@ def genMacOSPlist(): # Sample Project ZIP File Builder (sample) ## -def buildSampleZip(): +def buildSampleZip() -> None: """Bundle the sample project into a single zip file to be saved into the novelwriter/assets folder for further bundling into builds. """ @@ -459,7 +457,7 @@ def buildSampleZip(): return -def cleanBuiltAssets(): +def cleanBuiltAssets() -> None: """Remove assets built by this script.""" print("") print("Removing Built Assets") @@ -488,7 +486,7 @@ def cleanBuiltAssets(): return -def checkAssetsExist(): +def checkAssetsExist() -> bool: """Check that the necessary compiled assets exist ahead of a build. """ hasSample = False @@ -523,7 +521,7 @@ def checkAssetsExist(): # Import Translations (import-i18n) ## -def importI18nUpdates(sysArgs): +def importI18nUpdates(sysArgs: list[str]) -> None: """Import new translation files from a zip file.""" print("") print("Import Updated Translations") @@ -563,7 +561,7 @@ def importI18nUpdates(sysArgs): # Make Minimal Package (minimal-zip) ## -def makeMinimalPackage(targetOS): +def makeMinimalPackage(targetOS: int) -> None: """Pack the core source file in a single zip file.""" from zipfile import ZipFile, ZIP_DEFLATED @@ -598,8 +596,7 @@ def makeMinimalPackage(targetOS): # Build Minimal Zip # ================= - numVers, _, _ = extractVersion() - pkgVers = compactVersion(numVers) + pkgVers, _, _ = extractVersion() zipFile = f"novelwriter-{pkgVers}-minimal{targName}.zip" outFile = os.path.join(bldDir, zipFile) if os.path.isfile(outFile): @@ -679,7 +676,8 @@ def makeMinimalPackage(targetOS): # Make Debian Package (build-deb) ## -def makeDebianPackage(signKey=None, sourceBuild=False, distName="unstable", buildName=""): +def makeDebianPackage(signKey: str | None = None, sourceBuild: bool = False, + distName: str = "unstable", buildName: str = "") -> str: """Build a Debian package.""" print("") print("Build Debian Package") @@ -692,13 +690,12 @@ def makeDebianPackage(signKey=None, sourceBuild=False, distName="unstable", buil # ============ numVers, hexVers, relDate = extractVersion() - pkgVers = compactVersion(numVers) relDate = datetime.datetime.strptime(relDate, "%Y-%m-%d") pkgDate = email.utils.format_datetime(relDate.replace(hour=12, tzinfo=None)) print("") - if buildName: - pkgVers = f"{pkgVers}{buildName}" + pkgVers = numVers.replace("a", "~a").replace("b", "~b").replace("rc", "~rc") + pkgVers = f"{pkgVers}+{buildName}" if buildName else pkgVers # Set Up Folder # ============= @@ -839,11 +836,7 @@ def makeDebianPackage(signKey=None, sourceBuild=False, distName="unstable", buil print("") if sourceBuild: - if hexVers[-2] == "f": - ppaName = "novelwriter" - else: - ppaName = "novelwriter-pre" - + ppaName = "novelwriter" if hexVers[-2] == "f" else "novelwriter-pre" return f"dput {ppaName}/{distName} {bldDir}/{bldPkg}_source.changes" return "" @@ -853,14 +846,14 @@ def makeDebianPackage(signKey=None, sourceBuild=False, distName="unstable", buil # Make Launchpad Package (build-ubuntu) ## -def makeForLaunchpad(doSign=False, isFirst=False, isSnapshot=False): +def makeForLaunchpad(doSign: bool = False, isFirst: bool = False) -> None: """Wrapper for building Debian packages for Launchpad.""" print("") print("Launchpad Packages") print("==================") print("") - if isFirst or isSnapshot: + if isFirst: bldNum = "0" else: bldNum = input("Build number [0]: ") @@ -870,17 +863,12 @@ def makeForLaunchpad(doSign=False, isFirst=False, isSnapshot=False): distLoop = [ ("20.04", "focal"), ("22.04", "jammy"), - ("23.04", "lunar"), ("23.10", "mantic"), + ("24.04", "noble"), ] - tStamp = datetime.datetime.now().strftime("%Y%m%d~%H%M%S") - if isSnapshot: - print(f"Building Ununtu SNAPSHOT~{tStamp} for:") - print("") - else: - print("Building Ubuntu packages for:") - print("") + print("Building Ubuntu packages for:") + print("") for distNum, codeName in distLoop: print(f" * Ubuntu {distNum} {codeName.title()}") print("") @@ -895,11 +883,7 @@ def makeForLaunchpad(doSign=False, isFirst=False, isSnapshot=False): dputCmd = [] for distNum, codeName in distLoop: - if isSnapshot: - buildName = f"+SNAPSHOT~{tStamp}~ubuntu{distNum}.0" - else: - buildName = f"~ubuntu{distNum}.{bldNum}" - + buildName = f"ubuntu{distNum}.{bldNum}" dCmd = makeDebianPackage( signKey=signKey, sourceBuild=True, @@ -922,7 +906,7 @@ def makeForLaunchpad(doSign=False, isFirst=False, isSnapshot=False): # Make AppImage (build-appimage) ## -def makeAppImage(sysArgs): +def makeAppImage(sysArgs: list[str]) -> list[str]: """Build an AppImage.""" import glob import argparse @@ -968,8 +952,7 @@ def makeAppImage(sysArgs): # Version Info # ============ - numVers, _, relDate = extractVersion() - pkgVers = compactVersion(numVers) + pkgVers, _, relDate = extractVersion() relDate = datetime.datetime.strptime(relDate, "%Y-%m-%d") print("") @@ -1139,7 +1122,7 @@ def makeAppImage(sysArgs): # Make Windows Setup EXE (build-win-exe) ## -def makeWindowsEmbedded(sysArgs): +def makeWindowsEmbedded(sysArgs: list[str]) -> None: """Set up a package with embedded Python and dependencies for Windows installation. """ @@ -1371,7 +1354,7 @@ def makeWindowsEmbedded(sysArgs): # XDG Installation (xdg-install) ## -def xdgInstall(): +def xdgInstall() -> None: """Will attempt to install icons and make a launcher.""" print("") print("XDG Install") @@ -1507,7 +1490,7 @@ def xdgInstall(): # XDG Uninstallation (xdg-uninstall) ## -def xdgUninstall(): +def xdgUninstall() -> None: """Will attempt to uninstall icons and the launcher.""" print("") print("XDG Uninstall") @@ -1577,7 +1560,7 @@ def xdgUninstall(): # WIN Installation (win-install) ## -def winInstall(): +def winInstall() -> None: """Will attempt to install icons and make a launcher for Windows.""" import winreg try: @@ -1704,7 +1687,7 @@ def winInstall(): # WIN Uninstallation (win-uninstall) ## -def winUninstall(): +def winUninstall() -> None: """Will attempt to uninstall icons previously installed.""" import winreg try: @@ -1805,40 +1788,35 @@ if __name__ == "__main__": else: hostOS = OS_NONE + sysArgs = sys.argv.copy() + # Set Target OS - if "--target-linux" in sys.argv: - sys.argv.remove("--target-linux") + if "--target-linux" in sysArgs: + sysArgs.remove("--target-linux") targetOS = OS_LINUX - elif "--target-darwin" in sys.argv: - sys.argv.remove("--target-darwin") + elif "--target-darwin" in sysArgs: + sysArgs.remove("--target-darwin") targetOS = OS_DARWIN - elif "--target-win" in sys.argv: - sys.argv.remove("--target-win") + elif "--target-win" in sysArgs: + sysArgs.remove("--target-win") targetOS = OS_WIN else: targetOS = hostOS # Sign package - if "--sign" in sys.argv: - sys.argv.remove("--sign") + if "--sign" in sysArgs: + sysArgs.remove("--sign") doSign = True else: doSign = False # First build - if "--first" in sys.argv: - sys.argv.remove("--first") + if "--first" in sysArgs: + sysArgs.remove("--first") isFirstBuild = True else: isFirstBuild = False - # Build snapshot - if "--snapshot" in sys.argv: - sys.argv.remove("--snapshot") - isSnapshot = True - else: - isSnapshot = False - helpMsg = [ "", "novelWriter Setup Tool", @@ -1856,7 +1834,7 @@ if __name__ == "__main__": "", " help Print the help message.", " pip Install all package dependencies for novelWriter using pip.", - " version Print the novelWriter version. Add -c for short version.", + " version Print the novelWriter version.", " build-clean Will attempt to delete 'build' and 'dist' folders.", "", "Additional Builds:", @@ -1880,7 +1858,6 @@ if __name__ == "__main__": " sign package.", " build-ubuntu Build a .deb packages Launchpad. Add --sign to ", " sign package. Add --first to set build number to 0.", - " Add --snapshot to make a snapshot package.", " build-win-exe Build a setup.exe file with Python embedded for Windows.", " The package must be built from a minimal windows zip file.", " build-appimage Build an AppImage. Argument --linux-tag defaults to", @@ -1905,71 +1882,66 @@ if __name__ == "__main__": # General # ======= - if "help" in sys.argv: - sys.argv.remove("help") + if "help" in sysArgs: + sysArgs.remove("help") print("\n".join(helpMsg)) sys.exit(0) - if "version" in sys.argv: - sys.argv.remove("version") - numVers, _, _ = extractVersion(beQuiet=True) - if "-c" in sys.argv: - sys.argv.remove("-c") - print(compactVersion(numVers), end=None) - else: - print(numVers, end=None) + if "version" in sysArgs: + sysArgs.remove("version") + print(extractVersion(beQuiet=True)[0], end=None) sys.exit(0) - if "pip" in sys.argv: - sys.argv.remove("pip") + if "pip" in sysArgs: + sysArgs.remove("pip") installPackages(hostOS) - if "build-clean" in sys.argv: - sys.argv.remove("build-clean") + if "build-clean" in sysArgs: + sysArgs.remove("build-clean") cleanBuildDirs() # Additional Builds # ================= - if "manual" in sys.argv: - sys.argv.remove("manual") + if "manual" in sysArgs: + sysArgs.remove("manual") buildPdfManual() - if "qtlrelease" in sys.argv: - sys.argv.remove("qtlrelease") + if "qtlrelease" in sysArgs: + sysArgs.remove("qtlrelease") buildQtI18n() - if "qtlupdate" in sys.argv: - sys.argv.remove("qtlupdate") - buildQtI18nTS(sys.argv) + if "qtlupdate" in sysArgs: + sysArgs.remove("qtlupdate") + buildQtI18nTS(sysArgs) sys.exit(0) # Don't continue execution - if "sample" in sys.argv: - sys.argv.remove("sample") + if "sample" in sysArgs: + sysArgs.remove("sample") buildSampleZip() - if "clean-assets" in sys.argv: - sys.argv.remove("clean-assets") + if "clean-assets" in sysArgs: + sysArgs.remove("clean-assets") cleanBuiltAssets() - if "gen-plist" in sys.argv: - sys.argv.remove("gen-plist") + if "gen-plist" in sysArgs: + sysArgs.remove("gen-plist") genMacOSPlist() # Python Packaging # ================ - if "import-i18n" in sys.argv: - sys.argv.remove("import-i18n") - importI18nUpdates(sys.argv) + if "import-i18n" in sysArgs: + sysArgs.remove("import-i18n") + importI18nUpdates(sysArgs) sys.exit(0) # Don't continue execution - if "minimal-zip" in sys.argv: - sys.argv.remove("minimal-zip") + if "minimal-zip" in sysArgs: + sysArgs.remove("minimal-zip") makeMinimalPackage(targetOS) - if "build-deb" in sys.argv: - sys.argv.remove("build-deb") + if "build-deb" in sysArgs: + sysArgs.remove("build-deb") if hostOS == OS_LINUX: if doSign: signKey = "D6A9F6B8F227CF7C6F6D1EE84DBBE4B734B0BD08" @@ -1980,23 +1952,23 @@ if __name__ == "__main__": print("ERROR: Command 'build-deb' can only be used on Linux") sys.exit(1) - if "build-ubuntu" in sys.argv: - sys.argv.remove("build-ubuntu") + if "build-ubuntu" in sysArgs: + sysArgs.remove("build-ubuntu") if hostOS == OS_LINUX: - makeForLaunchpad(doSign=doSign, isFirst=isFirstBuild, isSnapshot=isSnapshot) + makeForLaunchpad(doSign=doSign, isFirst=isFirstBuild) else: print("ERROR: Command 'build-ubuntu' can only be used on Linux") sys.exit(1) - if "build-win-exe" in sys.argv: - sys.argv.remove("build-win-exe") - makeWindowsEmbedded(sys.argv) + if "build-win-exe" in sysArgs: + sysArgs.remove("build-win-exe") + makeWindowsEmbedded(sysArgs) sys.exit(0) # Don't continue execution - if "build-appimage" in sys.argv: - sys.argv.remove("build-appimage") + if "build-appimage" in sysArgs: + sysArgs.remove("build-appimage") if hostOS == OS_LINUX: - sys.argv = makeAppImage(sys.argv) # Build appimage and prune its args + sysArgs = makeAppImage(sysArgs) else: print("ERROR: Command 'build-appimage' can only be used on Linux") sys.exit(1) @@ -2004,32 +1976,32 @@ if __name__ == "__main__": # General Installers # ================== - if "xdg-install" in sys.argv: - sys.argv.remove("xdg-install") + if "xdg-install" in sysArgs: + sysArgs.remove("xdg-install") if hostOS == OS_WIN: print("ERROR: Command 'xdg-install' cannot be used on Windows") sys.exit(1) else: xdgInstall() - if "xdg-uninstall" in sys.argv: - sys.argv.remove("xdg-uninstall") + if "xdg-uninstall" in sysArgs: + sysArgs.remove("xdg-uninstall") if hostOS == OS_WIN: print("ERROR: Command 'xdg-uninstall' cannot be used on Windows") sys.exit(1) else: xdgUninstall() - if "win-install" in sys.argv: - sys.argv.remove("win-install") + if "win-install" in sysArgs: + sysArgs.remove("win-install") if hostOS == OS_WIN: winInstall() else: print("ERROR: Command 'win-install' can only be used on Windows") sys.exit(1) - if "win-uninstall" in sys.argv: - sys.argv.remove("win-uninstall") + if "win-uninstall" in sysArgs: + sysArgs.remove("win-uninstall") if hostOS == OS_WIN: winUninstall() else: diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index 9206abf4..8d02b22d 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,8 +1,7 @@ - - + + Sample Project - Sample Project Jane Smith diff --git a/setup/macos/build.sh b/setup/macos/build.sh index dbc9834e..1ffca83f 100755 --- a/setup/macos/build.sh +++ b/setup/macos/build.sh @@ -24,7 +24,7 @@ echo "Build Dir: $BUILD_DIR" pushd "$SRC_DIR" || exit 1 -VERSION="$(python3 pkgutils.py version -c)" +VERSION="$(python3 pkgutils.py version)" echo "novelWriter Version: $VERSION" # --- Prepare Files ----------------------------------------------------------------------------- # diff --git a/setup/make_snapshot.sh b/setup/make_snapshot.sh deleted file mode 100755 index 5b92350c..00000000 --- a/setup/make_snapshot.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -set -e - -ENVPATH=/tmp/nwBuild - -if [ ! -f pkgutils.py ]; then - echo "Must be called from the root folder of the source" - exit 1 -fi - -echo "" -echo " Building Dependencies" -echo "================================================================================" -echo "" -if [ ! -d $ENVPATH ]; then - python3 -m venv $ENVPATH -fi -source $ENVPATH/bin/activate -pip3 install -r docs/source/requirements.txt -python3 pkgutils.py clean-assets -python3 pkgutils.py qtlrelease manual sample -deactivate - -echo "" -echo " Building Linux Snapshots" -echo "================================================================================" -echo "" -python3 pkgutils.py build-ubuntu --sign --snapshot diff --git a/tests/files/nwProject-1.5.nwx b/tests/files/nwProject-1.5.nwx index 1965de73..cc17f909 100644 --- a/tests/files/nwProject-1.5.nwx +++ b/tests/files/nwProject-1.5.nwx @@ -1,8 +1,7 @@ - + Sample Project - Sample Project Jane Smith diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx index 6fd70fb2..99b5a57c 100644 --- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx +++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx @@ -1,8 +1,7 @@ - + New Project - New Novel Jane Doe diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx index 057e3742..028da14c 100644 --- a/tests/reference/coreProject_NewRoot_nwProject.nwx +++ b/tests/reference/coreProject_NewRoot_nwProject.nwx @@ -1,8 +1,7 @@ - + New Project - New Novel Jane Doe diff --git a/tests/reference/coreTools_DocDuplicator_nwProject.nwx b/tests/reference/coreTools_DocDuplicator_nwProject.nwx index 0ee99397..bab9a82d 100644 --- a/tests/reference/coreTools_DocDuplicator_nwProject.nwx +++ b/tests/reference/coreTools_DocDuplicator_nwProject.nwx @@ -1,8 +1,7 @@ - + New Project - New Novel Jane Doe diff --git a/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx b/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx index 02f11a29..ee5eeeea 100644 --- a/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx +++ b/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx @@ -1,8 +1,7 @@ - + Test Project A - Test Project A Jane Doe diff --git a/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx b/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx index 0df054a8..209c7809 100644 --- a/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx +++ b/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx @@ -1,8 +1,7 @@ - + Test Project B - Test Project B Jane Doe diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx index c56d9782..c3ee941f 100644 --- a/tests/reference/guiEditor_Main_Final_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx @@ -1,8 +1,7 @@ - - + + New Project - New Novel Jane Doe diff --git a/tests/reference/guiEditor_Main_Initial_nwProject.nwx b/tests/reference/guiEditor_Main_Initial_nwProject.nwx index e266f65f..aa9c3a74 100644 --- a/tests/reference/guiEditor_Main_Initial_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Initial_nwProject.nwx @@ -1,8 +1,7 @@ - + New Project - New Novel Jane Doe diff --git a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json index 429aff11..7c315e5a 100644 --- a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json +++ b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json @@ -1,10 +1,9 @@ { "meta": { "projectName": "Lorem Ipsum", - "novelTitle": "Lorem Ipsum", "novelAuthor": "lipsum.com", - "buildTime": 1687620769, - "buildTimeStr": "2023-06-24 17:32:49" + "buildTime": 1706306020, + "buildTimeStr": "2024-01-26 22:53:40" }, "text": { "css": [ diff --git a/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.json b/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.json index 305b0006..06633aa2 100644 --- a/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.json +++ b/tests/reference/mBuildDocBuild_NWD_Lorem_Ipsum.json @@ -1,10 +1,9 @@ { "meta": { "projectName": "Lorem Ipsum", - "novelTitle": "Lorem Ipsum", "novelAuthor": "lipsum.com", - "buildTime": 1687621222, - "buildTimeStr": "2023-06-24 17:40:22" + "buildTime": 1706306020, + "buildTimeStr": "2024-01-26 22:53:40" }, "text": { "nwd": [ diff --git a/tests/reference/projectXML_ReadLegacy10.nwx b/tests/reference/projectXML_ReadLegacy10.nwx index 9d04ef44..5161eb5b 100644 --- a/tests/reference/projectXML_ReadLegacy10.nwx +++ b/tests/reference/projectXML_ReadLegacy10.nwx @@ -1,8 +1,7 @@ - + Sample Project - Sample Project Jay Doh diff --git a/tests/reference/projectXML_ReadLegacy11.nwx b/tests/reference/projectXML_ReadLegacy11.nwx index 50924c78..7624a057 100644 --- a/tests/reference/projectXML_ReadLegacy11.nwx +++ b/tests/reference/projectXML_ReadLegacy11.nwx @@ -1,8 +1,7 @@ - + Sample Project - Sample Project Jay Doh diff --git a/tests/reference/projectXML_ReadLegacy12.nwx b/tests/reference/projectXML_ReadLegacy12.nwx index 7d36e380..58b29061 100644 --- a/tests/reference/projectXML_ReadLegacy12.nwx +++ b/tests/reference/projectXML_ReadLegacy12.nwx @@ -1,8 +1,7 @@ - + Sample Project - Sample Project Jay Doh diff --git a/tests/reference/projectXML_ReadLegacy13.nwx b/tests/reference/projectXML_ReadLegacy13.nwx index c75aa97c..28e24c69 100644 --- a/tests/reference/projectXML_ReadLegacy13.nwx +++ b/tests/reference/projectXML_ReadLegacy13.nwx @@ -1,8 +1,7 @@ - + Sample Project - Sample Project Jay Doh diff --git a/tests/reference/projectXML_ReadLegacy14.nwx b/tests/reference/projectXML_ReadLegacy14.nwx index aef603dd..26e7413c 100644 --- a/tests/reference/projectXML_ReadLegacy14.nwx +++ b/tests/reference/projectXML_ReadLegacy14.nwx @@ -1,8 +1,7 @@ - + Sample Project - Sample Project Jay Doh diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index 0a475c19..a45571cf 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -35,7 +35,7 @@ from PyQt5.QtCore import QUrl from novelwriter.common import ( checkBool, checkFloat, checkHandle, checkInt, checkIntTuple, checkPath, checkString, checkStringNone, checkUuid, formatInt, formatTime, - formatTimeStamp, fuzzyTime, getFileSize, hexToInt, isHandle, isItemClass, + formatTimeStamp, formatVersion, fuzzyTime, getFileSize, hexToInt, isHandle, isItemClass, isItemLayout, isItemType, isTitleTag, jsonEncode, makeFileNameSafe, minmax, numberToRoman, NWConfigParser, openExternalPath, readTextFile, simplified, transferCase, xmlIndent, yesNo @@ -348,6 +348,17 @@ def testBaseCommon_formatTime(): # END Test testBaseCommon_formatTime +@pytest.mark.base +def testBaseCommon_formatVersion(): + """Test the formatVersion function.""" + assert formatVersion("1.2") == "1.2" + assert formatVersion("1.2a1") == "1.2 Alpha 1" + assert formatVersion("1.2b2") == "1.2 Beta 2" + assert formatVersion("1.2rc3") == "1.2 RC 3" + +# END Test testBaseCommon_formatVersion + + @pytest.mark.base def testBaseCommon_simplified(): """Test the simplified function.""" diff --git a/tests/test_core/test_core_docbuild.py b/tests/test_core/test_core_docbuild.py index 8890e2a8..b70269a8 100644 --- a/tests/test_core/test_core_docbuild.py +++ b/tests/test_core/test_core_docbuild.py @@ -230,7 +230,7 @@ def testCoreDocBuild_HTML(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): assert error == [] copyfile(docFile, tstFile) - assert cmpFiles(tstFile, cmpFile, ignoreLines=[6, 7]) + assert cmpFiles(tstFile, cmpFile, ignoreLines=[5, 6]) # Check Error Handling # ==================== @@ -370,7 +370,7 @@ def testCoreDocBuild_NWD(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): assert error == [] copyfile(docFile, tstFile) - assert cmpFiles(tstFile, cmpFile, ignoreLines=[6, 7]) + assert cmpFiles(tstFile, cmpFile, ignoreLines=[5, 6]) # Check Error Handling # ==================== diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 5e9dee3f..44836349 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -695,6 +695,7 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): "This is a story about Jane Smith.\n\n" "Well, not really. She's still awesome though.\n" )) + # Whole document cC, wC, pC = index.getCounts(nHandle) assert cC == 152 @@ -798,6 +799,10 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): # Extract stats assert index.getNovelWordCount(activeOnly=False) == 43 assert index.getNovelWordCount(activeOnly=True) == 15 + assert index.getNovelWordCount(rootHandle=C.hNovelRoot, activeOnly=False) == 43 + assert index.getNovelWordCount(rootHandle=C.hNovelRoot, activeOnly=True) == 15 + assert index.getNovelWordCount(rootHandle=C.hWorldRoot, activeOnly=False) == 0 + assert index.getNovelWordCount(rootHandle=C.hWorldRoot, activeOnly=True) == 0 assert index.getNovelTitleCounts(activeOnly=False) == [0, 3, 2, 3, 0] assert index.getNovelTitleCounts(activeOnly=True) == [0, 1, 2, 3, 0] diff --git a/tests/test_core/test_core_options.py b/tests/test_core/test_core_options.py index 7458c746..fd291d08 100644 --- a/tests/test_core/test_core_options.py +++ b/tests/test_core/test_core_options.py @@ -121,27 +121,27 @@ def testCoreOptions_SetGet(mockGUI): assert options.setValue("GuiProjectSettings", "winWidth", 100) is True # Set some values of different types - assert options.setValue("GuiProjectDetails", "winWidth", 100) is True - assert options.setValue("GuiProjectDetails", "winHeight", 12.34) is True - assert options.setValue("GuiProjectDetails", "clearDouble", True) is True + assert options.setValue("GuiNovelDetails", "winWidth", 100) is True + assert options.setValue("GuiNovelDetails", "winHeight", 12.34) is True + assert options.setValue("GuiNovelDetails", "clearDouble", True) is True assert options.setValue("GuiNovelView", "lastCol", nwColHidden) is True # Generic get, doesn't check type - assert options.getValue("GuiProjectDetails", "winWidth", None) == 100 - assert options.getValue("GuiProjectDetails", "winHeight", None) == 12.34 - assert options.getValue("GuiProjectDetails", "clearDouble", None) is True - assert options.getValue("GuiProjectDetails", "mockItem", None) is None + assert options.getValue("GuiNovelDetails", "winWidth", None) == 100 + assert options.getValue("GuiNovelDetails", "winHeight", None) == 12.34 + assert options.getValue("GuiNovelDetails", "clearDouble", None) is True + assert options.getValue("GuiNovelDetails", "mockItem", None) is None # Get type-specific - assert options.getString("GuiProjectDetails", "winWidth", None) is None # type: ignore - assert options.getString("GuiProjectDetails", "mockItem", None) is None # type: ignore - assert options.getInt("GuiProjectDetails", "winWidth", None) == 100 # type: ignore - assert options.getInt("GuiProjectDetails", "textFont", None) is None # type: ignore - assert options.getInt("GuiProjectDetails", "mockItem", None) is None # type: ignore - assert options.getFloat("GuiProjectDetails", "winWidth", None) == 100.0 # type: ignore - assert options.getFloat("GuiProjectDetails", "mockItem", None) is None # type: ignore - assert options.getBool("GuiProjectDetails", "clearDouble", None) is True # type: ignore - assert options.getBool("GuiProjectDetails", "mockItem", None) is None # type: ignore + assert options.getString("GuiNovelDetails", "winWidth", None) is None # type: ignore + assert options.getString("GuiNovelDetails", "mockItem", None) is None # type: ignore + assert options.getInt("GuiNovelDetails", "winWidth", None) == 100 # type: ignore + assert options.getInt("GuiNovelDetails", "textFont", None) is None # type: ignore + assert options.getInt("GuiNovelDetails", "mockItem", None) is None # type: ignore + assert options.getFloat("GuiNovelDetails", "winWidth", None) == 100.0 # type: ignore + assert options.getFloat("GuiNovelDetails", "mockItem", None) is None # type: ignore + assert options.getBool("GuiNovelDetails", "clearDouble", None) is True # type: ignore + assert options.getBool("GuiNovelDetails", "mockItem", None) is None # type: ignore assert options.getEnum("GuiNovelView", "lastCol", NovelTreeColumn, nwColHidden) == nwColHidden # Get from non-existent groups diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index 538e179b..fdad6b77 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -482,10 +482,6 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): project.data.setName(" A Name ") assert project.data.name == "A Name" - # Project Title - project.data.setTitle(" A Title ") - assert project.data.title == "A Title" - # Project Author project.data.setAuthor(" Jane\tDoe ") assert project.data.author == "Jane Doe" diff --git a/tests/test_core/test_core_projectxml.py b/tests/test_core/test_core_projectxml.py index 7753722b..554da1ef 100644 --- a/tests/test_core/test_core_projectxml.py +++ b/tests/test_core/test_core_projectxml.py @@ -131,13 +131,12 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath): assert xmlReader.state == XMLReadState.PARSED_OK assert xmlReader.xmlRoot == "novelWriterXML" assert xmlReader.xmlVersion == 0x0105 - assert xmlReader.xmlRevision == 1 + assert xmlReader.xmlRevision == 2 assert xmlReader.appVersion == "2.0-rc1" assert xmlReader.hexVersion == 0x020000c1 # Check loaded data assert data.name == "Sample Project" - assert data.title == "Sample Project" assert data.author == "Jane Smith" assert data.saveCount == 5 assert data.autoCount == 10 @@ -256,7 +255,6 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd): # Check loaded data assert data.name == "Sample Project" - assert data.title == "Sample Project" assert data.author == "Jay Doh" # Only last author is preserved assert data.saveCount == 0 # Doesn't exist in 1.0 assert data.autoCount == 0 # Doesn't exist in 1.0 @@ -391,7 +389,6 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd): # Check loaded data assert data.name == "Sample Project" - assert data.title == "Sample Project" assert data.author == "Jay Doh" # Only last author is preserved assert data.saveCount == 5 assert data.autoCount == 10 @@ -526,7 +523,6 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd): # Check loaded data assert data.name == "Sample Project" - assert data.title == "Sample Project" assert data.author == "Jay Doh" # Only last author is preserved assert data.saveCount == 5 assert data.autoCount == 10 @@ -664,7 +660,6 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd): # Check loaded data assert data.name == "Sample Project" - assert data.title == "Sample Project" assert data.author == "Jay Doh" # Only last author is preserved assert data.saveCount == 5 assert data.autoCount == 10 @@ -802,7 +797,6 @@ def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockRnd): # Check loaded data assert data.name == "Sample Project" - assert data.title == "Sample Project" assert data.author == "Jay Doh" # Only last author is preserved assert data.saveCount == 5 assert data.autoCount == 10 diff --git a/tests/test_dialogs/test_dlg_preferences.py b/tests/test_dialogs/test_dlg_preferences.py index c4c2e590..408865c7 100644 --- a/tests/test_dialogs/test_dlg_preferences.py +++ b/tests/test_dialogs/test_dlg_preferences.py @@ -22,9 +22,11 @@ from __future__ import annotations import pytest +from tools import getGuiItem + from PyQt5.QtGui import QFontDatabase, QKeyEvent from PyQt5.QtCore import QEvent, Qt -from PyQt5.QtWidgets import QDialogButtonBox, QFileDialog, QFontDialog +from PyQt5.QtWidgets import QAction, QDialogButtonBox, QFileDialog, QFontDialog from novelwriter import CONFIG, SHARED from novelwriter.constants import nwConst, nwUnicode @@ -38,9 +40,13 @@ KEY_DELAY = 1 def testDlgPreferences_Main(qtbot, monkeypatch, nwGUI, tstPaths): """Test the preferences dialog loading.""" monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")]) + monkeypatch.setattr(GuiPreferences, "exec_", lambda *a: None) # Load GUI with standard values - prefs = GuiPreferences(nwGUI) + nwGUI.mainMenu.aPreferences.activate(QAction.ActionEvent.Trigger) + qtbot.waitUntil(lambda: getGuiItem("GuiPreferences") is not None, timeout=1000) + prefs = getGuiItem("GuiPreferences") + assert isinstance(prefs, GuiPreferences) prefs.show() # Check Languages @@ -95,10 +101,6 @@ def testDlgPreferences_Actions(qtbot, monkeypatch, nwGUI): # Check Navigation vBar = prefs.mainForm.verticalScrollBar() old = -1 - with qtbot.waitSignal(vBar.valueChanged) as value: - prefs.sidebar.button(0).click() - assert value.args[0] > old - old = value.args[0] with qtbot.waitSignal(vBar.valueChanged) as value: prefs.sidebar.button(1).click() assert value.args[0] > old @@ -107,6 +109,10 @@ def testDlgPreferences_Actions(qtbot, monkeypatch, nwGUI): prefs.sidebar.button(2).click() assert value.args[0] > old old = value.args[0] + with qtbot.waitSignal(vBar.valueChanged) as value: + prefs.sidebar.button(3).click() + assert value.args[0] > old + old = value.args[0] # Check Search prefs.searchText.setText("Display language") diff --git a/tests/test_dialogs/test_dlg_projdetails.py b/tests/test_dialogs/test_dlg_projdetails.py deleted file mode 100644 index 3d68f9d7..00000000 --- a/tests/test_dialogs/test_dlg_projdetails.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -novelWriter – Project Details Dialog Class Tester -================================================= - -This file is a part of novelWriter -Copyright 2018–2024, Veronica Berglyd Olsen - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, but -WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" -from __future__ import annotations - -import pytest - -from tools import getGuiItem - -from PyQt5.QtWidgets import QAction - -from novelwriter import SHARED -from novelwriter.dialogs.projdetails import GuiProjectDetails - - -@pytest.mark.gui -def testDlgProjDetails_Dialog(qtbot, nwGUI, prjLipsum): - """Test the project details dialog. - """ - # Create a project to work on - assert nwGUI.openProject(prjLipsum) - assert nwGUI.rebuildIndex(beQuiet=True) - qtbot.wait(100) - - # Open the Writing Stats dialog - nwGUI.mainMenu.aProjectDetails.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiProjectDetails") is not None, timeout=1000) - - projDet = getGuiItem("GuiProjectDetails") - assert isinstance(projDet, GuiProjectDetails) - - # Overview Page - # ============= - - assert projDet.tabMain.bookTitle.text() == "Lorem Ipsum" - assert projDet.tabMain.projName.text()[-11:] == "Lorem Ipsum" - assert projDet.tabMain.bookAuthors.text()[-10:] == "lipsum.com" - - assert projDet.tabMain.wordCountVal.text() == f"{3000:n}" - assert projDet.tabMain.chapCountVal.text() == f"{3:n}" - assert projDet.tabMain.sceneCountVal.text() == f"{5:n}" - assert projDet.tabMain.revCountVal.text() == f"{SHARED.project.data.saveCount:n}" - - assert projDet.tabMain.projPathVal.text() == str(prjLipsum) - - # Contents Page - # ============= - - tocTab = projDet.tabContents - tocTree = tocTab.tocTree - assert tocTree.topLevelItemCount() == 7 - assert tocTree.topLevelItem(0).text(tocTab.C_TITLE) == "Lorem Ipsum" # type: ignore - assert tocTree.topLevelItem(2).text(tocTab.C_TITLE) == "Prologue" # type: ignore - assert tocTree.topLevelItem(3).text(tocTab.C_TITLE) == "Act One" # type: ignore - assert tocTree.topLevelItem(4).text(tocTab.C_TITLE) == "Chapter One" # type: ignore - assert tocTree.topLevelItem(5).text(tocTab.C_TITLE) == "Chapter Two" # type: ignore - assert tocTree.topLevelItem(6).text(tocTab.C_TITLE) == "END" # type: ignore - - # Count Pages - tocTab.wpValue.setValue(100) - tocTab.poValue.setValue(4) - tocTab.dblValue.setChecked(False) - tocTab._populateTree() - - thePages = ["1", "2", "1", "1", "11", "17", "0"] - thePage = ["i", "ii", "1", "2", "3", "14", "31"] - for i in range(7): - assert tocTree.topLevelItem(i).text(tocTab.C_PAGES) == thePages[i] # type: ignore - assert tocTree.topLevelItem(i).text(tocTab.C_PAGE) == thePage[i] # type: ignore - - tocTab.poValue.setValue(5) - tocTab.dblValue.setChecked(True) - tocTab._populateTree() - - thePages = ["2", "2", "2", "2", "12", "18", "0"] - thePage = ["i", "iii", "1", "3", "5", "17", "35"] - for i in range(7): - assert tocTree.topLevelItem(i).text(tocTab.C_PAGES) == thePages[i] # type: ignore - assert tocTree.topLevelItem(i).text(tocTab.C_PAGE) == thePage[i] # type: ignore - - # Re-populate - assert tocTab._currentRoot is None - tocTab._novelValueChanged("7a992350f3eb6") # Not a root - assert tocTab._currentRoot == "b3643d0f92e32" # The actual novel root - - # qtbot.stop() - - # Clean Up - projDet.close() - nwGUI.closeMain() - -# END Test testDlgProjDetails_Dialog diff --git a/tests/test_dialogs/test_dlg_projsettings.py b/tests/test_dialogs/test_dlg_projectsettings.py similarity index 50% rename from tests/test_dialogs/test_dlg_projsettings.py rename to tests/test_dialogs/test_dlg_projectsettings.py index 37556245..5b4d5499 100644 --- a/tests/test_dialogs/test_dlg_projsettings.py +++ b/tests/test_dialogs/test_dlg_projectsettings.py @@ -31,7 +31,7 @@ from PyQt5.QtWidgets import QDialog, QAction, QColorDialog from novelwriter import CONFIG, SHARED from novelwriter.enum import nwItemType from novelwriter.dialogs.editlabel import GuiEditLabel -from novelwriter.dialogs.projsettings import GuiProjectSettings +from novelwriter.dialogs.projectsettings import GuiProjectSettings KEY_DELAY = 1 @@ -63,17 +63,17 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI): qtbot.addWidget(projSettings) # Switch Tabs - projSettings._focusTab(GuiProjectSettings.TAB_REPLACE) - assert projSettings._tabBox.currentWidget() == projSettings.tabReplace + projSettings.sidebar.button(GuiProjectSettings.PAGE_SETTINGS).click() + assert projSettings.mainStack.currentWidget() == projSettings.settingsPage - projSettings._focusTab(GuiProjectSettings.TAB_IMPORT) - assert projSettings._tabBox.currentWidget() == projSettings.tabImport + projSettings.sidebar.button(GuiProjectSettings.PAGE_STATUS).click() + assert projSettings.mainStack.currentWidget() == projSettings.statusPage - projSettings._focusTab(GuiProjectSettings.TAB_STATUS) - assert projSettings._tabBox.currentWidget() == projSettings.tabStatus + projSettings.sidebar.button(GuiProjectSettings.PAGE_IMPORT).click() + assert projSettings.mainStack.currentWidget() == projSettings.importPage - projSettings._focusTab(GuiProjectSettings.TAB_MAIN) - assert projSettings._tabBox.currentWidget() == projSettings.tabMain + projSettings.sidebar.button(GuiProjectSettings.PAGE_REPLACE).click() + assert projSettings.mainStack.currentWidget() == projSettings.replacePage # Clean Up projSettings.close() @@ -83,9 +83,11 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI): @pytest.mark.gui -def testDlgProjSettings_Main(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): - """Test the main tab of the project settings dialog.""" - monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")]) +def testDlgProjSettings_SettingsPage(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): + """Test the settings page of the dialog.""" + languages = [("en", "English"), ("de", "German")] + monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda *a: languages) + monkeypatch.setattr(CONFIG, "listLanguages", lambda *a: languages) # Create new project buildTestProject(nwGUI, projPath) @@ -94,72 +96,57 @@ def testDlgProjSettings_Main(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockR # Set some values project = SHARED.project + project.data.setLanguage("en") project.data.setSpellLang("en") project.data.setAuthor("Jane Smith") project.data.setAutoReplace({"A": "B", "C": "D"}) # Create Dialog - projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.TAB_MAIN) + projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.PAGE_SETTINGS) projSettings.show() qtbot.addWidget(projSettings) # Settings Tab - # ============ + settings = projSettings.settingsPage - tabMain = projSettings.tabMain + assert settings.projName.text() == "New Project" + assert settings.projAuthor.text() == "Jane Smith" + assert settings.projLang.currentData() == "en" + assert settings.spellLang.currentData() == "en" + assert settings.doBackup.isChecked() is False - assert tabMain.editName.text() == "New Project" - assert tabMain.editTitle.text() == "New Novel" - assert tabMain.editAuthor.text() == "Jane Smith" - assert tabMain.spellLang.currentData() == "en" - assert tabMain.doBackup.isChecked() is False - - tabMain.editName.setText("") - for c in "Project Name": - qtbot.keyClick(tabMain.editName, c, delay=KEY_DELAY) - tabMain.editTitle.setText("") - for c in "Project Title": - qtbot.keyClick(tabMain.editTitle, c, delay=KEY_DELAY) - - tabMain.editAuthor.clear() - for c in "Jane Doe": - qtbot.keyClick(tabMain.editAuthor, c, delay=KEY_DELAY) - - assert tabMain.editName.text() == "Project Name" - assert tabMain.editTitle.text() == "Project Title" - assert tabMain.editAuthor.text() == "Jane Doe" + settings.projName.setText("Project Name") + settings.projAuthor.setText("Jane Doe") + settings.projLang.setCurrentIndex(settings.projLang.findData("de")) + settings.spellLang.setCurrentIndex(settings.spellLang.findData("de")) + settings.doBackup.setChecked(True) projSettings._doSave() assert project.data.name == "Project Name" - assert project.data.title == "Project Title" assert project.data.author == "Jane Doe" + assert project.data.language == "de" + assert project.data.spellLang == "de" + assert project.data.doBackup is False - nwGUI._processProjectSettingsChanges() + nwGUI._processProjectSettingsChanges(False) assert nwGUI.windowTitle() == "novelWriter - Project Name" # qtbot.stop() -# END Test testDlgProjSettings_Main +# END Test testDlgProjSettings_SettingsPage @pytest.mark.gui -def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): - """Test the status and importance tabs of the project settings - dialog. - """ +def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, projPath, mockRnd): + """Test the status and importance pages of the dialog.""" monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) - monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")]) - - # Create new project - mockRnd.reset() buildTestProject(nwGUI, projPath) - CONFIG.setBackupPath(fncPath) # Set some values - theProject = SHARED.project - theProject.tree[C.hTitlePage].setStatus(C.sFinished) # type: ignore - theProject.tree[C.hChapterDoc].setStatus(C.sDraft) # type: ignore - theProject.tree[C.hSceneDoc].setStatus(C.sDraft) # type: ignore + project = SHARED.project + project.tree[C.hTitlePage].setStatus(C.sFinished) # type: ignore + project.tree[C.hChapterDoc].setStatus(C.sDraft) # type: ignore + project.tree[C.hSceneDoc].setStatus(C.sDraft) # type: ignore nwGUI.projView.projTree.setSelectedHandle(C.hPlotRoot) nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) @@ -172,51 +159,54 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPat hCharNote = "0000000000011" hWorldNote = "0000000000012" - theProject.tree[hPlotNote].setImport(C.iMajor) # type: ignore - theProject.tree[hCharNote].setImport(C.iMajor) # type: ignore - theProject.tree[hWorldNote].setImport(C.iMain) # type: ignore + project.tree[hPlotNote].setImport(C.iMajor) # type: ignore + project.tree[hCharNote].setImport(C.iMajor) # type: ignore + project.tree[hWorldNote].setImport(C.iMain) # type: ignore + + nwGUI.rebuildTrees() + project.countStatus() + + assert [e["count"] for _, e in project.data.itemStatus.items()] == [2, 0, 2, 1] + assert [e["count"] for _, e in project.data.itemImport.items()] == [3, 0, 2, 1] # Create Dialog - projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.TAB_STATUS) + projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.PAGE_STATUS) projSettings.show() qtbot.addWidget(projSettings) # Status Tab # ========== - tabStatus = projSettings.tabStatus + status = projSettings.statusPage - assert tabStatus.colChanged is False - assert tabStatus.getNewList() == ([], []) - assert tabStatus.listBox.topLevelItemCount() == 4 + assert status.wasChanged is False + assert status.getNewList() == ([], []) + assert status.listBox.topLevelItemCount() == 4 # Can't delete the first item (it's in use) - tabStatus.listBox.clearSelection() - tabStatus.listBox.setCurrentItem(tabStatus.listBox.topLevelItem(0)) - qtbot.mouseClick(tabStatus.delButton, Qt.LeftButton) - assert tabStatus.listBox.topLevelItemCount() == 4 + status.listBox.clearSelection() + status.listBox.setCurrentItem(status.listBox.topLevelItem(0)) + qtbot.mouseClick(status.delButton, Qt.LeftButton) + assert status.listBox.topLevelItemCount() == 4 # Can delete the second item - tabStatus.listBox.clearSelection() - tabStatus.listBox.setCurrentItem(tabStatus.listBox.topLevelItem(1)) - qtbot.mouseClick(tabStatus.delButton, Qt.LeftButton) - assert tabStatus.listBox.topLevelItemCount() == 3 + status.listBox.clearSelection() + status.listBox.setCurrentItem(status.listBox.topLevelItem(1)) + qtbot.mouseClick(status.delButton, Qt.LeftButton) + assert status.listBox.topLevelItemCount() == 3 # Add a new item with monkeypatch.context() as mp: mp.setattr(QColorDialog, "getColor", lambda *a: QColor(20, 30, 40)) - qtbot.mouseClick(tabStatus.addButton, Qt.LeftButton) - tabStatus.listBox.setCurrentItem(tabStatus.listBox.topLevelItem(3)) - for _ in range(8): - qtbot.keyClick(tabStatus.editName, Qt.Key_Backspace, delay=KEY_DELAY) - for c in "Final": - qtbot.keyClick(tabStatus.editName, c, delay=KEY_DELAY) - qtbot.mouseClick(tabStatus.colButton, Qt.LeftButton) - qtbot.mouseClick(tabStatus.saveButton, Qt.LeftButton) - assert tabStatus.listBox.topLevelItemCount() == 4 + status.addButton.click() + status.listBox.setCurrentItem(status.listBox.topLevelItem(3)) + status.editName.setText("Final") + status.colButton.click() + status.saveButton.click() + assert status.listBox.topLevelItemCount() == 4 - assert tabStatus.colChanged is True - assert tabStatus.getNewList() == ( + assert status.wasChanged is True + assert status.getNewList() == ( [ { "key": C.sNew, @@ -241,62 +231,62 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPat ) # Move items, none selected -> no change - tabStatus.listBox.clearSelection() - tabStatus._moveItem(1) - assert [x["key"] for x in tabStatus.getNewList()[0]] == [ + status.listBox.clearSelection() + status._moveItem(1) + assert [x["key"] for x in status.getNewList()[0]] == [ C.sNew, C.sDraft, C.sFinished, None ] # Move items, first selected, move up -> no change - tabStatus.listBox.clearSelection() - tabStatus.listBox.setCurrentItem(tabStatus.listBox.topLevelItem(0)) - tabStatus._moveItem(-1) - assert [x["key"] for x in tabStatus.getNewList()[0]] == [ + status.listBox.clearSelection() + status.listBox.setCurrentItem(status.listBox.topLevelItem(0)) + status._moveItem(-1) + assert [x["key"] for x in status.getNewList()[0]] == [ C.sNew, C.sDraft, C.sFinished, None ] # Move items, last selected, move up -> allowed - tabStatus.listBox.clearSelection() - tabStatus.listBox.setCurrentItem(tabStatus.listBox.topLevelItem(3)) - tabStatus._moveItem(-1) - assert [x["key"] for x in tabStatus.getNewList()[0]] == [ + status.listBox.clearSelection() + status.listBox.setCurrentItem(status.listBox.topLevelItem(3)) + status._moveItem(-1) + assert [x["key"] for x in status.getNewList()[0]] == [ C.sNew, C.sDraft, None, C.sFinished ] # Move items, same selected, move down -> allowed - tabStatus._moveItem(1) - assert [x["key"] for x in tabStatus.getNewList()[0]] == [ + status._moveItem(1) + assert [x["key"] for x in status.getNewList()[0]] == [ C.sNew, C.sDraft, C.sFinished, None ] # Importance Tab # ============== - tabImport = projSettings.tabImport - projSettings._focusTab(GuiProjectSettings.TAB_IMPORT) + importance = projSettings.importPage + projSettings._sidebarClicked(GuiProjectSettings.PAGE_IMPORT) # Delete unused entry - tabImport.listBox.clearSelection() - tabImport.listBox.setCurrentItem(tabImport.listBox.topLevelItem(1)) - qtbot.mouseClick(tabImport.delButton, Qt.LeftButton) - assert tabImport.listBox.topLevelItemCount() == 3 + importance.listBox.clearSelection() + importance.listBox.setCurrentItem(importance.listBox.topLevelItem(1)) + qtbot.mouseClick(importance.delButton, Qt.LeftButton) + assert importance.listBox.topLevelItemCount() == 3 # Add a new entry with monkeypatch.context() as mp: mp.setattr(QColorDialog, "getColor", lambda *a: QColor(20, 30, 40)) - qtbot.mouseClick(tabImport.addButton, Qt.LeftButton) - tabImport.listBox.clearSelection() - tabImport.listBox.setCurrentItem(tabImport.listBox.topLevelItem(3)) + qtbot.mouseClick(importance.addButton, Qt.LeftButton) + importance.listBox.clearSelection() + importance.listBox.setCurrentItem(importance.listBox.topLevelItem(3)) for _ in range(8): - qtbot.keyClick(tabImport.editName, Qt.Key_Backspace, delay=KEY_DELAY) + qtbot.keyClick(importance.editName, Qt.Key_Backspace, delay=KEY_DELAY) for c in "Final": - qtbot.keyClick(tabImport.editName, c, delay=KEY_DELAY) - qtbot.mouseClick(tabImport.colButton, Qt.LeftButton) - qtbot.mouseClick(tabImport.saveButton, Qt.LeftButton) - assert tabImport.listBox.topLevelItemCount() == 4 + qtbot.keyClick(importance.editName, c, delay=KEY_DELAY) + qtbot.mouseClick(importance.colButton, Qt.LeftButton) + qtbot.mouseClick(importance.saveButton, Qt.LeftButton) + assert importance.listBox.topLevelItemCount() == 4 - assert tabImport.colChanged is True - assert tabImport.getNewList() == ( + assert importance.wasChanged is True + assert importance.getNewList() == ( [ { "key": C.iNew, @@ -323,13 +313,13 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPat # Check Project projSettings._doSave() - statusItems = dict(theProject.data.itemStatus.items()) + statusItems = dict(project.data.itemStatus.items()) assert statusItems[C.sNew]["name"] == "New" assert statusItems[C.sDraft]["name"] == "Draft" assert statusItems[C.sFinished]["name"] == "Finished" assert statusItems["s000013"]["name"] == "Final" - importItems = dict(theProject.data.itemImport.items()) + importItems = dict(project.data.itemImport.items()) assert importItems[C.iNew]["name"] == "New" assert importItems[C.iMajor]["name"] == "Major" assert importItems[C.iMain]["name"] == "Main" @@ -341,83 +331,78 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPat @pytest.mark.gui -def testDlgProjSettings_Replace(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): - """Test the auto-replace tab of the project settings dialog.""" +def testDlgProjSettings_Replace(qtbot, monkeypatch, nwGUI, projPath, mockRnd): + """Test the auto-replace page of the dialog.""" monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) - monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")]) - - # Create new project - mockRnd.reset() buildTestProject(nwGUI, projPath) - CONFIG.setBackupPath(fncPath) # Set some values - theProject = SHARED.project - theProject.data.setAutoReplace({ + project = SHARED.project + project.data.setAutoReplace({ "A": "B", "C": "D" }) # Create Dialog - projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.TAB_REPLACE) + projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.PAGE_REPLACE) projSettings.show() qtbot.addWidget(projSettings) # Auto-Replace Tab # ================ - tabReplace = projSettings.tabReplace + replace = projSettings.replacePage - assert tabReplace.listBox.topLevelItem(0).text(0) == "" # type: ignore - assert tabReplace.listBox.topLevelItem(0).text(1) == "B" # type: ignore - assert tabReplace.listBox.topLevelItem(1).text(0) == "" # type: ignore - assert tabReplace.listBox.topLevelItem(1).text(1) == "D" # type: ignore - assert tabReplace.listBox.topLevelItemCount() == 2 + assert replace.listBox.topLevelItem(0).text(0) == "" # type: ignore + assert replace.listBox.topLevelItem(0).text(1) == "B" # type: ignore + assert replace.listBox.topLevelItem(1).text(0) == "" # type: ignore + assert replace.listBox.topLevelItem(1).text(1) == "D" # type: ignore + assert replace.listBox.topLevelItemCount() == 2 # Nothing to save or delete - tabReplace.listBox.clearSelection() - tabReplace._saveEntry() - tabReplace._delEntry() - assert tabReplace.listBox.topLevelItemCount() == 2 + replace.listBox.clearSelection() + replace._saveEntry() + replace._delEntry() + assert replace.listBox.topLevelItemCount() == 2 # Create a new entry - qtbot.mouseClick(tabReplace.addButton, Qt.LeftButton) - assert tabReplace.listBox.topLevelItemCount() == 3 - assert tabReplace.listBox.topLevelItem(2).text(0) == "" # type: ignore - assert tabReplace.listBox.topLevelItem(2).text(1) == "" # type: ignore + qtbot.mouseClick(replace.addButton, Qt.LeftButton) + assert replace.listBox.topLevelItemCount() == 3 + assert replace.listBox.topLevelItem(2).text(0) == "" # type: ignore + assert replace.listBox.topLevelItem(2).text(1) == "" # type: ignore # Edit the entry - tabReplace.listBox.setCurrentItem(tabReplace.listBox.topLevelItem(2)) - tabReplace.editKey.setText("") + replace.listBox.setCurrentItem(replace.listBox.topLevelItem(2)) + replace.editKey.setText("") for c in "Th is ": - qtbot.keyClick(tabReplace.editKey, c, delay=KEY_DELAY) - tabReplace.editValue.setText("") + qtbot.keyClick(replace.editKey, c, delay=KEY_DELAY) + replace.editValue.setText("") for c in "With This Stuff ": - qtbot.keyClick(tabReplace.editValue, c, delay=KEY_DELAY) - qtbot.mouseClick(tabReplace.saveButton, Qt.LeftButton) - assert tabReplace.listBox.topLevelItem(2).text(0) == "" # type: ignore - assert tabReplace.listBox.topLevelItem(2).text(1) == "With This Stuff " # type: ignore + qtbot.keyClick(replace.editValue, c, delay=KEY_DELAY) + qtbot.mouseClick(replace.saveButton, Qt.LeftButton) + assert replace.listBox.topLevelItem(2).text(0) == "" # type: ignore + assert replace.listBox.topLevelItem(2).text(1) == "With This Stuff " # type: ignore # Create a new entry again - tabReplace.listBox.clearSelection() - qtbot.mouseClick(tabReplace.addButton, Qt.LeftButton) - assert tabReplace.listBox.topLevelItemCount() == 4 + replace.listBox.clearSelection() + qtbot.mouseClick(replace.addButton, Qt.LeftButton) + assert replace.listBox.topLevelItemCount() == 4 # The list is sorted, so we must find it newIdx = -1 - for i in range(tabReplace.listBox.topLevelItemCount()): - if tabReplace.listBox.topLevelItem(i).text(0) == "": # type: ignore + for i in range(replace.listBox.topLevelItemCount()): + if replace.listBox.topLevelItem(i).text(0) == "": # type: ignore newIdx = i break assert newIdx >= 0 # Then delete the new item - tabReplace.listBox.setCurrentItem(tabReplace.listBox.topLevelItem(newIdx)) - qtbot.mouseClick(tabReplace.delButton, Qt.LeftButton) - assert tabReplace.listBox.topLevelItemCount() == 3 + replace.listBox.setCurrentItem(replace.listBox.topLevelItem(newIdx)) + qtbot.mouseClick(replace.delButton, Qt.LeftButton) + assert replace.listBox.topLevelItemCount() == 3 # Check Project projSettings._doSave() - assert theProject.data.autoReplace == { + assert project.data.autoReplace == { "A": "B", "C": "D", "This": "With This Stuff" } diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 38cb7ef2..5c15ed4a 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -29,7 +29,7 @@ from tools import ( C, NWD_IGNORE, cmpFiles, buildTestProject, XML_IGNORE, getGuiItem ) -from PyQt5.QtGui import QColor, QPalette +from PyQt5.QtGui import QPalette from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMenu, QInputDialog @@ -170,7 +170,7 @@ def testGuiMain_UpdateTheme(qtbot, nwGUI): mainTheme.loadSyntax() nwGUI._processConfigChanges(True, True, True, True) - syntaxBack = QColor(*SHARED.theme.colBack) + syntaxBack = SHARED.theme.colBack assert nwGUI.docEditor.palette().color(QPalette.ColorRole.Window) == syntaxBack assert nwGUI.docEditor.docHeader.palette().color(QPalette.ColorRole.Window) == syntaxBack @@ -200,7 +200,6 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): assert len(SHARED.project.tree._roots) == 0 assert SHARED.project.tree.trashRoot is None assert SHARED.project.data.name == "" - assert SHARED.project.data.title == "" assert SHARED.project.data.author == "" assert SHARED.project.data.spellCheck is False @@ -220,7 +219,6 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): assert len(SHARED.project.tree._roots) == 4 assert SHARED.project.tree.trashRoot is None assert SHARED.project.data.name == "New Project" - assert SHARED.project.data.title == "New Novel" assert SHARED.project.data.author == "Jane Doe" assert SHARED.project.data.spellCheck is False diff --git a/tests/test_gui/test_gui_theme.py b/tests/test_gui/test_gui_theme.py index 08defddc..b7a77dcd 100644 --- a/tests/test_gui/test_gui_theme.py +++ b/tests/test_gui/test_gui_theme.py @@ -27,7 +27,7 @@ from pathlib import Path from mocked import causeOSError from tools import writeFile -from PyQt5.QtGui import QIcon, QPalette, QPixmap +from PyQt5.QtGui import QColor, QIcon, QPalette, QPixmap from PyQt5.QtWidgets import QApplication from novelwriter import CONFIG, SHARED @@ -89,29 +89,38 @@ def testGuiTheme_Main(qtbot, nwGUI, tstPaths): parser = NWConfigParser() parser["Palette"] = { - "colour1": "100, 150, 200", - "colour2": "100, 150, 200, 250", - "colour3": "250, 250", - "colour4": "-10, 127, 300", + "colour1": "100, 150, 200", # Valid + "colour2": "100, 150, 200, 250", # With alpha + "colour3": "100, 150, 200, 250, 300", # Too many values + "colour4": "250, 250", # Missing blue + "colour5": "-10, 127, 300", # Invalid red and blue + "colour6": "bob, 127, 255", # Invalid red } # Test the parser for several valid and invalid values - assert mainTheme._parseColour(parser, "Palette", "colour1") == [100, 150, 200] - assert mainTheme._parseColour(parser, "Palette", "colour2") == [100, 150, 200] - assert mainTheme._parseColour(parser, "Palette", "colour3") == [0, 0, 0] - assert mainTheme._parseColour(parser, "Palette", "colour4") == [0, 127, 255] - assert mainTheme._parseColour(parser, "Palette", "colour5") == [0, 0, 0] + assert mainTheme._parseColour(parser, "Palette", "colour1").getRgb() == (100, 150, 200, 255) + assert mainTheme._parseColour(parser, "Palette", "colour2").getRgb() == (100, 150, 200, 250) + assert mainTheme._parseColour(parser, "Palette", "colour3").getRgb() == (100, 150, 200, 250) + assert mainTheme._parseColour(parser, "Palette", "colour4").getRgb() == (250, 250, 0, 255) + assert mainTheme._parseColour(parser, "Palette", "colour5").getRgb() == (0, 0, 0, 0) + assert mainTheme._parseColour(parser, "Palette", "colour6").getRgb() == (0, 127, 255, 255) # The palette should load with the parsed values mainTheme._setPalette(parser, "Palette", "colour1", QPalette.Window) assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (100, 150, 200, 255) mainTheme._setPalette(parser, "Palette", "colour2", QPalette.Window) - assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (100, 150, 200, 255) + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (100, 150, 200, 250) mainTheme._setPalette(parser, "Palette", "colour3", QPalette.Window) - assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (0, 0, 0, 255) + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (100, 150, 200, 250) mainTheme._setPalette(parser, "Palette", "colour4", QPalette.Window) - assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (0, 127, 255, 255) + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (250, 250, 0, 255) mainTheme._setPalette(parser, "Palette", "colour5", QPalette.Window) + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (0, 0, 0, 0) + mainTheme._setPalette(parser, "Palette", "colour6", QPalette.Window) + assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (0, 127, 255, 255) + + # Non-existing value should return default colour + mainTheme._setPalette(parser, "Palette", "stuff", QPalette.Window) assert mainTheme._guiPalette.color(QPalette.Window).getRgb() == (0, 0, 0, 255) # qtbot.stop() @@ -240,9 +249,9 @@ def testGuiTheme_Syntax(qtbot, monkeypatch, nwGUI): # Check some values assert mainTheme.syntaxName == "Default Light" - assert mainTheme.colBack == [255, 255, 255] - assert mainTheme.colText == [0, 0, 0] - assert mainTheme.colLink == [0, 0, 200] + assert mainTheme.colBack == QColor(255, 255, 255) + assert mainTheme.colText == QColor(0, 0, 0) + assert mainTheme.colLink == QColor(0, 0, 200) # Load Default Dark Theme # ======================= @@ -253,9 +262,9 @@ def testGuiTheme_Syntax(qtbot, monkeypatch, nwGUI): # Check some values assert mainTheme.syntaxName == "Default Dark" - assert mainTheme.colBack == [54, 54, 54] - assert mainTheme.colText == [199, 207, 208] - assert mainTheme.colLink == [184, 200, 0] + assert mainTheme.colBack == QColor(54, 54, 54) + assert mainTheme.colText == QColor(199, 207, 208) + assert mainTheme.colLink == QColor(184, 200, 0) # qtbot.stop() diff --git a/tests/test_tools/test_tools_noveldetails.py b/tests/test_tools/test_tools_noveldetails.py new file mode 100644 index 00000000..c8925e53 --- /dev/null +++ b/tests/test_tools/test_tools_noveldetails.py @@ -0,0 +1,136 @@ +""" +novelWriter – Novel Details Tool Tester +======================================= + +This file is a part of novelWriter +Copyright 2018–2024, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +import pytest + +from tools import getGuiItem + +from PyQt5.QtWidgets import QAction + +from novelwriter import SHARED +from novelwriter.enum import nwItemClass +from novelwriter.tools.noveldetails import GuiNovelDetails + + +@pytest.mark.gui +def testToolNovelDetails_Main(qtbot, nwGUI, prjLipsum, ipsumText): + """Test the Novel Details main dialog.""" + nwGUI.openProject(prjLipsum) + nHandle = "b3643d0f92e32" + + # Add a second Novel folder + project = SHARED.project + secondText = "#! Second\n\n" + "\n\n".join(ipsumText) + sHandle = project.newRoot(nwItemClass.NOVEL, "Second") + dHandle = project.newFile("Document", sHandle) + project.storage.getDocument(dHandle).writeDocument(secondText) + project.index.reIndexHandle(dHandle) + nwGUI.projView.projTree.revealNewTreeItem(sHandle) + nwGUI.projView.projTree.revealNewTreeItem(dHandle) + + # Create the dialog + nwGUI.mainMenu.aNovelDetails.activate(QAction.ActionEvent.Trigger) + qtbot.waitUntil(lambda: getGuiItem("GuiNovelDetails") is not None, timeout=1000) + details = getGuiItem("GuiNovelDetails") + assert isinstance(details, GuiNovelDetails) + + # Overview Page + # ============= + overview = details.overviewPage + + # The selector should default to the first entry + assert details.novelSelector.handle == nHandle + + # Check project data + assert overview.projName.text() == "Lorem Ipsum" + assert overview.projWords.text() == f"{4376:n}" + assert overview.projNovels.text() == f"{3638:n}" + assert overview.projNotes.text() == f"{738:n}" + assert overview.projRevisions.text() != "" + assert overview.projEditTime.text() != "" + + # Check novel data for "Novel" + assert overview.novelName.text() == "Novel" + assert overview.novelWords.text() == f"{3000:n}" + assert overview.novelChapters.text() == f"{3:n}" + assert overview.novelScenes.text() == f"{5:n}" + + # Check novel data for "Second" + details.novelSelector.setHandle(sHandle, blockSignal=False) + assert overview.novelName.text() == "Second" + assert overview.novelWords.text() == f"{529:n}" + assert overview.novelChapters.text() == f"{0:n}" + assert overview.novelScenes.text() == f"{0:n}" + + # Contents Page + # ============= + details.novelSelector.setHandle(nHandle, blockSignal=False) + details.sidebar.button(details.PAGE_CONTENTS).click() + assert details.mainStack.currentIndex() == 1 + contents = details.contentsPage + + # Check defaults + words = [f"{v:n}" for v in [40, 176, 92, 6, 1071, 1615, 0]] + pages = [f"{v:n}" for v in [2, 2, 2, 2, 4, 6, 0]] + page = [f"{v:n}" for v in [1, 3, 5, 7, 9, 13, 19]] + for i in range(6): + item = contents.tocTree.topLevelItem(i) + assert item is not None + assert item.text(contents.C_WORDS) == words[i] + assert item.text(contents.C_PAGES) == pages[i] + assert item.text(contents.C_PAGE) == page[i] + + # Change Settings + contents.poValue.setValue(7) + contents.wpValue.setValue(50) + words = [f"{v:n}" for v in [40, 176, 92, 6, 1071, 1615, 0]] + pages = [f"{v:n}" for v in [2, 4, 2, 2, 22, 34, 0]] + page = ["i", "iii"] + [f"{v:n}" for v in [1, 3, 5, 27, 61]] + for i in range(6): + item = contents.tocTree.topLevelItem(i) + assert item is not None + assert item.text(contents.C_WORDS) == words[i] + assert item.text(contents.C_PAGES) == pages[i] + assert item.text(contents.C_PAGE) == page[i] + + # Turn off use odd pages + contents.dblValue.setChecked(False) + contents.poValue.setValue(0) + contents.wpValue.setValue(100) + words = [f"{v:n}" for v in [40, 176, 92, 6, 1071, 1615, 0]] + pages = [f"{v:n}" for v in [1, 2, 1, 1, 11, 17, 0]] + page = [f"{v:n}" for v in [1, 2, 4, 5, 6, 17, 34]] + for i in range(6): + item = contents.tocTree.topLevelItem(i) + assert item is not None + assert item.text(contents.C_WORDS) == words[i] + assert item.text(contents.C_PAGES) == pages[i] + assert item.text(contents.C_PAGE) == page[i] + + # Revert to Overview page + details.sidebar.button(details.PAGE_OVERVIEW).click() + assert details.mainStack.currentIndex() == 0 + + # qtbot.stop() + details.close() + +# END Test testToolNovelDetails_Main diff --git a/tests/tools.py b/tests/tools.py index 6bc21966..2da23818 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -156,52 +156,54 @@ def cleanProject(path: str | Path): return -def buildTestProject(obj, projPath): +def buildTestProject(obj: object, projPath: Path) -> None: """Build a standard test project in projPath using the project object as the parent. """ from novelwriter.enum import nwItemClass + from novelwriter.guimain import GuiMain from novelwriter.core.project import NWProject if isinstance(obj, NWProject): nwGUI = None project = obj - else: + elif isinstance(obj, GuiMain): from novelwriter import SHARED nwGUI = obj project = SHARED.project + else: + return project.storage.createNewProject(projPath) project.setDefaultStatusImport() project.data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed") project.data.setName("New Project") - project.data.setTitle("New Novel") project.data.setAuthor("Jane Doe") # Creating a minimal project with a few root folders and a # single chapter folder with a single file. - xHandle = {} - xHandle[1] = project.newRoot(nwItemClass.NOVEL, "Novel") - xHandle[2] = project.newRoot(nwItemClass.PLOT, "Plot") - xHandle[3] = project.newRoot(nwItemClass.CHARACTER, "Characters") - xHandle[4] = project.newRoot(nwItemClass.WORLD, "World") - xHandle[5] = project.newFile("Title Page", xHandle[1]) - xHandle[6] = project.newFolder("New Chapter", xHandle[1]) - xHandle[7] = project.newFile("New Chapter", xHandle[6]) - xHandle[8] = project.newFile("New Scene", xHandle[6]) + nrHandle = project.newRoot(nwItemClass.NOVEL, "Novel") + project.newRoot(nwItemClass.PLOT, "Plot") + project.newRoot(nwItemClass.CHARACTER, "Characters") + project.newRoot(nwItemClass.WORLD, "World") - aDoc = project.storage.getDocument(xHandle[5]) + tdHandle = project.newFile("Title Page", nrHandle) + cfHandle = project.newFolder("New Chapter", nrHandle) or "" + cdHandle = project.newFile("New Chapter", cfHandle) + sdHandle = project.newFile("New Scene", cfHandle) + + aDoc = project.storage.getDocument(tdHandle) aDoc.writeDocument("#! New Novel\n\n>> By Jane Doe <<\n") - project.index.reIndexHandle(xHandle[5]) + project.index.reIndexHandle(tdHandle) - aDoc = project.storage.getDocument(xHandle[7]) + aDoc = project.storage.getDocument(cdHandle) aDoc.writeDocument("## %s\n\n" % project.tr("New Chapter")) - project.index.reIndexHandle(xHandle[7]) + project.index.reIndexHandle(cdHandle) - aDoc = project.storage.getDocument(xHandle[8]) + aDoc = project.storage.getDocument(sdHandle) aDoc.writeDocument("### %s\n\n" % project.tr("New Scene")) - project.index.reIndexHandle(xHandle[8]) + project.index.reIndexHandle(sdHandle) project.session.startSession() project.setProjectChanged(True)