diff --git a/.github/workflows/syntax.yml b/.github/workflows/syntax.yml
index 038480f7..bf8ffec3 100644
--- a/.github/workflows/syntax.yml
+++ b/.github/workflows/syntax.yml
@@ -25,5 +25,5 @@ jobs:
flake8 tests --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Coding Style Violations
run: |
- flake8 nw --count --max-line-length=99 --ignore E203,E221,E226,E241,E251,E261,E266,E302,E305 --show-source --statistics
- flake8 tests --count --max-line-length=99 --ignore E203,E221,E226,E241,E251,E261,E266,E302,E305 --show-source --statistics
+ flake8 nw --count --max-line-length=99 --ignore E203,E221,E226,E228,E241,E251,E261,E266,E302,E305 --show-source --statistics
+ flake8 tests --count --max-line-length=99 --ignore E203,E221,E226,E228,E241,E251,E261,E266,E302,E305 --show-source --statistics
diff --git a/.gitignore b/.gitignore
index 57dc50a9..66c21d61 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,8 @@
/deploy/
*.spec
*.egg-info
+setup.iss
+novelwriter.desktop
# Documentation
/docs/build/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d73e598..d93b0044 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,84 @@
# novelWriter ChangeLog
+## Version 1.0 Beta 5 [2020-10-18]
+
+**Important Notes**
+
+* The minimal supported Python version is now 3.6. While novelWriter has worked fine in the post with versions as low as 3.4, neither 3.4 nor 3.5 is tested. They have also both reached end of life. There are a couple of good reasons to drop support for older versions. PR #470.
+ 1. Python 3.6 introduces ordered dictionaries as the standard.
+ 2. The format string decorator (`f""`) was added in 3.6, and is much less clunky in many parts of the code than the full `"".format()` syntax.
+ 3. Especially 3.4 has limited support for `*var` expansion of iterables. These are used several places in the code.
+
+**Bugfixes**
+
+* Fixed a bug in the Build Novel Project tool where novelWriter would crash when trying to build the preview when running a version of the Qt library lower than 5.14. Issue #471, PR #472.
+
+**User Interface**
+
+* An option has been added in Preferences to hide horizontal or vertical scroll bars on the main GUI. These optons will hide scroll bars on the Project Tree, Document Editor, Document Viewer, Outline Tab and on the controls of the Build Novel Project tool. Scroll bars take up space, and as long as the project doesn't contain very long documents, scrolling with the mouse wheel is enough. The feature is of course entirely optional. PRs #468 and #469.
+* It is no possible to enable scrolling past the end of the document with a new option in Preferences. Previously, the editor would just allow scrolling to the bottom of the document. The new option adds a margin to the bottom of the document itself that allows for scrolling past this point. This avoids having to type text at the bottom of the editor window. PRs #468 and #469.
+* A new feature called "Typewriter Scrolling" has been added. It basically means that the editor window will try to keep the cursor at a given vertical position and instead scroll the document when the cursor moves to a new line, either by arrow keys or while typing. The position can also be defined in Preferences. The scroll bar uses an animation effect to perform the scrolling to avoid abrupt jumps in the editor window. PRs #468 and #474.
+* The line counter in the Document Editor footer now shows the location in the document in terms of percentage. This is convenient for very large documents. PR #474.
+* A "Follow Tag" option has been added to the Document Editor context menu. This option appears when right-clicking a tag value on a meta data line. PR #474.
+* When applying a format from the format menu to a selection of multiple paragraphs (or lines), only the first paragraph (or line) receives the formatting. The editor doesn't allow markdown formatting to span multiple lines. Issue #451, PR #475.
+* The syntax highlighter no longer uses the same colour to highlight strikethrough text as for emphasised text. The colour is intended to stand out, which makes little sense for such text. Instead, the highlighter uses the same colour as for comments. PR #476.
+
+**Other Changes**
+
+* Since support for Python < 3.6 has been dropped, it is now possible to use `f""` formatted strings in many more places in the source code where this is convenient. This has been implemented many places, but the code is still a mix of all three styles of formatting text. PR #478.
+* Extensive changes have been made to the build and distribute tools. The `install.py` file has been dropped, and the features in it merged into a new file named `make.py`. The make file can now also build a setup installer for Windows. The `setup.py` file has been rewritten to a more standardised source layout, and all the setup configuration moved to the `setup.cfg` file. PRs #479 and #480.
+
+
+## Version 1.0 Beta 4 [2020-10-11]
+
+**Bugfixes**
+
+* When the Trash folder didn't exist because nothing had been deleted yet, the lookup function for the Trash folder's handle returned `None`. That meant that any item with a parent handle `None` would be treated as a Trash folder in many parts of the code before the Trash folder was first used. This caused a few decision branches to make non-critical mistakes. In particular the project tree context menu. This issue has now been fixed with a new check function that takes this into account. PRs #452 and #453.
+* If an older project was opened, one with a different project file layout than the more recent versions, a dialog asked whether the user wants the project updated or not. However, the function that moves files to their new location would actually start working before the dialog asked for permission. The permission would only be applied to the project XML file. Now, the check is still run before the dialog, but the action of moving files around are postponed to after the permission has been given and the project XML file parsed. PR #453.
+* If there were multiple headings in a file, and the last paragraph did not end in a line break, the word counter for the individual sections would miss the last paragraph of the last section due to an indexing error. This has now been fixed. PR #453.
+* The last cursor position of a document in the editor would only be saved if the document had been altered. It is now also saved in the cases where the user makes no changes. PR #460.
+* When using an aspell dictionary for spell checking, words containing a hyphen would be highlighted as misspelled. This is not the case for hunspell dictionaries. The hyphen is now taken into account when splitting sentences into words for spell check highlighting. PR #462.
+* Some of the file dialogs would fail with a non-critical error when the cancel button was clicked. The cancel is now captured consistently in all instances where such a dialog is used, and the calling function exited properly. PR #463.
+
+**User Interface**
+
+* Some minor changes to the text formatting on the Recent Projects dialog. PR #452.
+* The Build Novel Project tool has been improved. The settings side panel is now scrollable, and the document and settings panel now have a movable splitter between them. This gives more flexibility to the sizes of the various parts. PR #459.
+* A new option to replace tabs with spaces has been added to the Build Novel Project tool. Previously, they were always replaces for HTML output. However, converting them to the HTML code for a tab is actually convenient for later import into for instance Libre Office, which then converts them back to regular tabs. Issue #458, PR #459.
+* Non-breaking spaces have been removed from the HTML conversion of keywords and tags. Issue #458, PR #459.
+* An upper limit of how large a document the Build Novel Project tool can view has been set. It is 10 megabytes of generated HTML. The tool will still build larger documents, but they aren't displayed. This also limits which options are available in the "Save As" list for such large documents. Only native novelWriter exports are supported in such cases. The limit is an order of magnitude larger than a typical long novel. PR #460.
+* The language indicator in the status bar now has a tooltip stating what tool and spell check dictionary provider is being used. PR #462.
+* All representations of integers, mostly word counts, are now presented in the same way. They should all use a thousand separator representation defined by the local language settings. PR #464.
+* Many parts of the GUI have had a spin/wait cursor added for processes that may take a while and will block the GUI in the meantime. PRs #460, #463 and #464.
+* A line counter has been added to the footer of the document editor next to the word counter. It makes it easier to compare the position in the document when also accessing it in an external editor. PR #466.
+
+**Improvements for macOS**
+
+* The native macOS menu bar now pulls the correct menu entries into the first menu column. PR #463.
+* The application name in the main menu would state Python instead of novelWriter. As long as the `pyobjc` package is installed, the label will now correctly state novelWriter. PR #463.
+* Install and run instructions for macOS have been added to the main README. PR #463.
+
+**Editor Performance**
+
+* The syntax highlighter now remembers what type of line every line in the document is. This means that certain types of lines can be re-highlighted without having to process the entire document again. This is particularly useful for refreshing the highlighting of keywords and tags after the index has been rebuilt. PR #460.
+* On a few occasions, the entire document in the editor would be reloaded in order to update the layout and formatting. This is not only slow for big documents, it also resets the undo stack. Instead, the entire document is "marked as dirty" to force the Qt library to update the layout, which is much faster. PR #460.
+* For very large documents (in the megabyte range), the repositioning of the cursor when the document was opened would sometimes interfere with the rendering of the document itself. This could potentially cause the editor to hang for up to a couple of minutes. Instead, the repositioning of the cursor is now postponed until the document layout size has reached past the character where the cursor is to be moved. This mode is only used for documents larger than 50 kilobytes. PR #460.
+* The document editor will no longer accept single documents larger than 5 megabytes. This restriction has also been applied to the Build Novel Project tool. For reference, a typical long novel is less than 1 megabyte in size. PR #460.
+
+**Other Changes**
+
+* The command line switches `--quiet` and `--logfile=` have been removed. They were intended for testing, but have never been used. The default mode of only printing warnings and errors is quiet enough, and logging to file shouldn't be necessary for a GUI application. PR #453.
+* A number of if-statements and conditions in the code that were intended to alter behaviour when running tests, mostly to stop modal dialogs from blocking the main thread, have been removed. These types of changes to the program flow when running tests have now been reduced to a minimum, and modifications instead handled with pytest monkeypatches. PR #453.
+* The `QtSvg` package is no longer in use by novelWriter. The internal dependency check has been removed. PR #457.
+* It is no longer possible to set the user's home folder as the root directory of a project. The home folder is the default lookup folder in many cases, so it's easy to do by mistake. PR #457.
+* The background word counter has been rewritten to run on an application wide thread pool. This is a more appropriate way of running background tasks. PR #462.
+
+**Test Suite**
+
+* Major additions to the test suite, taking the test coverage to 91%. PR #453.
+* Test coverage for Linux (Ubuntu) for Python versions 3.6, 3.7, and 3.8 are now separate jobs. In addition, Windows with Python 3.8 and macOS with Python 3.8 is also tested. All OSes are piped into test coverage, and they all have status badges. PRs #453 and #454.
+
+
## Version 1.0 Beta 3 [2020-09-20]
**Bugfixes**
diff --git a/MANIFEST.in b/MANIFEST.in
index 36a99646..b3004fc3 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,4 @@
include LICENSE.md
-recursive-include assets *
+recursive-include setup *
recursive-include nw/assets *
recursive-include sample *.nwx *.nwd
diff --git a/README.md b/README.md
index a5e6aea7..72ae5824 100644
--- a/README.md
+++ b/README.md
@@ -12,11 +12,11 @@
[](https://pypi.org/project/novelWriter)
[](https://pypi.org/project/novelWriter)
-
+
novelWriter is a Markdown-like text editor designed for writing novels and larger projects of many
smaller plain text documents. It uses its own flavour of Markdown that supports a meta data syntax
-for comments, synopsis and cross-referencing between files. It's designed to be a simple text editor
+for comments, synopsis, and cross-referencing between files. It's designed to be a simple text editor
that allows for easy organisation of text files and notes, built on plain text files for
robustness.
@@ -29,19 +29,6 @@ The full documentation is available at [novelwriter.readthedocs.io](https://nove
The contributing guide is available in [CONTRIBUTING](CONTRIBUTING.md).
-### Note on the Default Branch
-
-The default branch on this repository switched to `main` on 6. August 2020. If you are running
-novelWriter from a git clone, you need to clone the repository again.
-
-Alternatively, you can run the following to get back on the new default branch:
-
-```bash
-git remote update
-git checkout -t origin/main
-```
-
-
### Development Status
The application is still under initial development, but all core features have now been added. The
@@ -49,15 +36,230 @@ core functionality has been in place for a while, and novelWriter is being used
by the author and collaborators.
No new major features will be added at this time, until the application is stable. Until then,
-novelWriter is in a _beta_ state. Please report any issues you may encounter in the repository issue
+novelWriter is in a _beta_ state. Please report any issues you encounter via the repository's issue
tracker.
You should be able to use novelWriter for real projects, but as with all software, please make
regular backups of your data. There is a built in backup feature that can pack the entire project
-into a zip file on close. Please check the documentation for further details.
+into a zip file each time the main window or the project is closed. Please check the documentation
+for further details.
-## License
+## Implementation
+
+The application is written in Python 3 using Qt5 via PyQt5. It is developed on Linux, but it should
+in principle work fine on other operating systems as well as long as dependencies are met. The unit
+tests are run on the latest versions of Ubuntu Linux, Windows Server and macOS.
+
+
+# Installing and Running
+
+novelWriter is available on [pypi.org](https://pypi.org/project/novelWriter/), and can be installed with:
+```bash
+pip install novelwriter
+```
+
+To upgrade an existing installation, use:
+```bash
+pip install --upgrade novelwriter
+```
+
+Dependencies are installed automatically, but can generally be installed with:
+```bash
+pip install -r requirements.txt
+```
+
+Below are some brief instructions on how to get started on different operating systems.
+
+
+## Linux
+
+Either download the source, or install with pip.
+
+If you run from source, install the dependencies via pip, or directly from the OS repo.
+There are very few dependencies, and they should be available in the standard repo.
+The Python packages needed are `pyqt5`, `lxml` and `pyenchant`.
+
+### Installing from Source
+
+You can also install novelWriter from source with:
+```bash
+python3 setup.py sample
+sudo python3 setup.py install
+sudo python3 setup.py launcher
+```
+
+The last line will install the application icons and set up a launcher for novelWriter.
+The method uses hardcoded paths, so it may or may not work for your Linux distro.
+If you have any issues, please submit a ticket so the script can be tuned.
+
+The script may prompt you to choose which executable to configure if it finds more than one.
+
+### Running from Source
+
+If you want to run directly from the source, the application can be started with:
+```bash
+./novelWriter.py
+```
+
+You can also create a launcher for running directly from source with:
+```bash
+python3 setup.py xdg-install
+```
+This will install the launcher and icons for the current user.
+To install them system-wide, run the above command with `sudo` or as root.
+
+For more install options, see [Build and Install novelWriter](setup/README.md).
+
+
+## macOS
+
+These instructions assume you're using brew, and have Python and pip set up.
+If not, see the [brew docs](https://docs.brew.sh/Homebrew-and-Python) for help.
+
+Main requirements are installed via the requirements file.
+You also need to install the `pyobjc` package on macOS, so you must run:
+```bash
+pip3 install --user -r requirements.txt
+pip3 install --user pyobjc
+```
+
+For spell checking you may also need to install the enchant package.
+It comes with a lot of default dictionaries.
+```bash
+brew install enchant
+```
+
+## Windows
+
+On Windows, you may first need to install Python.
+See the [python.org](https://www.python.org/) website for download packages.
+It is recommended that you install the latest version of Python 3.8.
+
+To install dependencies, run:
+```bash
+pip install --user -r requirements.txt
+```
+
+**Note:** On Windows, make sure Python3 is in your PATH if you want to launch novelWriter from
+command line. You can also right click the `novelWriter.py` file, create a shortcut, then right
+click again, select "Properties" and change the target to your python executable and
+`novelWriter.py`.
+
+It should look something like this:
+```
+C:\...\AppData\Local\Programs\Python\Python38\python.exe novelWriter.py
+```
+
+You can also run the `make.py` script to generate a single executable, or an installer.
+See [Build and Install novelWriter](setup/README.md) for more details.
+
+
+## Package Versions
+
+Exporting to Markdown requires PyQt/Qt 5.14. There are no known minimum for `lxml`, but the code
+was originally written with 4.2. The optional spell check library must be at least 3.0.0 to work
+with Windows 64 bit systems. On Linux, 2.0.0 also works fine.
+
+If no external spell checking tool is installed, novelWriter will use a basic spell checker based on
+standard Python package `difflib`. Currently, only English dictionaries are available for this spell
+checker, but more can be added to the `nw/assets/dict` folder. See the [README](nw/assets/dict/README.md)
+file in that folder for how to generate more dictionaries. Note that the difflib-based option is
+both slow and limited.
+
+## Debugging
+
+If you need to debug novelWriter, you must run it from command line.
+It takes a few parameters, which can be listed with the switch `--help`.
+The `--info`, `--debug` or `--verbose` flags are particularly useful for increasing logging output
+for debugging.
+
+
+# Key Features
+
+Some features of novelWriter are listed below. Consult the documentation for more information.
+
+### Markdown Flavour
+
+novelWriter is _not_ a full-feature Markdown editor. It allows for a minimal set of formatting
+needed for writing text documents for novels. These are currently limited to:
+
+* Headings level 1 to 4 using the `#` syntax only.
+* Emphasised and strong text. These are rendered as italicised and bold.
+* Strikethrough text.
+* Hard line breaks using two or more spaces at the end of a line.
+
+That is it. Features not supported in the editor are also not exported when using the export tool.
+
+In addition, novelWriter adds the following, which is otherwise not supported by Markdown:
+
+* A line starting with `%` is treated as a comment and not rendered on exports unless requested.
+ Comments do not count towards the word count. If the first word of the comment is `synopsis:`, the
+ comment is indexed and treated as the synopsis for the section of text under the same header.
+ These synopsis comments can be used to build an outline and exported to external documents.
+* A set of meta data keyword/values starting with the character `@`. This is used for tagging
+ and inter-linking documents, and can be used to generate a project outline.
+* Non-breaking spaces are supported as long as your system is using at least Qt 5.9. For earlier
+ version, non-breaking spaces are converted to normal spaces when saving the document. This is done
+ by the Qt library.
+* Thin spaces are also supported, as well as non-breaking thin spaces, with the same library version
+ restriction as above.
+* Tabs can be used in the text, and should be properly aligned. The width of a tab in pixels can be
+ changed in Preferences. Note that for the HTML format, most browsers will treat a tab as a space,
+ so it may not show up like expected. If you import the HTML file to Libre Office, for instance,
+ they should appear as expected.
+
+The core export format of novelWriter is HTML5. You can also export the entire project as a single
+novelWriter Markdown-flavour document. In addition, other exports to Open Document, PDF, and plain
+text is offered through the Qt library, although with limitations to formatting.
+
+The HTML format is well suited for file conversion tools and import into other text editors.
+
+
+### Colour Themes
+
+The editor has syntax highlighting for the features it supports, and includes a set of different
+syntax highlighting themes. The GUI also has an optional dark theme in addition to the default
+system theme.
+
+New themes can easily be added to the `nw/assets/themes` folder. Have a look in the existing folders
+for examples of how to define the colours.
+
+
+### Easy Organising of Project Files
+
+The structure of the project is shown on the left hand side of the main GUI. Project files are
+organised into root folders, indicating what class of file they are. The most important root folder
+is the Novel folder, which contains all of the files that makes up the finished novel. Each root
+folder can have subfolders. Folders have no impact on the final project structure, they are purely
+tools for organising the files in whatever way the user needs.
+
+The editor supports four levels of headings, which determines what level the following text belongs
+to. Headings of level one signify a book or partition title. Headings of level two signify the start
+of a new chapter. Headings of level three signify the start of a new scene. Headings of level four
+can be used internally in each scene to separate sections.
+
+Each novel file can be assigned a layout format, which shows up as a flag next to the item in the
+project tree. These are mostly to help the user track what they contain, but they also have some
+impact on the format of the exported document. See the documentation for further details.
+
+
+#### Project Notes
+
+Supporting note files can be added for the story plot, characters, locations, story timeline, etc.
+These have their separate root folders. These are optional files.
+
+
+### Visualisation of Story Elements
+
+The different notes can be assigned tags, which other files can refer back to using the `@` meta
+keywords. This information can be used to display an outline of the story, showing where each scene
+connects to the plot, and which characters, etc. occur in them. In addition, the tags themselves are
+clickable in the document view pane, and control-clickable in the editor. They make it possible to
+quickly navigate between the documents while editing.
+
+
+## Licenses
This is Open Source software, and novelWriter is licensed under GPLv3. See the
[GNU General Public License website](https://www.gnu.org/licenses/gpl-3.0.en.html) for more details,
@@ -79,171 +281,6 @@ Bundled assets have the following licenses:
main repo is available at [sdras/night-owl-vscode-theme](https://github.com/sdras/night-owl-vscode-theme).
-## Markdown Flavour
-
-novelWriter is _not_ a full-feature Markdown editor. It allows for a minimal set of formatting
-needed for writing text documents for novels. These are currently limited to:
-
-* Headings level 1 to 4 using the `#` syntax only.
-* Emphasised and strong text. These are rendered as italicised and bold.
-* Strikethrough text.
-* Hard line breaks using two or more spaces at the end of a line.
-
-That is it. Features not supported in the editor are also not exported when using the export tool.
-
-In addition, novelWriter adds the following, which is otherwise not supported by Markdown:
-
-* A line starting with `%` is treated as a comment and not rendered on exports unless requested.
- Comments do not count towards the word count. If the first word of the comment is `synopsis:`, the
- comment is indexed and treated as the synopsis for the following section of text. These synopsis
- comments can be used to build an outline and exported to external documents.
-* A set of meta data keyword/value sets starting with the character `@`. This is used for tagging
- and inter-linking documents.
-* Non-breaking spaces are supported as long as your system is using at least Qt 5.9. For earlier
- version, non-breaking spaces are converted to normal spaces when saving the document. This is done
- by the Qt library.
-* Thin spaces are also supported, as well as non-breaking thin spaces.
-* Tabs can be used in the text, and should be properly aligned. The width of a tab in pixels can be
- changed in Preferences. Note that tabs are exported as-is, also to HTML format. However, most
- browsers will treat a tab as a space, so it may not show up like expected if you view the exported
- HTML file.
-
-The core export format of novelWriter is HTML5. You can also export the entire project as a single
-novelWriter Markdown-flavour document. In addition, other exports to Open Document, PDF, and plain
-text is offered through the Qt library, although with limitations to formatting.
-
-
-## Implementation
-
-The application is written in Python3 using Qt5 via PyQt5. It is developed on Linux, but it should
-in principle work fine on other operating systems as well, as long as dependencies are met. It is
-regularly tested on Windows 10.
-
-The application can be started from the source folder with one of the commands, depending on your
-Python configuration:
-```bash
-./novelWriter.py
-python novelWriter.py
-python3 novelWriter.py
-```
-
-It also takes a few parameters for debugging and such, which can be listed with the switch `--help`.
-
-In the root assets folder there are icons and scripts and a template for setting up a launcher on
-Gnome desktops. You may need to modify those scripts slightly, but as they are, they work on Debian
-and Ubuntu. For other operating systems, please consult your operating system documentation for how
-to make those. Feel free to submit more if you are able to make them.
-
-
-## Package Dependencies
-
-It is recommended that novelWriter runs with Qt 5.10 or later, and Python 3.6 or later. Running with
-Qt as low as 5.2.1 and Python 3.4.3 has been tested, and worked in the past, but there are no
-guarantees that this will keep working as these are not a part of the test builds.
-
-For the apt package manager on Debian/Ubuntu systems, the following Python3 packages are needed:
-
-* `python3-pyqt5` for the GUI
-* `python3-lxml` for writing project files
-
-These are optional, but recommended:
-
-* `python3-enchant` for better spell checking
-
-Alternatively, the packages can be installed with `pip` by running
-```bash
-pip install -r requirements.txt
-```
-
-in the application folder.
-
-You can also do them one at a time, skipping the ones you don't need:
-```bash
-pip install pyqt5
-pip install lxml
-pip install pyenchant
-```
-
-PyQt/Qt should be at least 5.3, but ideally 5.10 or higher for nearly all features to work.
-Exporting to Markdown requires PyQt/Qt 5.14. There are no known minimum for `lxml`, but the code
-was originally written with 4.2. The optional spell check library must be at least 3.0.0 to work
-with Windows 64 bit systems. On Linux, 2.0.0 also works fine.
-
-If no external spell checking tool is installed, novelWriter will use a basic spell checker based on
-standard Python package `difflib`. Currently, only English dictionaries are available for this spell
-checker, but more can be added to the `nw/assets/dict` folder. See the [README](nw/assets/dict/README.md)
-file in that folder for how to generate more dictionaries. Note that the difflib-based option is
-both slow and limited.
-
-Note: On Windows, make sure Python3 is in your PATH if you want to launch novelWriter from command
-line. You can also right click the `novelWriter.py` file, create a shortcut, then right click again,
-select "Properties" and change the target to your python executable and `novelWriter.py`.
-
-It should look something like this:
-```
-C:\...\AppData\Local\Programs\Python\Python38\python.exe novelWriter.py
-```
-
-## Key Features
-
-Some features of novelWriter are listed below. Consult the documentation for more information.
-
-
-### Colour Themes
-
-The editor has syntax highlighting for the features it supports, and includes a set of different
-syntax highlighting themes. The GUI also has an optional dark theme in addition to the default
-system theme.
-
-New themes can easily be added to the `nw/assets/themes` folder. Have a look in the existing folders
-for examples of how to define the colours.
-
-
-### Auto-Saving and Document Stats
-
-Open documents and the project file itself is saved regularly on a timer. The status of this is
-indicated by two indicators on the right hand side of the status bar. Latest project word count is
-shown next to these indicators in the status bar. The counts are updated regularly, but not
-as-you-type.
-
-The word count for documents is presented in a footer in the document editor itself. Both project
-and document word counters will also show how many words you've added in the current writing
-session.
-
-
-### Easy Organising of Project Files
-
-The structure of the project is shown on the left hand side of the main GUI. Project files are
-organised into root folders, indicating what class of file they are. The most important root folder
-is the Novel folder, which contains all of the files that makes up the finished novel. Each root
-folder can have subfolders. Folders have no impact on the project structure, they are purely tools
-for organising the files in whatever way the user needs.
-
-The editor supports four levels of headings, which determines what level the following text belongs
-to. Headings of level one signify a book or partition title. Headings of level two signify the start
-of a new chapter. Headings of level three signify the start of a new scene. Headings of level four
-can be used internally in each scene to separate sections.
-
-Each novel file can be assigned a layout format, which shows up as a flag next to the item in the
-project tree. These are mostly to help the user see what they contain, but they also have some
-impact on the format of the exported document. See the documentation for further details.
-
-
-#### Project Notes
-
-Supporting note files can be added for the story plot, characters, locations, story timeline, etc.
-These have their separate root folders. These are optional files.
-
-
-### Visualisation of Story Elements
-
-The different notes can be assigned tags, which other files can refer back to using special meta
-keywords. This information can be used to display an outline of the story, showing where each scene
-connects to the plot, and which characters, etc. occur in them. In addition, the tags themselves are
-clickable in the document view pane, and control-clickable in the editor. They make it possible to
-quickly navigate between the documents while editing.
-
-
## Screenshot
**novelWriter with default system theme:**
diff --git a/assets/installDebian.sh b/assets/installDebian.sh
deleted file mode 100755
index 3f5ae392..00000000
--- a/assets/installDebian.sh
+++ /dev/null
@@ -1,49 +0,0 @@
-#!/bin/bash
-
-cd ..
-
-EXEC=$(pwd)/novelWriter.py
-EXEC=$(echo $EXEC | sed 's_/_\\/_g')
-
-sed "s/%%exec%%/$EXEC/g" assets/novelwriter.desktop > /usr/share/applications/novelwriter.desktop
-
-if [ ! -d /usr/share/icons/hicolor/24x24/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/24x24/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/48x48/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/48x48/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/96x96/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/96x96/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/256x256/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/256x256/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/512x512/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/512x512/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/scalable/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/scalable/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/scalable/mimetypes ]; then
- mkdir -pv /usr/share/icons/hicolor/scalable/mimetypes
-fi
-
-cp -v assets/icons/24x24/novelwriter.png /usr/share/icons/hicolor/24x24/apps/
-cp -v assets/icons/48x48/novelwriter.png /usr/share/icons/hicolor/48x48/apps/
-cp -v assets/icons/96x96/novelwriter.png /usr/share/icons/hicolor/96x96/apps/
-cp -v assets/icons/256x256/novelwriter.png /usr/share/icons/hicolor/256x256/apps/
-cp -v assets/icons/512x512/novelwriter.png /usr/share/icons/hicolor/512x512/apps/
-cp -v assets/icons/novelwriter.svg /usr/share/icons/hicolor/scalable/apps/
-cp -v assets/icons/x-novelwriter-project.svg /usr/share/icons/hicolor/scalable/mimetypes/application-x-novelwriter-project.svg
-cp -v assets/mime/x-novelwriter-project.xml /usr/share/mime/packages/
-
-update-mime-database /usr/share/mime/
-update-icon-caches /usr/share/icons/hicolor/24x24/apps/
-update-icon-caches /usr/share/icons/hicolor/48x48/apps/
-update-icon-caches /usr/share/icons/hicolor/96x96/apps/
-update-icon-caches /usr/share/icons/hicolor/256x256/apps/
-update-icon-caches /usr/share/icons/hicolor/512x512/apps/
-update-icon-caches /usr/share/icons/hicolor/1024x1024/apps/
-update-icon-caches /usr/share/icons/hicolor/scalable/apps/
-update-icon-caches /usr/share/icons/hicolor/scalable/mimetypes/
diff --git a/assets/installUbuntu.sh b/assets/installUbuntu.sh
deleted file mode 100755
index 91e78409..00000000
--- a/assets/installUbuntu.sh
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/bin/bash
-
-cd ..
-
-EXEC=$(pwd)/novelWriter.py
-EXEC=$(echo $EXEC | sed 's_/_\\/_g')
-
-sed "s/%%exec%%/$EXEC/g" assets/novelwriter.desktop > /usr/share/applications/novelwriter.desktop
-
-if [ ! -d /usr/share/icons/hicolor/24x24/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/24x24/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/48x48/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/48x48/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/96x96/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/96x96/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/256x256/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/256x256/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/512x512/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/512x512/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/scalable/apps ]; then
- mkdir -pv /usr/share/icons/hicolor/scalable/apps
-fi
-if [ ! -d /usr/share/icons/hicolor/scalable/mimetypes ]; then
- mkdir -pv /usr/share/icons/hicolor/scalable/mimetypes
-fi
-
-cp -v assets/icons/24x24/novelwriter.png /usr/share/icons/hicolor/24x24/apps/
-cp -v assets/icons/48x48/novelwriter.png /usr/share/icons/hicolor/48x48/apps/
-cp -v assets/icons/96x96/novelwriter.png /usr/share/icons/hicolor/96x96/apps/
-cp -v assets/icons/256x256/novelwriter.png /usr/share/icons/hicolor/256x256/apps/
-cp -v assets/icons/512x512/novelwriter.png /usr/share/icons/hicolor/512x512/apps/
-cp -v assets/icons/novelwriter.svg /usr/share/icons/hicolor/scalable/apps/
-cp -v assets/icons/x-novelwriter-project.svg /usr/share/icons/hicolor/scalable/mimetypes/application-x-novelwriter-project.svg
-cp -v assets/mime/x-novelwriter-project.xml /usr/share/mime/packages/
-
-update-mime-database /usr/share/mime/
-update-icon-caches /usr/share/icons/*
diff --git a/docs/source/conf.py b/docs/source/conf.py
index afeadcd5..e8607fed 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -12,7 +12,6 @@
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
-# import os
# import sys
# sys.path.insert(0, os.path.abspath("."))
import os
@@ -28,7 +27,7 @@ author = "Veronica Berglyd Olsen"
# The short X.Y version
version = "1.0"
# The full version, including alpha/beta/rc tags
-release = "1.0-beta3"
+release = "1.0-beta5"
# -- General configuration ---------------------------------------------------
diff --git a/docs/source/int_introduction.rst b/docs/source/int_introduction.rst
index 6ca43b5f..66ca0351 100644
--- a/docs/source/int_introduction.rst
+++ b/docs/source/int_introduction.rst
@@ -46,7 +46,7 @@ than the document editor itself are hidden away.
The colour scheme of the user interface defaults to that of the host operating system. In addition,
a dark theme is provided, and can be enabled in :guilabel:`Preferences` from the :guilabel:`Tools`
menu. A number of syntax highlighting themes are also available in :guilabel:`Preferences`. A set of
-icon themes in colour and greyscale are also offered. The icons are based on the Typicon_ icon set
+icon themes in colour and greyscale are also offered. The icons are based on the Typicons_ icon set
designed by Stephen Hutchings.
The main window is split in two, or optionally three, panels. The left-most contains the project
@@ -58,7 +58,7 @@ entire novel structure can be displayed, with all the tags and references listed
you structure your novel project files, this outline can be quite different than your project tree.
Your project tree lists files, your Outline tree lists the structure of the novel itself.
-.. _Typicon: https://github.com/stephenhutchings/typicons.font
+.. _Typicons: https://github.com/stephenhutchings/typicons.font
.. _a_intro_project:
diff --git a/docs/source/int_started.rst b/docs/source/int_started.rst
index e96a0285..0d0236c8 100644
--- a/docs/source/int_started.rst
+++ b/docs/source/int_started.rst
@@ -140,7 +140,7 @@ encountered. To list all options, run:
python novelWriter.py --help
-There are also a couple of install scripts in the assets folder which will assist in setting up a
+There are also a couple of install scripts in the setup folder which will assist in setting up a
launch icon and the novelWriter project file mimetype for Gnome desktops on Linux. Currently,
there's one script for Debian and one for Ubuntu.
diff --git a/install.py b/install.py
deleted file mode 100755
index ea7c976d..00000000
--- a/install.py
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-
-import os
-import sys
-import getopt
-import subprocess
-
-# Defaults
-buildWindowed = True
-
-# Parse Options
-shortOpt = "hd"
-longOpt = [
- "help",
- "debug",
-]
-helpMsg = (
- "\n"
- "novelWriter Install Script\n"
- "\n"
- "Usage:\n"
- " -h, --help Print this message.\n"
- " -d, --debug Build novelWriter for debugging. To debug novelwriter after build,\n"
- " run it from command line with the debug options. Please check the\n"
- " novelWriter --help output for details.\n"
-)
-
-try:
- inOpts, inArgs = getopt.getopt(sys.argv[1:], shortOpt, longOpt)
-except getopt.GetoptError:
- print(helpMsg)
- sys.exit(2)
-
-for inOpt, inArg in inOpts:
- if inOpt in ("-h", "--help"):
- print(helpMsg)
- sys.exit()
- elif inOpt in ("-d", "--debug"):
- buildWindowed = False
-
-# Run pip
-packList = ["pyinstaller"]
-with open("requirements.txt", mode="r") as reqFile:
- for reqPack in reqFile:
- if len(reqPack.strip()) > 0:
- packList.append(reqPack)
-
-for packName in packList:
- print("Installing package dependency: %s" % packName)
- try:
- subprocess.call([sys.executable, "-m", "pip", "install", packName])
- except Exception as e:
- print("Failed with error:")
- print(str(e))
-
-# Run pyinstaller
-if sys.platform.startswith("win32"):
- dotDot = ";"
-else:
- dotDot = ":"
-
-instOpt = [
- "--name=novelWriter",
- "--clean",
- "--onefile",
- "--add-data=%s%s%s" % (os.path.join("nw", "assets"), dotDot, "assets"),
- "--icon=%s" % os.path.join("nw", "assets", "icons", "novelwriter.ico"),
-]
-if buildWindowed:
- instOpt.append("--windowed")
-
-instOpt.append("novelWriter.py")
-
-import PyInstaller.__main__ # noqa: F401
-PyInstaller.__main__.run(instOpt)
-
-print("")
-print("##################")
-print(" Build Finished")
-print("##################")
-print("")
-print("If everything went well, the novelWriter executable should be in the folder named 'dist'")
-print("")
diff --git a/make.py b/make.py
new file mode 100755
index 00000000..859ad8ca
--- /dev/null
+++ b/make.py
@@ -0,0 +1,333 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+This make script is intended for building distributable packages of
+novelWriter. These are either:
+
+ * A single file executable named dist/novelWriter(.exe). This is a
+ quite slow option, and the file is fairly big.
+ * A single directory named dist/novelWriter with a novelWriter(.exe),
+ and all dependecies included.
+ * The latter can be combined with a build stage of a setup.exe file if
+ on Windows. This requires Inno Setup to be installed and in path.
+
+In addition, providing the pip otion will cause the script to try to
+install all dependencies needed for runing the build, and for running
+novelWriter itself.
+"""
+
+import os
+import sys
+import shutil
+import subprocess
+
+OS_NONE = 0
+OS_LINUX = 1
+OS_WIN = 2
+OS_DARWIN = 3
+
+# =============================================================================================== #
+# Package Installer
+# =============================================================================================== #
+
+def installPackages(hostOS):
+ """Install package dependencies both for this script and for running
+ novelWriter itself.
+ """
+ print("")
+ print("Installing Dependencies")
+ print("#######################")
+ print("")
+
+ installQueue = ["pip", "pyinstaller", "-r requirements.txt"]
+ if hostOS == OS_DARWIN:
+ installQueue.append("pyobjc")
+
+ pyCmd = [sys.executable, "-m"]
+ pipCmd = ["pip", "install", "--user", "--upgrade"]
+ for stepCmd in installQueue:
+ pkgCmd = stepCmd.split(" ")
+ try:
+ subprocess.call(pyCmd + pipCmd + pkgCmd)
+ except Exception as e:
+ print("Failed with error:")
+ print(str(e))
+ sys.exit(1)
+
+ return
+
+# =============================================================================================== #
+# Run PyInstaller on Package
+# =============================================================================================== #
+
+def freezePackage(buildWindowed, oneFile, makeSetup, hostOS):
+ """Run PyInstaller to freeze the packages. This assumes all
+ dependencies are already in place.
+ """
+ import PyInstaller.__main__ # noqa: E402
+
+ print("")
+ print("Running PyInstaller")
+ print("###################")
+ print("")
+
+ if hostOS == OS_WIN:
+ dotDot = ";"
+ else:
+ dotDot = ":"
+
+ sys.modules["FixTk"] = None
+ instOpt = [
+ "--name=novelWriter",
+ "--clean",
+ "--add-data=%s%s%s" % (os.path.join("nw", "assets"), dotDot, "assets"),
+ "--icon=%s" % os.path.join("nw", "assets", "icons", "novelwriter.ico"),
+ "--exclude-module=PyQt5.QtQml",
+ "--exclude-module=PyQt5.QtBluetooth",
+ "--exclude-module=PyQt5.QtDBus",
+ "--exclude-module=PyQt5.QtMultimedia",
+ "--exclude-module=PyQt5.QtMultimediaWidgets",
+ "--exclude-module=PyQt5.QtNetwork",
+ "--exclude-module=PyQt5.QtNetworkAuth",
+ "--exclude-module=PyQt5.QtNfc",
+ "--exclude-module=PyQt5.QtQuick",
+ "--exclude-module=PyQt5.QtQuickWidgets",
+ "--exclude-module=PyQt5.QtRemoteObjects",
+ "--exclude-module=PyQt5.QtSensors",
+ "--exclude-module=PyQt5.QtSerialPort",
+ "--exclude-module=PyQt5.QtSql",
+ "--exclude-module=FixTk",
+ "--exclude-module=tcl",
+ "--exclude-module=tk",
+ "--exclude-module=_tkinter",
+ "--exclude-module=tkinter",
+ "--exclude-module=Tkinter",
+ ]
+
+ if buildWindowed:
+ instOpt.append("--windowed")
+
+ if oneFile and not makeSetup:
+ instOpt.append("--onefile")
+ else:
+ instOpt.append("--onedir")
+
+ instOpt.append("novelWriter.py")
+
+ # Make sample.zip first
+ try:
+ subprocess.call([sys.executable, "setup.py", "sample"])
+ except Exception as e:
+ print("Failed with error:")
+ print(str(e))
+ sys.exit(1)
+
+ PyInstaller.__main__.run(instOpt)
+
+ if not oneFile:
+ # These files are not needed, and take up a fair bit of space.
+ delFiles = []
+ if hostOS == OS_WIN:
+ delFiles = [
+ "Qt5DBus.dll",
+ "Qt5Network.dll",
+ "Qt5Qml.dll",
+ "Qt5QmlModels.dll",
+ "Qt5Quick.dll",
+ "Qt5Quick3D.dll",
+ "Qt5Quick3DAssetImport.dll",
+ "Qt5Quick3DRender.dll",
+ "Qt5Quick3DRuntimeRender.dll",
+ "Qt5Quick3DUtils.dll",
+ "Qt5Sql.dll"
+ ]
+ elif hostOS == OS_LINUX:
+ delFiles = [
+ "libQt5DBus.so.5",
+ "libQt5Network.so.5",
+ "libQt5Qml.so.5",
+ "libQt5QmlModels.so.5",
+ "libQt5Quick.so.5",
+ "libQt5Quick3D.so.5",
+ "libQt5Quick3DAssetImport.so.5",
+ "libQt5Quick3DRender.so.5",
+ "libQt5Quick3DRuntimeRender.so.5",
+ "libQt5Quick3DUtils.so.5",
+ "libQt5Sql.so.5"
+ ]
+ distDir = os.path.join(os.getcwd(), "dist", "novelWriter")
+ for delFile in delFiles:
+ delPath = os.path.join(distDir, delFile)
+ if os.path.isfile(delPath):
+ print("Deleting file: %s" % delPath)
+ os.unlink(delPath)
+
+ print("")
+ print("Build Finished")
+ print("")
+ print("The novelWriter executable should be in the folder named 'dist'")
+ print("")
+
+ return
+
+# =============================================================================================== #
+# Inno Setup Builder
+# =============================================================================================== #
+
+def innoSetup():
+ """Run the Inno Setup tool to build a setup.exe file for Windows.
+ """
+ print("")
+ print("Running Inno Setup")
+ print("##################")
+ print("")
+
+ # Read the iss template
+ issData = ""
+ with open(os.path.join("setup", "win_setup.iss"), mode="r") as inFile:
+ issData = inFile.read()
+
+ import nw # noqa: E402
+ issData = issData.replace(r"%%version%%", nw.__version__)
+ issData = issData.replace(r"%%dir%%", os.getcwd())
+
+ with open("setup.iss", mode="w+") as outFile:
+ outFile.write(issData)
+
+ try:
+ subprocess.call(["iscc", "setup.iss"])
+ except Exception as e:
+ print("Inno Setup failed with error:")
+ print(str(e))
+ sys.exit(1)
+
+ return
+
+# =============================================================================================== #
+# Clean Build and Dist Folders
+# =============================================================================================== #
+
+def cleanInstall():
+ """Recursively delete the 'build' and 'dist' folders.
+ """
+ print("")
+ print("Cleaning up build environment ...")
+
+ buildDir = os.path.join(os.getcwd(), "build")
+ if os.path.isdir(buildDir):
+ try:
+ shutil.rmtree(buildDir)
+ print("Deleted folder 'build'")
+ except Exception as e:
+ print("Error: Cannot delete 'build' folder.")
+ print(str(e))
+ sys.exit(1)
+ else:
+ print("Folder 'build' not found")
+
+ distDir = os.path.join(os.getcwd(), "dist")
+ if os.path.isdir(distDir):
+ try:
+ shutil.rmtree(distDir)
+ print("Deleted folder 'dist'")
+ except Exception as e:
+ print("Error: Cannot delete 'dist' folder.")
+ print(str(e))
+ sys.exit(1)
+ else:
+ print("Folder 'dist' not found")
+
+ print("")
+
+ return
+
+# =============================================================================================== #
+# Process Build Steps
+# =============================================================================================== #
+
+if __name__ == "__main__":
+ """Parse command line options and run the commands.
+ """
+ # Detect OS
+ if sys.platform.startswith("linux"):
+ hostOS = OS_LINUX
+ elif sys.platform.startswith("darwin"):
+ hostOS = OS_DARWIN
+ elif sys.platform.startswith("win32"):
+ hostOS = OS_WIN
+ elif sys.platform.startswith("cygwin"):
+ hostOS = OS_WIN
+ else:
+ hostOS = OS_NONE
+
+ # Flags and Variables
+ buildWindowed = True
+ oneFile = False
+ makeSetup = False
+ doFreeze = False
+
+ helpMsg = (
+ "\n"
+ "novelWriter Make Tool\n"
+ "=====================\n"
+ "This tool provides build commands for distibuting novelWriter as a\n"
+ "package. The available options are as follows:\n"
+ "\n"
+ "help Print the help message.\n"
+ "freeze Freeze the package and produces a folder of all\n"
+ " dependencies using pyinstaller.\n"
+ "onefile Build a standalone executable with all dependencies\n"
+ " bundled. Implies 'freeze', cannot be used with 'setup'.\n"
+ "pip Run pip to install all package dependencies for\n"
+ " novelWriter and this build tool.\n"
+ "setup Build a setup.exe installer for Windows. This option\n"
+ " automaticall disables the 'onefile' option.\n"
+ "clean This will attempt to delete the 'build' and 'dist'\n"
+ " folders in the current folder.\n"
+ )
+
+ if "help" in sys.argv or len(sys.argv) <= 1:
+ print(helpMsg)
+ sys.exit(0)
+
+ if not os.path.isfile(os.path.join(os.getcwd(), "novelWriter.py")):
+ print("Error: This script must be run in the root folder of novelWriter.")
+ sys.exit(1)
+
+ if not os.path.isdir(os.path.join(os.getcwd(), "nw")):
+ print("Error: This script must be run in the root folder of novelWriter.")
+ sys.exit(1)
+
+ if "clean" in sys.argv:
+ sys.argv.remove("clean")
+ cleanInstall()
+
+ if "pip" in sys.argv:
+ sys.argv.remove("pip")
+ installPackages(hostOS)
+
+ if "freeze" in sys.argv:
+ sys.argv.remove("freeze")
+ doFreeze = True
+
+ if "onefile" in sys.argv:
+ sys.argv.remove("onefile")
+ doFreeze = True
+ oneFile = True
+
+ if "setup" in sys.argv:
+ sys.argv.remove("setup")
+ if hostOS == OS_WIN:
+ oneFile = False
+ makeSetup = True
+ else:
+ print("Error: Argument 'setup' for Inno Setup is Windows only.")
+ sys.exit(1)
+
+ if doFreeze:
+ freezePackage(buildWindowed, oneFile, makeSetup, hostOS)
+
+ if makeSetup:
+ innoSetup()
+
+# END Main
diff --git a/nw/__init__.py b/nw/__init__.py
index 5aa259e9..d4597c17 100644
--- a/nw/__init__.py
+++ b/nw/__init__.py
@@ -35,13 +35,35 @@ from PyQt5.QtWidgets import QApplication, QErrorMessage
from nw.error import exceptionHandler
from nw.config import Config
+#
+# Version Scheme
+# ================
+# Generally follows PEP 440
+# Hex Version:
+# - Digit 1,2 : Major Version (01, 02, 03)
+# = Digit 3,4 : Minor Version (01, 09, 10, 99)
+# - Digit 5,6 : Patch Version (01, 09, 10, 99)
+# = Digit 7 : Release Type (a: aplha, b: beta, c: candidate, f: final)
+# - Digit 8 : Release Number (0-9)
+#
+# Example : Full Short Description
+# -------------------------------------------------------------------------
+# 0x010200a0 : 1.2-alpha0 1.2a0 Can be used for the dev branch
+# 0x010200a1 : 1.2-alpha1 1.2a1 First alpha release
+# 0x010200b1 : 1.2-beta1 1.2b1 First beta release
+# 0x010200c1 : 1.2-rc1 1.2rc1 First release candidate
+# 0x010200f0 : 1.2 1.2 Final release
+# 0x010200f1 : 1.2-post1 1.2.post1 Post release, but not a code patch!
+# 0x010201f0 : 1.2.1 1.2.1 Patch release
+#
+
__package__ = "nw"
__author__ = "Veronica Berglyd Olsen"
__copyright__ = "Copyright 2018–2020, Veronica Berglyd Olsen"
__license__ = "GPLv3"
-__version__ = "1.0b3"
-__hexversion__ = "0x010000b3"
-__date__ = "2020-09-20"
+__version__ = "1.0b5"
+__hexversion__ = "0x010000b5"
+__date__ = "2020-10-18"
__maintainer__ = "Veronica Berglyd Olsen"
__email__ = "code@vkbo.net"
__status__ = "Beta"
@@ -200,9 +222,9 @@ def main(sysArgs=None):
# Check Packages and Versions
errorData = []
errorCode = 0
- if sys.hexversion < 0x030403f0:
+ if sys.hexversion < 0x030600f0:
errorData.append(
- "At least Python 3.4.3 is required, but 3.6 is highly recommended."
+ "At least Python 3.6.0 is required, found %s." % CONFIG.verPyString
)
errorCode |= 4
if CONFIG.verQtValue < 50200:
@@ -241,6 +263,16 @@ def main(sysArgs=None):
# Finish initialising config
CONFIG.initConfig(confPath, dataPath)
+ if CONFIG.osDarwin:
+ try:
+ from Foundation import NSBundle
+ bundle = NSBundle.mainBundle()
+ info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
+ info["CFBundleName"] = "novelWriter"
+ except ImportError as e:
+ logger.error("Failed to set application name")
+ logger.error(str(e))
+
# Import GUI (after dependency checks), and launch
from nw.guimain import GuiMain
if testMode:
diff --git a/nw/assets/icons/fallback/status_lines-dark.svg b/nw/assets/icons/fallback/status_lines-dark.svg
new file mode 100644
index 00000000..a977f507
--- /dev/null
+++ b/nw/assets/icons/fallback/status_lines-dark.svg
@@ -0,0 +1,78 @@
+
+
diff --git a/nw/assets/icons/fallback/status_lines.svg b/nw/assets/icons/fallback/status_lines.svg
new file mode 100644
index 00000000..d249f307
--- /dev/null
+++ b/nw/assets/icons/fallback/status_lines.svg
@@ -0,0 +1,78 @@
+
+
diff --git a/nw/common.py b/nw/common.py
index 2fef7874..d5563d16 100644
--- a/nw/common.py
+++ b/nw/common.py
@@ -152,13 +152,13 @@ def formatInt(theInt):
theVal /= 1000.0
if theVal < 1000.0:
if theVal < 10.0:
- return "%4.2f%s%s" % (theVal, nwUnicode.U_THNSP, pF)
+ return f"{theVal:4.2f}{nwUnicode.U_THNSP}{pF}"
elif theVal < 100.0:
- return "%4.1f%s%s" % (theVal, nwUnicode.U_THNSP, pF)
+ return f"{theVal:4.1f}{nwUnicode.U_THNSP}{pF}"
else:
- return "%3.0f%s%s" % (theVal, nwUnicode.U_THNSP, pF)
+ return f"{theVal:3.0f}{nwUnicode.U_THNSP}{pF}"
- return "%d" % theInt
+ return str(theInt)
def formatTimeStamp(theTime, fileSafe=False):
"""Take a number (on the format returned by time.time()) and convert
@@ -169,6 +169,17 @@ def formatTimeStamp(theTime, fileSafe=False):
else:
return datetime.fromtimestamp(theTime).strftime(nwConst.tStampFmt)
+def formatTime(tS):
+ """Format a time in seconds in HH:MM:SS format or d-HH:MM:SS format
+ if a full day or longer.
+ """
+ if isinstance(tS, int):
+ if tS >= 86400:
+ return f"{tS//86400:d}-{tS%86400//3600:02d}:{tS%3600//60:02d}:{tS%60:02d}"
+ else:
+ return f"{tS//3600:02d}:{tS%3600//60:02d}:{tS%60:02d}"
+ return "ERROR"
+
def splitVersionNumber(vString):
""" Splits a version string on the form aa.bb.cc into major, minor
and patch, and computes an integer value aabbcc.
@@ -203,12 +214,12 @@ def transferCase(theSource, theTarget):
if len(theTarget) < 1 or len(theSource) < 1:
return theResult
- if theSource[0] == theSource[0].upper():
- theResult = theTarget[0].upper() + theTarget[1:]
+ if theSource.istitle():
+ theResult = theTarget.title()
- if theSource == theSource.upper():
+ if theSource.isupper():
theResult = theTarget.upper()
- elif theSource == theSource.lower():
+ elif theSource.islower():
theResult = theTarget.lower()
return theResult
diff --git a/nw/config.py b/nw/config.py
index 5f4a1300..a82c133b 100644
--- a/nw/config.py
+++ b/nw/config.py
@@ -3,7 +3,7 @@
novelWriter – Config Class
============================
- This class reads and store the main preferences of the application
+ Class reading and holding the preferences of the application
File History:
Created: 2018-09-22 [0.0.1]
@@ -102,41 +102,57 @@ class Config:
self.outlnPanePos = [500, 150]
self.isFullScreen = False
+ ## Features
+ self.hideVScroll = False # Hide vertical scroll bars on main widgets
+ self.hideHScroll = False # Hide horizontal scroll bars on main widgets
+
## Project
- self.autoSaveProj = 60
- self.autoSaveDoc = 30
+ self.autoSaveProj = 60 # Interval for auto-saving project in seconds
+ self.autoSaveDoc = 30 # Interval for auto-saving document in seconds
## Text Editor
- self.textFont = None
- self.textSize = 12
- self.textFixedW = True
- self.textWidth = 600
- self.textMargin = 40
- self.tabWidth = 40
- self.focusWidth = 800
- self.hideFocusFooter = False
- self.doJustify = False
- self.autoSelect = True
- self.doReplace = True
- self.doReplaceSQuote = True
- self.doReplaceDQuote = True
- self.doReplaceDash = True
- self.doReplaceDots = True
- self.wordCountTimer = 5.0
- self.showTabsNSpaces = False
- self.showLineEndings = False
- self.bigDocLimit = 800
- self.showFullPath = True
- self.highlightQuotes = True
- self.highlightEmph = True
+ self.textFont = None # Editor font
+ self.textSize = 12 # Editor font size
+ self.textFixedW = True # Keep editor text fixed width
+ self.textWidth = 600 # Editor text width
+ self.textMargin = 40 # Editor/viewer text margin
+ self.tabWidth = 40 # Editor tabulator width
+ self.focusWidth = 800 # Focus Mode text width
+ self.hideFocusFooter = False # Hide document footer in Focus Mode
+ self.showFullPath = True # Show full document path in editor header
+ self.autoSelect = True # Auto-select word when applying format with no selection
+
+ self.doJustify = False # Justify text
+ self.showTabsNSpaces = False # Show tabs and spaces in edior
+ self.showLineEndings = False # Show line endings in editor
+
+ self.doReplace = True # Enable auto-replace as you type
+ self.doReplaceSQuote = True # Smart single quotes
+ self.doReplaceDQuote = True # Smart double quotes
+ self.doReplaceDash = True # Replace multiple hyphens with dashes
+ self.doReplaceDots = True # Replace three dots with ellipsis
+
+ self.scrollPastEnd = True # Allow scrolling past end of document
+ self.autoScroll = False # Typewriter-like scrolling
+ self.autoScrollPos = 30 # Start point for typewriter-like scrolling
+
+ self.wordCountTimer = 5.0 # Interval for word count update in seconds
+ self.bigDocLimit = 800 # Size threshold for heavy editor features in kilobytes
+
+ self.highlightQuotes = True # Highlight text in quotes
+ self.highlightEmph = True # Add colour to text emphasis
+
+ ## User-Selected Symbols
self.fmtApostrophe = nwUnicode.U_RSQUO
self.fmtSingleQuotes = [nwUnicode.U_LSQUO, nwUnicode.U_RSQUO]
self.fmtDoubleQuotes = [nwUnicode.U_LDQUO, nwUnicode.U_RDQUO]
+ ## Spell Checking
self.spellTool = None
self.spellLanguage = None
+ ## Search Bar Switches
self.searchCase = False
self.searchWord = False
self.searchRegEx = False
@@ -391,6 +407,12 @@ class Config:
self.isFullScreen = self._parseLine(
cnfParse, cnfSec, "fullscreen", self.CNF_BOOL, self.isFullScreen
)
+ self.hideVScroll = self._parseLine(
+ cnfParse, cnfSec, "hidevscroll", self.CNF_BOOL, self.hideVScroll
+ )
+ self.hideHScroll = self._parseLine(
+ cnfParse, cnfSec, "hidehscroll", self.CNF_BOOL, self.hideHScroll
+ )
## Project
cnfSec = "Project"
@@ -448,6 +470,15 @@ class Config:
self.doReplaceDots = self._parseLine(
cnfParse, cnfSec, "repdots", self.CNF_BOOL, self.doReplaceDots
)
+ self.scrollPastEnd = self._parseLine(
+ cnfParse, cnfSec, "scrollpastend", self.CNF_BOOL, self.scrollPastEnd
+ )
+ self.autoScroll = self._parseLine(
+ cnfParse, cnfSec, "autoscroll", self.CNF_BOOL, self.autoScroll
+ )
+ self.autoScrollPos = self._parseLine(
+ cnfParse, cnfSec, "autoscrollpos", self.CNF_INT, self.autoScrollPos
+ )
self.fmtSingleQuotes = self._parseLine(
cnfParse, cnfSec, "fmtsinglequote", self.CNF_LIST, self.fmtSingleQuotes
)
@@ -565,6 +596,8 @@ class Config:
cnfParse.set(cnfSec, "viewpane", self._packList(self.viewPanePos))
cnfParse.set(cnfSec, "outlinepane", self._packList(self.outlnPanePos))
cnfParse.set(cnfSec, "fullscreen", str(self.isFullScreen))
+ cnfParse.set(cnfSec, "hidevscroll", str(self.hideVScroll))
+ cnfParse.set(cnfSec, "hidehscroll", str(self.hideHScroll))
## Project
cnfSec = "Project"
@@ -590,6 +623,9 @@ class Config:
cnfParse.set(cnfSec, "repdquotes", str(self.doReplaceDQuote))
cnfParse.set(cnfSec, "repdash", str(self.doReplaceDash))
cnfParse.set(cnfSec, "repdots", str(self.doReplaceDots))
+ cnfParse.set(cnfSec, "scrollpastend", str(self.scrollPastEnd))
+ cnfParse.set(cnfSec, "autoscroll", str(self.autoScroll))
+ cnfParse.set(cnfSec, "autoscrollpos", str(self.autoScrollPos))
cnfParse.set(cnfSec, "fmtsinglequote", self._packList(self.fmtSingleQuotes))
cnfParse.set(cnfSec, "fmtdoublequote", self._packList(self.fmtDoubleQuotes))
cnfParse.set(cnfSec, "spelltool", str(self.spellTool))
@@ -654,8 +690,7 @@ class Config:
if os.path.isfile(cacheFile):
try:
with open(cacheFile, mode="r", encoding="utf8") as inFile:
- theJson = inFile.read()
- theData = json.loads(theJson)
+ theData = json.load(inFile)
for projPath in theData.keys():
theEntry = theData[projPath]
@@ -693,7 +728,7 @@ class Config:
try:
with open(cacheTemp, mode="w+", encoding="utf8") as outFile:
- outFile.write(json.dumps(self.recentProj, indent=2))
+ json.dump(self.recentProj, outFile, indent=2)
except Exception as e:
self.hasError = True
self.errData.append("Could not save recent project cache")
@@ -876,23 +911,28 @@ class Config:
def _packList(self, inData):
"""Pack a list of items into a comma separated string.
"""
- return ", ".join(str(inVal) for inVal in inData)
+ return ", ".join([str(inVal) for inVal in inData])
def _parseLine(self, cnfParse, cnfSec, cnfName, cnfType, cnfDefault):
"""Parse a line and return the correct datatype.
"""
if cnfParse.has_section(cnfSec):
if cnfParse.has_option(cnfSec, cnfName):
- if cnfType == self.CNF_STR:
- return cnfParse.get(cnfSec, cnfName)
- elif cnfType == self.CNF_INT:
- return cnfParse.getint(cnfSec, cnfName)
- elif cnfType == self.CNF_BOOL:
- return cnfParse.getboolean(cnfSec, cnfName)
- elif cnfType == self.CNF_LIST:
- return self._unpackList(
- cnfParse.get(cnfSec, cnfName), len(cnfDefault), cnfDefault
- )
+ try:
+ if cnfType == self.CNF_STR:
+ return cnfParse.get(cnfSec, cnfName)
+ elif cnfType == self.CNF_INT:
+ return cnfParse.getint(cnfSec, cnfName)
+ elif cnfType == self.CNF_BOOL:
+ return cnfParse.getboolean(cnfSec, cnfName)
+ elif cnfType == self.CNF_LIST:
+ return self._unpackList(
+ cnfParse.get(cnfSec, cnfName), len(cnfDefault), cnfDefault
+ )
+ except ValueError as e:
+ logger.error("Failed to load value from config file.")
+ logger.error(str(e))
+
return cnfDefault
def _checkNone(self, checkVal):
@@ -927,4 +967,4 @@ class Config:
return
-# End Class Config
+# END Class Config
diff --git a/nw/constants/constants.py b/nw/constants/constants.py
index 9a539b73..e7549bb4 100644
--- a/nw/constants/constants.py
+++ b/nw/constants/constants.py
@@ -41,8 +41,8 @@ class nwConst():
class nwRegEx():
- FMT_I = r"(?%s" % self.mainConf.appName)
+ self.lblName = QLabel("novelWriter")
self.lblVers = QLabel("v%s" % nw.__version__)
self.lblDate = QLabel(datetime.strptime(nw.__date__, "%Y-%m-%d").strftime("%x"))
@@ -115,17 +115,17 @@ class GuiAbout(QDialog):
"""
listPrefix = " • "
aboutMsg = (
- "
{copyright:s}.
" "Website: {domain:s}
" - "{name:s} is a markdown-like text editor designed for " + "
novelWriter is a markdown-like text editor designed for " "organising and writing novels. It is written in Python 3 with a " "Qt5 GUI, using PyQt5.
" - "{name:s} is free software: you can redistribute it and/or " + "
novelWriter 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.
" - "{name:s} is distributed in the hope that it will be useful, " + "
novelWriter 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 License tab for the full text, or visit the GNU website " @@ -134,7 +134,6 @@ class GuiAbout(QDialog): "
{credits:s}
" ).format( - name = self.mainConf.appName, copyright = nw.__copyright__, website = nw.__url__, domain = nw.__domain__, @@ -222,7 +221,6 @@ class GuiAbout(QDialog): hColB = self.theParent.theTheme.colHead[2], ) self.pageAbout.document().setDefaultStyleSheet(styleSheet) - # self.pageCredit.document().setDefaultStyleSheet(styleSheet) self.pageLicense.document().setDefaultStyleSheet(styleSheet) return diff --git a/nw/gui/build.py b/nw/gui/build.py index 6e571959..4a8f8aad 100644 --- a/nw/gui/build.py +++ b/nw/gui/build.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""novelWriter GUI Build Novel +"""novelWriter GUI Build Novel Project - novelWriter – GUI Build Novel -=============================== - Class holding the build novel window + novelWriter – GUI Build Novel Project +======================================= + Class holding the build novel project dialog File History: Created: 2020-05-09 [0.5] @@ -391,11 +391,11 @@ class GuiBuildNovel(QDialog): self.savePDF.triggered.connect(lambda: self._saveDocument(self.FMT_PDF)) self.saveMenu.addAction(self.savePDF) - self.saveHTM = QAction("%s HTML (.htm)" % self.mainConf.appName, self) + self.saveHTM = QAction("novelWriter HTML (.htm)", self) self.saveHTM.triggered.connect(lambda: self._saveDocument(self.FMT_HTM)) self.saveMenu.addAction(self.saveHTM) - self.saveNWD = QAction("%s Markdown (.nwd)" % self.mainConf.appName, self) + self.saveNWD = QAction("novelWriter Markdown (.nwd)", self) self.saveNWD.triggered.connect(lambda: self._saveDocument(self.FMT_NWD)) self.saveMenu.addAction(self.saveNWD) @@ -408,11 +408,11 @@ class GuiBuildNovel(QDialog): self.saveTXT.triggered.connect(lambda: self._saveDocument(self.FMT_TXT)) self.saveMenu.addAction(self.saveTXT) - self.saveJsonH = QAction("JSON + %s HTML (.json)" % self.mainConf.appName, self) + self.saveJsonH = QAction("JSON + novelWriter HTML (.json)", self) self.saveJsonH.triggered.connect(lambda: self._saveDocument(self.FMT_JSON_H)) self.saveMenu.addAction(self.saveJsonH) - self.saveJsonM = QAction("JSON + %s Markdown (.json)" % self.mainConf.appName, self) + self.saveJsonM = QAction("JSON + novelWriters Markdown (.json)", self) self.saveJsonM.triggered.connect(lambda: self._saveDocument(self.FMT_JSON_M)) self.saveMenu.addAction(self.saveJsonM) @@ -450,11 +450,19 @@ class GuiBuildNovel(QDialog): # Tool Box Scroll Area self.toolsArea = QScrollArea() self.toolsArea.setMinimumWidth(self.mainConf.pxInt(250)) - self.toolsArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.toolsArea.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.toolsArea.setWidgetResizable(True) self.toolsArea.setWidget(self.toolsWidget) + if self.mainConf.hideVScroll: + self.toolsArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.toolsArea.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + if self.mainConf.hideHScroll: + self.toolsArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.toolsArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + # Tools and Buttons Layout self.innerBox = QVBoxLayout() self.innerBox.addWidget(self.toolsArea) @@ -676,8 +684,8 @@ class GuiBuildNovel(QDialog): isNone |= theItem.itemLayout == nwItemLayout.NO_LAYOUT isNone |= theItem.itemClass == nwItemClass.NO_CLASS isNone |= theItem.itemClass == nwItemClass.TRASH - isNone |= theItem.parHandle == self.theProject.projTree.trashRoot() - isNone |= theItem.parHandle is None + isNone |= theItem.itemParent == self.theProject.projTree.trashRoot() + isNone |= theItem.itemParent is None isNote = theItem.itemLayout == nwItemLayout.NOTE isNovel = not isNone and not isNote @@ -762,12 +770,10 @@ class GuiBuildNovel(QDialog): if self.mainConf.showGUI: dlgOpt = QFileDialog.Options() dlgOpt |= QFileDialog.DontUseNativeDialog - saveTo = QFileDialog.getSaveFileName( + savePath, _ = QFileDialog.getSaveFileName( self, "Save Document As", savePath, options=dlgOpt ) - if saveTo[0]: - savePath = saveTo[0] - else: + if not savePath: return False self.mainConf.setLastPath(savePath) @@ -901,8 +907,10 @@ class GuiBuildNovel(QDialog): def _doPrintPreview(self, thePrinter): """Connect the print preview painter to the document viewer. """ + qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) thePrinter.setOrientation(QPrinter.Portrait) self.docView.qDocument.print(thePrinter) + qApp.restoreOverrideCursor() return def _selectFont(self): @@ -915,6 +923,9 @@ class GuiBuildNovel(QDialog): if theStatus: self.textFont.setText(theFont.family()) self.textSize.setValue(theFont.pointSize()) + + self.raise_() # Move the dialog to front (fixes a bug on macOS) + return def _loadCache(self): @@ -1002,8 +1013,9 @@ class GuiBuildNovel(QDialog): """ self.saveODT.setEnabled(theState) self.savePDF.setEnabled(theState) - self.saveMD.setEnabled(theState) self.saveTXT.setEnabled(theState) + if self.mainConf.verQtValue >= 51400: + self.saveMD.setEnabled(theState) return def _saveSettings(self): diff --git a/nw/gui/custom.py b/nw/gui/custom.py index 03fc6318..a6260f33 100644 --- a/nw/gui/custom.py +++ b/nw/gui/custom.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""novelWriter Addition QConfigLayout +"""novelWriter Custom Widgets and Layouts - novelWriter – Addition QConfigLayout -====================================== - A custom Qt grid layout for config forms similar to QFormLayout + novelWriter – Custom Widgets and Layouts +========================================== + Various custom widget and layout classes File History: Created: 2020-05-03 [0.4.5] QConfigLayout @@ -326,7 +326,7 @@ class QSwitch(QAbstractButton): """ super().mouseReleaseEvent(event) if event.button() == Qt.LeftButton: - doAnim = QPropertyAnimation(self, b'offset', self) + doAnim = QPropertyAnimation(self, b"offset", self) doAnim.setDuration(120) doAnim.setStartValue(self.offset) if self.isChecked(): diff --git a/nw/gui/doceditor.py b/nw/gui/doceditor.py index 7e83461a..a24a1f4e 100644 --- a/nw/gui/doceditor.py +++ b/nw/gui/doceditor.py @@ -3,7 +3,7 @@ novelWriter – GUI Document Editor =================================== - Class holding the document editor + Class holding the main document editor File History: Created: 2018-09-29 [0.0.1] GuiDocEditor @@ -12,6 +12,7 @@ Created: 2020-04-25 [0.4.5] GuiDocEditHeader Rewritten: 2020-06-15 [0.9.0] GuiDocEditSearch Created: 2020-06-27 [0.10.0] GuiDocEditFooter + Rewritten: 2020-10-07 [1.0b3] BackgroundWordCounter This file is a part of novelWriter Copyright 2018–2020, Veronica Berglyd Olsen @@ -36,7 +37,8 @@ import logging from time import time from PyQt5.QtCore import ( - Qt, QSize, QThread, QTimer, pyqtSlot, QRegExp, QRegularExpression, QPointF + Qt, QSize, QTimer, pyqtSlot, pyqtSignal, QRegExp, QRegularExpression, + QPointF, QObject, QRunnable, QPropertyAnimation ) from PyQt5.QtGui import ( QTextCursor, QTextOption, QKeySequence, QFont, QColor, QPalette, @@ -59,6 +61,11 @@ logger = logging.getLogger(__name__) class GuiDocEditor(QTextEdit): + MOVE_KEYS = ( + Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, Qt.Key_Down, + Qt.Key_PageUp, Qt.Key_PageDown + ) + def __init__(self, theParent): QTextEdit.__init__(self, theParent) @@ -135,14 +142,15 @@ class GuiDocEditor(QTextEdit): activated=self._followTag ) - # Set Up Word Count Thread and Timer + # Set Up Word Counter self.wcInterval = self.mainConf.wordCountTimer self.wcTimer = QTimer() self.wcTimer.setInterval(int(self.wcInterval*1000)) self.wcTimer.timeout.connect(self._runCounter) self.wCounter = BackgroundWordCounter(self) - self.wCounter.finished.connect(self._updateCounts) + self.wCounter.setAutoDelete(False) + self.wCounter.signals.countsReady.connect(self._updateCounts) self.initEditor() @@ -221,6 +229,17 @@ class GuiDocEditor(QTextEdit): self.qDocument.setDefaultTextOption(theOpt) + # Scroll bars + if self.mainConf.hideVScroll: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + if self.mainConf.hideHScroll: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + # Refresh the tab stops if self.mainConf.verQtValue >= 51000: self.setTabStopDistance(self.mainConf.getTabWidth()) @@ -282,7 +301,7 @@ class GuiDocEditor(QTextEdit): self._allowAutoReplace(True) afTime = time() - logger.debug("Document highlighted in %.3f milliseconds" % (1000*(afTime-bfTime))) + logger.debug("Document highlighted in %.3f ms" % (1000*(afTime-bfTime))) self.lastEdit = time() self._runCounter() @@ -309,6 +328,9 @@ class GuiDocEditor(QTextEdit): else: self.setCursorLine(tLine) + self.docFooter.updateLineCount() + self.lengthLast = self.qDocument.characterCount() + qApp.restoreOverrideCursor() return True @@ -338,9 +360,11 @@ class GuiDocEditor(QTextEdit): ) % (docSize/1.0e6, nwConst.maxDocSize/1.0e6), nwAlert.ERROR) return False + qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) self.setPlainText(theText) self.setDocumentChanged(True) self.updateDocMargins() + qApp.restoreOverrideCursor() return True @@ -353,9 +377,14 @@ class GuiDocEditor(QTextEdit): return False docText = self.getText() + + cC, wC, pC = countWords(docText) + self._updateCounts(cC, wC, pC) + theItem.setCharCount(self.charCount) theItem.setWordCount(self.wordCount) theItem.setParaCount(self.paraCount) + self.saveCursorPosition() self.nwDocument.saveDocument(docText) self.setDocumentChanged(False) @@ -370,6 +399,7 @@ class GuiDocEditor(QTextEdit): just ensure the margins are set correctly. """ wW = self.width() + wH = self.height() cM = self.mainConf.getTextMargin() vBar = self.verticalScrollBar() @@ -393,7 +423,7 @@ class GuiDocEditor(QTextEdit): tW = wW - 2*tB - sW tH = self.docHeader.height() fH = self.docFooter.height() - fY = self.height() - fH - tB - sH + fY = wH - fH - tB - sH self.docHeader.setGeometry(tB, tB, tW, tH) self.docFooter.setGeometry(tB, fY, tW, fH) @@ -405,7 +435,14 @@ class GuiDocEditor(QTextEdit): else: rH = 0 - self.setViewportMargins(tM, max(cM, tH, rH), tM, max(cM, fH)) + uM = max(cM, tH, rH) + lM = max(cM, fH) + self.setViewportMargins(tM, uM, tM, lM) + + if self.mainConf.scrollPastEnd: + docFrame = self.qDocument.rootFrame().frameFormat() + docFrame.setBottomMargin(max(0, 0.6*(wH - uM - lM - 4*tB))) + self.qDocument.rootFrame().setFrameFormat(docFrame) return @@ -453,10 +490,13 @@ class GuiDocEditor(QTextEdit): """ if not isinstance(thePosition, int): return False + if thePosition >= 0: theCursor = self.textCursor() theCursor.setPosition(thePosition) self.setTextCursor(theCursor) + self.docFooter.updateLineCount() + return True def getCursorPosition(self): @@ -483,6 +523,7 @@ class GuiDocEditor(QTextEdit): theBlock = self.qDocument.findBlockByLineNumber(theLine) if theBlock: self.setCursorPosition(theBlock.position()) + self.docFooter.updateLineCount() logger.verbose("Cursor moved to line %d" % theLine) return True @@ -501,7 +542,11 @@ class GuiDocEditor(QTextEdit): theLang = self.theProject.projLang self.theDict.setLanguage(theLang, self.theProject.projDict) - self.theParent.statusBar.setLanguage(self.theDict.spellLanguage) + + aLang, aName = self.theDict.describeDict() + self.theParent.statusBar.setLanguage( + aLang, "%s [%s]" % (self.mainConf.spellTool.title(), aName.title()) + ) if not self.bigDoc: self.spellCheckDocument() @@ -548,9 +593,8 @@ class GuiDocEditor(QTextEdit): qApp.restoreOverrideCursor() afTime = time() logger.debug( - "Document re-highlighted in %.3f milliseconds" % (1000*(afTime-bfTime)) + "Document highlighted in %.3f ms" % (1000*(afTime-bfTime)) ) - self.theParent.statusBar.showMessage("Spell check complete") return True @@ -716,8 +760,8 @@ class GuiDocEditor(QTextEdit): * The return and enter key redirects here even if the search box has focus. Since we need these keys to continue search, we block any further interaction here while it's in focus. - * The undo/redo sequences bypasses the doAction pathway from - the menu, so we redirect them back from here. + * The undo/redo/select all sequences bypasses the docAction + pathway from the menu, so we redirect them back from here. """ isReturn = keyEvent.key() == Qt.Key_Return isReturn |= keyEvent.key() == Qt.Key_Enter @@ -725,10 +769,40 @@ class GuiDocEditor(QTextEdit): return elif keyEvent == QKeySequence.Redo: self.docAction(nwDocAction.REDO) + return elif keyEvent == QKeySequence.Undo: self.docAction(nwDocAction.UNDO) + return + elif keyEvent == QKeySequence.SelectAll: + self.docAction(nwDocAction.SEL_ALL) + return + + if self.mainConf.autoScroll: + + cOld = self.cursorRect().center().y() + QTextEdit.keyPressEvent(self, keyEvent) + + kMod = keyEvent.modifiers() + okMod = kMod == Qt.NoModifier or kMod == Qt.ShiftModifier + okKey = keyEvent.key() not in self.MOVE_KEYS + if okMod and okKey: + cNew = self.cursorRect().center().y() + cMov = cNew - cOld + mPos = self.mainConf.autoScrollPos * self.viewport().height() * 0.01 + if abs(cMov) > 0 and cOld > mPos: + # Move the scroll bar + vBar = self.verticalScrollBar() + doAnim = QPropertyAnimation(vBar, b"value", self) + doAnim.setDuration(120) + doAnim.setStartValue(vBar.value()) + doAnim.setEndValue(vBar.value() + cMov) + doAnim.start() + else: QTextEdit.keyPressEvent(self, keyEvent) + + self.docFooter.updateLineCount() + return def focusNextPrevChild(self, toNext): @@ -743,15 +817,18 @@ class GuiDocEditor(QTextEdit): return self.docSearch.cycleFocus(toNext) return True - def mouseReleaseEvent(self, mEvent): + def mouseReleaseEvent(self, theEvent): """If the mouse button is released and the control key is pressed, check if we're clicking on a tag, and trigger the follow tag function. """ if qApp.keyboardModifiers() == Qt.ControlModifier: - theCursor = self.cursorForPosition(mEvent.pos()) + theCursor = self.cursorForPosition(theEvent.pos()) self._followTag(theCursor) - QTextEdit.mouseReleaseEvent(self, mEvent) + + QTextEdit.mouseReleaseEvent(self, theEvent) + self.docFooter.updateLineCount() + return def resizeEvent(self, theEvent): @@ -795,11 +872,18 @@ class GuiDocEditor(QTextEdit): """ userCursor = self.textCursor() userSelection = userCursor.hasSelection() + posCursor = self.cursorForPosition(thePos) mnuContext = QMenu() - # Cut, Copy and Paste - # =================== + # Follow, Cut, Copy and Paste + # =========================== + + if self._followTag(theCursor=posCursor, loadTag=False): + mnuTag = QAction("Follow Tag", mnuContext) + mnuTag.triggered.connect(lambda: self._followTag(theCursor=posCursor)) + mnuContext.addAction(mnuTag) + mnuContext.addSeparator() if userSelection: mnuCut = QAction("Cut", mnuContext) @@ -838,10 +922,13 @@ class GuiDocEditor(QTextEdit): # Spell Checking # ============== + posCursor = self.cursorForPosition(thePos) spellCheck = self.spellCheck + if posCursor.block().text().startswith("@"): + spellCheck = False + if spellCheck: - posCursor = self.cursorForPosition(thePos) posCursor.select(QTextCursor.WordUnderCursor) theWord = posCursor.selectedText().strip().strip(self.nonWord) spellCheck &= theWord != "" @@ -855,7 +942,7 @@ class GuiDocEditor(QTextEdit): mnuHead = QAction("Spelling Suggestion(s)", mnuContext) mnuContext.addAction(mnuHead) - theSuggest = self.theDict.suggestWords(theWord) + theSuggest = self.theDict.suggestWords(theWord)[:15] if len(theSuggest) > 0: for aWord in theSuggest: mnuWord = QAction("%s %s" % (nwUnicode.U_ENDASH, aWord), mnuContext) @@ -905,24 +992,21 @@ class GuiDocEditor(QTextEdit): @pyqtSlot() def _runCounter(self): - """Decide whether to run the word counter, or stop the timer due - to inactivity. + """Decide whether to run the word counter, or not due to + inactivity. """ - sinceActive = time()-self.lastEdit - if sinceActive > 5*self.wcInterval: - logger.debug( - "Stopping word count timer: no activity last %.1f seconds" % sinceActive - ) - self.wcTimer.stop() - elif self.wCounter.isRunning(): - logger.verbose("Word counter thread is busy") - else: - logger.verbose("Starting word counter") - self.wCounter.start() + if self.wCounter.isRunning(): + logger.verbose("Word counter is busy") + return + + if time() - self.lastEdit < 5*self.wcInterval: + logger.verbose("Running word counter") + self.theParent.threadPool.start(self.wCounter) + return - @pyqtSlot() - def _updateCounts(self): + @pyqtSlot(int, int, int) + def _updateCounts(self, cCount, wCount, pCount): """Slot for the word counter's finished signal """ theItem = self.nwDocument.getCurrentItem() @@ -931,19 +1015,17 @@ class GuiDocEditor(QTextEdit): logger.verbose("Updating word count") - self.charCount = self.wCounter.charCount - self.wordCount = self.wCounter.wordCount - self.paraCount = self.wCounter.paraCount - theItem.setCharCount(self.charCount) - theItem.setWordCount(self.wordCount) - theItem.setParaCount(self.paraCount) + self.charCount = cCount + self.wordCount = wCount + self.paraCount = pCount + theItem.setCharCount(cCount) + theItem.setWordCount(wCount) + theItem.setParaCount(pCount) - self.theParent.treeView.propagateCount(self.theHandle, self.wordCount) + self.theParent.treeView.propagateCount(self.theHandle, wCount) self.theParent.treeView.projectWordCount() - self.theParent.treeMeta.updateCounts( - self.theHandle, self.charCount, self.wordCount, self.paraCount - ) - self._checkDocSize(self.charCount) + self.theParent.treeMeta.updateCounts(self.theHandle, cCount, wCount, pCount) + self._checkDocSize(self.qDocument.characterCount()) self.docFooter.updateCounts() return @@ -975,7 +1057,7 @@ class GuiDocEditor(QTextEdit): # Internal Functions ## - def _followTag(self, theCursor=None): + def _followTag(self, theCursor=None, loadTag=True): """Activated by Ctrl+Enter. Checks that we're in a block starting with '@'. We then find the word under the cursor and check that it is after the ':'. If all this is fine, we have a @@ -1000,10 +1082,15 @@ class GuiDocEditor(QTextEdit): if wPos <= cPos: return False - logger.verbose("Attempting to follow tag '%s'" % theWord) - self.theParent.docViewer.loadFromTag(theWord) + if loadTag: + logger.verbose("Attempting to follow tag '%s'" % theWord) + self.theParent.docViewer.loadFromTag(theWord) + else: + logger.verbose("Potential tag '%s'" % theWord) - return True + return True + + return False def _openSpellContext(self): """Opens the spell check context menu at the current point of @@ -1126,20 +1213,19 @@ class GuiDocEditor(QTextEdit): """Check if document size crosses the big document limit set in config. If so, we will set the big document flag to True. """ - newState = theSize > self.mainConf.bigDocLimit*1000 + bigLim = self.mainConf.bigDocLimit*1000 + newState = theSize > bigLim if newState != self.bigDoc: if newState: logger.info( - "The document size is {:n} > {:n}, big doc mode has been enabled".format( - theSize, self.mainConf.bigDocLimit*1000 - ) + f"The document size is {theSize:n} > {bigLim:n}, " + f"big doc mode has been enabled" ) else: logger.info( - "The document size is {:n} <= {:n}, big doc mode has been disabled".format( - theSize, self.mainConf.bigDocLimit*1000 - ) + f"The document size is {theSize:n} <= {bigLim:n}, " + f"big doc mode has been disabled" ) self.bigDoc = newState @@ -1160,6 +1246,11 @@ class GuiDocEditor(QTextEdit): posS = theCursor.selectionStart() posE = theCursor.selectionEnd() + blockS = self.qDocument.findBlock(posS) + blockE = self.qDocument.findBlock(posE) + if blockS != blockE: + posE = blockS.position() + blockS.length() - 1 + theCursor.clearSelection() theCursor.beginEditBlock() theCursor.setPosition(posE) @@ -1168,8 +1259,8 @@ class GuiDocEditor(QTextEdit): theCursor.insertText(tBefore) theCursor.endEditBlock() - theCursor.setPosition(posE + len(tBefore)) - theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, posE-posS) + theCursor.setPosition(posE + len(tBefore), QTextCursor.MoveAnchor) + theCursor.setPosition(posS + len(tBefore), QTextCursor.KeepAnchor) self.setTextCursor(theCursor) else: @@ -1217,20 +1308,33 @@ class GuiDocEditor(QTextEdit): reSelect = True if reSelect: theCursor.clearSelection() - theCursor.setPosition(posE-1) - theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, posE-posS-1) + theCursor.setPosition(posS, QTextCursor.MoveAnchor) + theCursor.setPosition(posE-1, QTextCursor.KeepAnchor) self.setTextCursor(theCursor) + return theCursor def _toggleFormat(self, fLen, fChar): - """Toggle strikethrough text. + """Toggle the formatting of a specific type for a piece of text. + If more than one block is selected, the formatting is applied to + the first block. """ theCursor = self._autoSelect() if theCursor.hasSelection(): posS = theCursor.selectionStart() posE = theCursor.selectionEnd() + blockS = self.qDocument.findBlock(posS) + blockE = self.qDocument.findBlock(posE) + + if blockS != blockE: + posE = blockS.position() + blockS.length() - 1 + theCursor.clearSelection() + theCursor.setPosition(posS, QTextCursor.MoveAnchor) + theCursor.setPosition(posE, QTextCursor.KeepAnchor) + self.setTextCursor(theCursor) + numB = 0 for n in range(fLen): if self.qDocument.characterAt(posS-n-1) == fChar: @@ -1342,7 +1446,10 @@ class GuiDocEditor(QTextEdit): theCursor.clearSelection() theCursor.select(selMode) - if selMode == QTextCursor.BlockUnderCursor: + if selMode == QTextCursor.WordUnderCursor: + theCursor = self._autoSelect() + + elif selMode == QTextCursor.BlockUnderCursor: # This selection mode also selects the preceding oaragraph # separator, which we want to avoid. posS = theCursor.selectionStart() @@ -1517,33 +1624,42 @@ class GuiDocEditor(QTextEdit): # END Class GuiDocEditor # =============================================================================================== # -# The Off GUI Thread Word Counter -# Runs the word counter in the background for the DocEditor +# The Off-GUI Thread Word Counter +# A runnable for the word counter to be run in the thread pool off the main GUI thread. # =============================================================================================== # -class BackgroundWordCounter(QThread): +class BackgroundWordCounter(QRunnable): def __init__(self, docEditor): - QThread.__init__(self, docEditor) + QRunnable.__init__(self) self.docEditor = docEditor - self.charCount = 0 - self.wordCount = 0 - self.paraCount = 0 + self.signals = BackgroundWordCounterSignals() + self._isRunning = False return + def isRunning(self): + return self._isRunning + + @pyqtSlot() def run(self): """Overloaded run function for the word counter, forwarding the call to the function that does the actual counting. """ + self._isRunning = True theText = self.docEditor.getText() cC, wC, pC = countWords(theText) - self.charCount = cC - self.wordCount = wC - self.paraCount = pC + self.signals.countsReady.emit(cC, wC, pC) + self._isRunning = False return ## END Class BackgroundWordCounter +class BackgroundWordCounterSignals(QObject): + + countsReady = pyqtSignal(int, int, int) + +# END Class BackgroundWordCounterSignals + # =============================================================================================== # # The Embedded Document Search/Replace Feature # Only used by DocEditor, and is at a fixed position in the QTextEdit's viewport @@ -2154,6 +2270,7 @@ class GuiDocEditFooter(QWidget): self.sPx = int(round(0.9*self.theTheme.baseIconSize)) fPx = int(0.9*self.theTheme.fontPixelSize) bSp = self.mainConf.pxInt(4) + hSp = self.mainConf.pxInt(6) lblFont = self.font() lblFont.setPointSizeF(0.9*self.theTheme.fontPointSize) @@ -2179,6 +2296,23 @@ class GuiDocEditFooter(QWidget): self.statusText.setPalette(self.thePalette) self.statusText.setFont(lblFont) + # Lines + self.linesIcon = QLabel("") + self.linesIcon.setPixmap(self.theTheme.getPixmap("status_lines", (self.sPx, self.sPx))) + self.linesIcon.setContentsMargins(0, 0, 0, 0) + self.linesIcon.setFixedHeight(self.sPx) + self.linesIcon.setAlignment(Qt.AlignLeft | Qt.AlignTop) + + self.linesText = QLabel("Line: 0") + self.linesText.setIndent(0) + self.linesText.setMargin(0) + self.linesText.setContentsMargins(0, 0, 0, 0) + self.linesText.setAutoFillBackground(True) + self.linesText.setFixedHeight(fPx) + self.linesText.setAlignment(Qt.AlignLeft | Qt.AlignTop) + self.linesText.setPalette(self.thePalette) + self.linesText.setFont(lblFont) + # Words self.wordsIcon = QLabel("") self.wordsIcon.setPixmap(self.theTheme.getPixmap("status_stats", (self.sPx, self.sPx))) @@ -2202,6 +2336,9 @@ class GuiDocEditFooter(QWidget): self.outerBox.addWidget(self.statusIcon) self.outerBox.addWidget(self.statusText) self.outerBox.addStretch(1) + self.outerBox.addWidget(self.linesIcon) + self.outerBox.addWidget(self.linesText) + self.outerBox.addSpacing(hSp) self.outerBox.addWidget(self.wordsIcon) self.outerBox.addWidget(self.wordsText) self.setLayout(self.outerBox) @@ -2251,8 +2388,23 @@ class GuiDocEditFooter(QWidget): return + def updateLineCount(self): + """Update the word count. + """ + if self.theItem is None: + iLine = 0 + iDist = 0 + else: + theCursor = self.docEditor.textCursor() + iLine = theCursor.blockNumber() + 1 + iDist = 100*iLine/self.docEditor.qDocument.blockCount() + + self.linesText.setText(f"Line: {iLine:n} ({iDist:.0f}\u202f%)") + + return + def updateCounts(self): - """Update the word counts. + """Update the word count. """ if self.theItem is None: wCount = 0 @@ -2261,10 +2413,10 @@ class GuiDocEditFooter(QWidget): wCount = self.theItem.wordCount wDiff = wCount - self.theItem.initCount - self.wordsText.setText("Words: {:n} ({:+n})".format(wCount, wDiff)) + self.wordsText.setText(f"Words: {wCount:n} ({wDiff:+n})") byteSize = self.docEditor.qDocument.characterCount() - self.wordsText.setToolTip("Document size is {:n} bytes".format(byteSize)) + self.wordsText.setToolTip(f"Document size is {byteSize:n} bytes") return diff --git a/nw/gui/dochighlight.py b/nw/gui/dochighlight.py index 663732b4..309b74a8 100644 --- a/nw/gui/dochighlight.py +++ b/nw/gui/dochighlight.py @@ -3,7 +3,7 @@ novelWriter – GUI Document Highlighter ======================================== - Syntax highlighting for MarkDown + Subclass for the main editor syntax highlighting File History: Created: 2019-04-06 [0.0.1] @@ -28,6 +28,8 @@ import nw import logging +from time import time + from PyQt5.QtCore import Qt, QRegularExpression from PyQt5.QtGui import ( QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush @@ -66,7 +68,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): self.colDialN = QColor(0, 0, 0) self.colDialD = QColor(0, 0, 0) self.colDialS = QColor(0, 0, 0) - self.colComm = QColor(0, 0, 0) + self.colHidden = QColor(0, 0, 0) self.colKey = QColor(0, 0, 0) self.colVal = QColor(0, 0, 0) self.colSpell = QColor(0, 0, 0) @@ -90,7 +92,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): self.colDialN = QColor(*self.theTheme.colDialN) self.colDialD = QColor(*self.theTheme.colDialD) self.colDialS = QColor(*self.theTheme.colDialS) - self.colComm = QColor(*self.theTheme.colComm) + self.colHidden = QColor(*self.theTheme.colHidden) self.colKey = QColor(*self.theTheme.colKey) self.colVal = QColor(*self.theTheme.colVal) self.colSpell = QColor(*self.theTheme.colSpell) @@ -116,14 +118,14 @@ class GuiDocHighlighter(QSyntaxHighlighter): "header4h" : self._makeFormat(self.colHeadH, "bold", 1.2), "bold" : self._makeFormat(self.colEmph, "bold"), "italic" : self._makeFormat(self.colEmph, "italic"), - "strike" : self._makeFormat(self.colEmph, "strike"), + "strike" : self._makeFormat(self.colHidden, "strike"), "trailing" : self._makeFormat(self.colTrail, "background"), "nobreak" : self._makeFormat(self.colTrail, "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.colComm), + "hidden" : self._makeFormat(self.colHidden), "keyword" : self._makeFormat(self.colKey), "modifier" : self._makeFormat(self.colMod), "value" : self._makeFormat(self.colVal, "underline"), @@ -147,32 +149,36 @@ class GuiDocHighlighter(QSyntaxHighlighter): # Quoted Strings if self.mainConf.highlightQuotes: + fmtDO = self.mainConf.fmtDoubleQuotes[0] + fmtDC = self.mainConf.fmtDoubleQuotes[1] + fmtSO = self.mainConf.fmtSingleQuotes[0] + fmtSC = self.mainConf.fmtSingleQuotes[1] self.hRules.append(( - "\\B{:s}(.*?){:s}\\B".format('"', '"'), { + "\\B\"(.*?)\"\\B", { 0 : self.hStyles["dialogue1"], } )) self.hRules.append(( - "\\B{:s}(.*?){:s}\\B".format(*self.mainConf.fmtDoubleQuotes), { + f"\\B{fmtDO:s}(.*?){fmtDC:s}\\B", { 0 : self.hStyles["dialogue2"], } )) self.hRules.append(( - "\\B{:s}(.*?){:s}\\B".format(*self.mainConf.fmtSingleQuotes), { + f"\\B{fmtSO:s}(.*?){fmtSC:s}\\B", { 0 : self.hStyles["dialogue3"], } )) # Markdown self.hRules.append(( - nwRegEx.FMT_I, { + nwRegEx.FMT_EI, { 1 : self.hStyles["hidden"], 2 : self.hStyles["italic"], 3 : self.hStyles["hidden"], } )) self.hRules.append(( - nwRegEx.FMT_B, { + nwRegEx.FMT_EB, { 1 : self.hStyles["hidden"], 2 : self.hStyles["bold"], 3 : self.hStyles["hidden"], @@ -203,7 +209,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): # Build a QRegExp for spell checker # Include additional characters that the highlighter should # consider to be word separators - wordSep = r"_\+/" + wordSep = r"\-_\+/" wordSep += nwUnicode.U_ENDASH wordSep += nwUnicode.U_EMDASH self.spellRx = QRegularExpression(r"\b[^\s"+wordSep+r"]+\b") @@ -244,10 +250,15 @@ class GuiDocHighlighter(QSyntaxHighlighter): """ qDocument = self.document() nBlocks = qDocument.blockCount() + bfTime = time() for i in range(nBlocks): theBlock = qDocument.findBlockByNumber(i) - if theBlock.userState() & theType == theType: + if theBlock.userState() & theType > 0: self.rehighlightBlock(theBlock) + afTime = time() + logger.debug( + "Document highlighted in %.3f ms" % (1000*(afTime-bfTime)) + ) return ## @@ -343,7 +354,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): while rxSpell.hasNext(): rxMatch = rxSpell.next() if not self.theDict.checkWord(rxMatch.captured(0)): - if rxMatch.captured(0) == rxMatch.captured(0).upper(): + if rxMatch.captured(0).isupper() or rxMatch.captured(0).isnumeric(): continue xPos = rxMatch.capturedStart(0) xLen = rxMatch.capturedLength(0) diff --git a/nw/gui/docmerge.py b/nw/gui/docmerge.py index 2418a161..f2a49e19 100644 --- a/nw/gui/docmerge.py +++ b/nw/gui/docmerge.py @@ -3,7 +3,7 @@ novelWriter – GUI Doc Merge ============================= - Tool for merging multiple documents to one + Tool for merging multiple documents to one document File History: Created: 2020-01-23 [0.4.3] @@ -127,7 +127,7 @@ class GuiDocMerge(QDialog): ), nwAlert.ERROR) return - nHandle = self.theProject.newFile(srcItem.itemName, srcItem.itemClass, srcItem.parHandle) + nHandle = self.theProject.newFile(srcItem.itemName, srcItem.itemClass, srcItem.itemParent) newItem = self.theProject.projTree[nHandle] newItem.setStatus(srcItem.itemStatus) diff --git a/nw/gui/docsplit.py b/nw/gui/docsplit.py index 3f497c18..1a5e9491 100644 --- a/nw/gui/docsplit.py +++ b/nw/gui/docsplit.py @@ -154,7 +154,7 @@ class GuiDocSplit(QDialog): return # Check that another folder can be created - parTree = self.theProject.projTree.getItemPath(srcItem.parHandle) + parTree = self.theProject.projTree.getItemPath(srcItem.itemParent) if len(parTree) >= nwConst.maxDepth - 1: self.theParent.makeAlert(( "Cannot add new folder for the document split. " @@ -176,7 +176,7 @@ class GuiDocSplit(QDialog): # Create the folder fHandle = self.theProject.newFolder( - srcItem.itemName, srcItem.itemClass, srcItem.parHandle + srcItem.itemName, srcItem.itemClass, srcItem.itemParent ) self.theParent.treeView.revealNewTreeItem(fHandle) logger.verbose("Creating folder %s" % fHandle) diff --git a/nw/gui/docviewer.py b/nw/gui/docviewer.py index 2d24efc1..636c4637 100644 --- a/nw/gui/docviewer.py +++ b/nw/gui/docviewer.py @@ -3,7 +3,7 @@ novelWriter – GUI Document Viewer =================================== - Class holding the document html viewer + Class holding the main document viewer File History: Created: 2019-05-10 [0.0.1] GuiDocViewer @@ -34,10 +34,10 @@ import logging from PyQt5.QtCore import Qt, QUrl, QSize, pyqtSlot from PyQt5.QtGui import ( - QTextOption, QFont, QPalette, QColor, QTextCursor, QIcon + QTextOption, QFont, QPalette, QColor, QTextCursor, QIcon, QCursor ) from PyQt5.QtWidgets import ( - QTextBrowser, QWidget, QScrollArea, QLabel, QHBoxLayout, QToolButton, + qApp, QTextBrowser, QWidget, QScrollArea, QLabel, QHBoxLayout, QToolButton, QAction, QMenu ) @@ -124,11 +124,26 @@ class GuiDocViewer(QTextBrowser): theOpt.setAlignment(Qt.AlignJustify) self.qDocument.setDefaultTextOption(theOpt) + # Scroll bars + if self.mainConf.hideVScroll: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + if self.mainConf.hideHScroll: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + # Refresh the tab stops + if self.mainConf.verQtValue >= 51000: + self.setTabStopDistance(self.mainConf.getTabWidth()) + else: + self.setTabStopWidth(self.mainConf.getTabWidth()) + # If we have a document open, we should reload it in case the font changed if self.theHandle is not None: - tHandle = self.theHandle - self.clearViewer() - self.loadText(tHandle) + self.redrawText() return True @@ -144,6 +159,8 @@ class GuiDocViewer(QTextBrowser): return False logger.debug("Generating preview for item %s" % tHandle) + qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) + sPos = self.verticalScrollBar().value() aDoc = ToHtml(self.theProject, self.theParent) aDoc.setPreview(True, self.mainConf.viewComments, self.mainConf.viewSynopsis) @@ -195,7 +212,8 @@ class GuiDocViewer(QTextBrowser): # Since we change the content while it may still be rendering, we mark # the document dirty again to make sure it's re-rendered properly. - self.qDocument.markContentsDirty(0, self.qDocument.characterCount()) + self.redrawText() + qApp.restoreOverrideCursor() return True @@ -205,6 +223,12 @@ class GuiDocViewer(QTextBrowser): self.loadText(self.theHandle, updateHistory=False) return + def redrawText(self): + """Redraw the text by marking the document content as "dirty". + """ + self.qDocument.markContentsDirty(0, self.qDocument.characterCount()) + return + def loadFromTag(self, theTag): """Load text in the document from a reference given by a meta tag rather than a known handle. This function depends on the @@ -477,55 +501,45 @@ class GuiDocViewer(QTextBrowser): """ styleSheet = ( "body {{" - " color: rgb({tColR},{tColG},{tColB});" - " font-size: {textSize:.1f}pt;" + " color: rgb({tColR}, {tColG}, {tColB});" "}}\n" "h1, h2, h3, h4 {{" - " color: rgb({hColR},{hColG},{hColB});" + " color: rgb({hColR}, {hColG}, {hColB});" "}}\n" "a {{" - " color: rgb({aColR},{aColG},{aColB});" + " color: rgb({aColR}, {aColG}, {aColB});" "}}\n" "mark {{" - " color: rgb({eColR},{eColG},{eColB});" - "}}\n" - "table {{" - " margin: 10px 0px;" - "}}\n" - "td {{" - " padding: 0px 4px;" + " color: rgb({eColR}, {eColG}, {eColB});" "}}\n" ".tags {{" - " color: rgb({kColR},{kColG},{kColB});" - " font-wright: bold;" + " color: rgb({kColR}, {kColG}, {kColB});" "}}\n" ".comment {{" - " color: rgb({cColR},{cColG},{cColB});" + " color: rgb({cColR}, {cColG}, {cColB});" "}}\n" ".synopsis {{" - " color: rgb({mColR},{mColG},{mColB});" - " font-wright: bold;" + " color: rgb({mColR}, {mColG}, {mColB});" "}}\n" ).format( - textSize = self.mainConf.textSize, tColR = self.theTheme.colText[0], tColG = self.theTheme.colText[1], tColB = self.theTheme.colText[2], hColR = self.theTheme.colHead[0], hColG = self.theTheme.colHead[1], hColB = self.theTheme.colHead[2], - cColR = self.theTheme.colComm[0], - cColG = self.theTheme.colComm[1], - cColB = self.theTheme.colComm[2], - eColR = self.theTheme.colEmph[0], - eColG = self.theTheme.colEmph[1], - eColB = self.theTheme.colEmph[2], aColR = self.theTheme.colVal[0], aColG = self.theTheme.colVal[1], aColB = self.theTheme.colVal[2], + eColR = self.theTheme.colEmph[0], + eColG = self.theTheme.colEmph[1], + eColB = self.theTheme.colEmph[2], kColR = self.theTheme.colKey[0], kColG = self.theTheme.colKey[1], kColB = self.theTheme.colKey[2], + cColR = self.theTheme.colHidden[0], + cColG = self.theTheme.colHidden[1], + cColB = self.theTheme.colHidden[2], mColR = self.theTheme.colMod[0], mColG = self.theTheme.colMod[1], mColB = self.theTheme.colMod[2], diff --git a/nw/gui/itemdetails.py b/nw/gui/itemdetails.py index f64d570b..da1a8f4a 100644 --- a/nw/gui/itemdetails.py +++ b/nw/gui/itemdetails.py @@ -3,7 +3,7 @@ novelWriter – GUI Document Details ==================================== - Class holding the left side document details panel + Class holding the project tree item details panel File History: Created: 2019-04-24 [0.0.1] @@ -195,9 +195,9 @@ class GuiItemDetails(QWidget): we're already showing. """ if tHandle == self.theHandle: - self.cCountData.setText("{:n}".format(cC)) - self.wCountData.setText("{:n}".format(wC)) - self.pCountData.setText("{:n}".format(pC)) + self.cCountData.setText(f"{cC:n}") + self.wCountData.setText(f"{wC:n}") + self.pCountData.setText(f"{pC:n}") return def updateViewBox(self, tHandle): @@ -252,9 +252,9 @@ class GuiItemDetails(QWidget): self.layoutData.setText(nwLabels.LAYOUT_NAME[nwItem.itemLayout]) if nwItem.itemType == nwItemType.FILE: - self.cCountData.setText("{:n}".format(nwItem.charCount)) - self.wCountData.setText("{:n}".format(nwItem.wordCount)) - self.pCountData.setText("{:n}".format(nwItem.paraCount)) + self.cCountData.setText(f"{nwItem.charCount:n}") + self.wCountData.setText(f"{nwItem.wordCount:n}") + self.pCountData.setText(f"{nwItem.paraCount:n}") else: self.cCountData.setText("–") self.wCountData.setText("–") diff --git a/nw/gui/itemeditor.py b/nw/gui/itemeditor.py index 95a68c82..a56a93bf 100644 --- a/nw/gui/itemeditor.py +++ b/nw/gui/itemeditor.py @@ -3,7 +3,7 @@ novelWriter – GUI Item Editor =============================== - Class holding the item editor + Class holding the item editor dialog File History: Created: 2019-04-27 [0.0.1] diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 6e8e8cf7..706b5bba 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -3,10 +3,10 @@ novelWriter – GUI Main Menu ============================= - Class holding the main window + Class holding the main window menu File History: - Created: 2019-04-27 [0.0.1] (Split from winmain) + Created: 2019-04-27 [0.0.1] This file is a part of novelWriter Copyright 2018–2020, Veronica Berglyd Olsen @@ -274,8 +274,9 @@ class GuiMainMenu(QMenuBar): # Project > Exit self.aExitNW = QAction("Exit", self) - self.aExitNW.setStatusTip("Exit %s" % self.mainConf.appName) + self.aExitNW.setStatusTip("Exit novelWriter") self.aExitNW.setShortcut("Ctrl+Q") + self.aExitNW.setMenuRole(QAction.QuitRole) self.aExitNW.triggered.connect(lambda: self.theParent.closeMain()) self.projMenu.addAction(self.aExitNW) @@ -843,6 +844,7 @@ class GuiMainMenu(QMenuBar): self.aPreferences = QAction("Preferences", self) self.aPreferences.setStatusTip("Preferences") self.aPreferences.setShortcut("Ctrl+,") + self.aPreferences.setMenuRole(QAction.PreferencesRole) self.aPreferences.triggered.connect(lambda: self.theParent.showPreferencesDialog()) self.toolsMenu.addAction(self.aPreferences) @@ -855,14 +857,16 @@ class GuiMainMenu(QMenuBar): self.helpMenu = self.addMenu("&Help") # Help > About - self.aAboutNW = QAction("About %s" % self.mainConf.appName, self) - self.aAboutNW.setStatusTip("About %s" % self.mainConf.appName) + self.aAboutNW = QAction("About novelWriter", self) + self.aAboutNW.setStatusTip("About novelWriter") + self.aAboutNW.setMenuRole(QAction.AboutRole) self.aAboutNW.triggered.connect(lambda: self.theParent.showAboutNWDialog()) self.helpMenu.addAction(self.aAboutNW) # Help > About Qt5 self.aAboutQt = QAction("About Qt5", self) self.aAboutQt.setStatusTip("About Qt5") + self.aAboutQt.setMenuRole(QAction.AboutQtRole) self.aAboutQt.triggered.connect(lambda: self.theParent.showAboutQtDialog()) self.helpMenu.addAction(self.aAboutQt) diff --git a/nw/gui/outline.py b/nw/gui/outline.py index fcee1d8c..4238cc44 100644 --- a/nw/gui/outline.py +++ b/nw/gui/outline.py @@ -118,6 +118,7 @@ class GuiOutline(QTreeWidget): self.colIndex = {} self.treeNCols = 0 + self.initOutline() self.clearOutline() self.headerMenu.setHiddenState(self.colHidden) @@ -125,6 +126,22 @@ class GuiOutline(QTreeWidget): return + def initOutline(self): + """Set or update outline settings. + """ + # Scroll bars + if self.mainConf.hideVScroll: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + if self.mainConf.hideHScroll: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + return + def clearOutline(self): """Clear the tree and header and set the default values for the columns arrays. @@ -422,6 +439,10 @@ class GuiOutline(QTreeWidget): newItem = QTreeWidgetItem() hIcon = "doc_%s" % tLevel.lower() + cC = int(novIdx["cCount"]) + wC = int(novIdx["wCount"]) + pC = int(novIdx["pCount"]) + newItem.setText(self.colIndex[nwOutline.TITLE], novIdx["title"]) newItem.setData(self.colIndex[nwOutline.TITLE], Qt.UserRole, tHandle) newItem.setIcon(self.colIndex[nwOutline.TITLE], self.theTheme.getIcon(hIcon)) @@ -431,9 +452,9 @@ class GuiOutline(QTreeWidget): newItem.setText(self.colIndex[nwOutline.LINE], sTitle[1:].lstrip("0")) newItem.setData(self.colIndex[nwOutline.LINE], Qt.UserRole, sTitle) newItem.setText(self.colIndex[nwOutline.SYNOP], novIdx["synopsis"]) - newItem.setText(self.colIndex[nwOutline.CCOUNT], str(novIdx["cCount"])) - newItem.setText(self.colIndex[nwOutline.WCOUNT], str(novIdx["wCount"])) - newItem.setText(self.colIndex[nwOutline.PCOUNT], str(novIdx["pCount"])) + newItem.setText(self.colIndex[nwOutline.CCOUNT], f"{cC:n}") + newItem.setText(self.colIndex[nwOutline.WCOUNT], f"{wC:n}") + newItem.setText(self.colIndex[nwOutline.PCOUNT], f"{pC:n}") newItem.setTextAlignment(self.colIndex[nwOutline.CCOUNT], Qt.AlignRight) newItem.setTextAlignment(self.colIndex[nwOutline.WCOUNT], Qt.AlignRight) newItem.setTextAlignment(self.colIndex[nwOutline.PCOUNT], Qt.AlignRight) diff --git a/nw/gui/outlinedetails.py b/nw/gui/outlinedetails.py index c6372826..d19bf2e9 100644 --- a/nw/gui/outlinedetails.py +++ b/nw/gui/outlinedetails.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""novelWriter GUI Project Outline +"""novelWriter GUI Project Outline Details - novelWriter – GUI Project Outline -=================================== - Class holding the project outline view + novelWriter – GUI Project Outline Details +=========================================== + Class holding the project outline details panel File History: Created: 2020-06-02 [0.7.0] @@ -224,10 +224,28 @@ class GuiOutlineDetails(QScrollArea): self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.setWidgetResizable(True) + self.initDetails() + logger.debug("GuiOutlineDetails initialisation complete") return + def initDetails(self): + """Set or update outline settings. + """ + # Scroll bars + if self.mainConf.hideVScroll: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + if self.mainConf.hideHScroll: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + return + def showItem(self, tHandle, sTitle): """Update the content of the tree with the given handle and line number pointing to a header. @@ -248,9 +266,13 @@ class GuiOutlineDetails(QScrollArea): self.fileValue.setText(nwItem.itemName) self.itemValue.setText(nwItem.itemStatus) - self.cCValue.setText("{:n}".format(checkInt(novIdx["cCount"], 0))) - self.wCValue.setText("{:n}".format(checkInt(novIdx["wCount"], 0))) - self.pCValue.setText("{:n}".format(checkInt(novIdx["pCount"], 0))) + cC = checkInt(novIdx["cCount"], 0) + wC = checkInt(novIdx["wCount"], 0) + pC = checkInt(novIdx["pCount"], 0) + + self.cCValue.setText(f"{cC:n}") + self.wCValue.setText(f"{wC:n}") + self.pCValue.setText(f"{pC:n}") self.synopValue.setText(novIdx["synopsis"]) diff --git a/nw/gui/preferences.py b/nw/gui/preferences.py index ea475b70..945e35f6 100644 --- a/nw/gui/preferences.py +++ b/nw/gui/preferences.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""novelWriter GUI Config Editor +"""novelWriter GUI Preferences - novelWriter – GUI Config Editor -================================= - Class holding the config dialog + novelWriter – GUI Preferences +=============================== + Class holding the preferences dialog File History: Created: 2019-06-10 [0.1.5] @@ -55,15 +55,17 @@ class GuiPreferences(PagedDialog): self.setWindowTitle("Preferences") - self.tabGeneral = GuiConfigEditGeneralTab(self.theParent) - self.tabLayout = GuiConfigEditLayoutTab(self.theParent) - self.tabEditing = GuiConfigEditEditingTab(self.theParent) - self.tabAutoRep = GuiConfigEditAutoReplaceTab(self.theParent) + self.tabGeneral = GuiConfigEditGeneralTab(self.theParent) + self.tabProjects = GuiConfigEditProjectsTab(self.theParent) + self.tabLayout = GuiConfigEditLayoutTab(self.theParent) + self.tabEditing = GuiConfigEditEditingTab(self.theParent) + self.tabAutoRep = GuiConfigEditAutoReplaceTab(self.theParent) - self.addTab(self.tabGeneral, "General") - self.addTab(self.tabLayout, "Text Layout") - self.addTab(self.tabEditing, "Editor") - self.addTab(self.tabAutoRep, "Auto-Replace") + self.addTab(self.tabGeneral, "General") + self.addTab(self.tabProjects, "Projects") + self.addTab(self.tabLayout, "Text Layout") + self.addTab(self.tabEditing, "Editor") + self.addTab(self.tabAutoRep, "Auto-Replace") self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self.buttonBox.accepted.connect(self._doSave) @@ -91,6 +93,10 @@ class GuiPreferences(PagedDialog): validEntries &= retA needsRestart |= retB + retA, retB = self.tabProjects.saveValues() + validEntries &= retA + needsRestart |= retB + retA, retB = self.tabLayout.saveValues() validEntries &= retA needsRestart |= retB @@ -219,9 +225,94 @@ class GuiConfigEditGeneralTab(QWidget): self.showFullPath.setChecked(self.mainConf.showFullPath) self.mainForm.addRow( "Show full path in document header", - self.showFullPath + self.showFullPath, + "Shows the document title and parent folder names." ) + self.hideVScroll = QSwitch() + self.hideVScroll.setChecked(self.mainConf.hideVScroll) + self.mainForm.addRow( + "Hide vertical scroll bars in main windows", + self.hideVScroll, + "Scrolling with mouse wheel and keys only." + ) + + self.hideHScroll = QSwitch() + self.hideHScroll.setChecked(self.mainConf.hideHScroll) + self.mainForm.addRow( + "Hide horizontal scroll bars in main windows", + self.hideHScroll, + "Scrolling with mouse wheel and keys only." + ) + + return + + def saveValues(self): + """Save the values set for this tab. + """ + validEntries = True + needsRestart = False + + guiTheme = self.selectTheme.currentData() + guiIcons = self.selectIcons.currentData() + guiDark = self.preferDarkIcons.isChecked() + guiFont = self.guiFont.text() + guiFontSize = self.guiFontSize.value() + showFullPath = self.showFullPath.isChecked() + hideVScroll = self.hideVScroll.isChecked() + hideHScroll = self.hideHScroll.isChecked() + + # Check if restart is needed + needsRestart |= self.mainConf.guiTheme != guiTheme + needsRestart |= self.mainConf.guiIcons != guiIcons + needsRestart |= self.mainConf.guiFont != guiFont + needsRestart |= self.mainConf.guiFontSize != guiFontSize + + self.mainConf.guiTheme = guiTheme + self.mainConf.guiIcons = guiIcons + self.mainConf.guiDark = guiDark + self.mainConf.guiFont = guiFont + self.mainConf.guiFontSize = guiFontSize + self.mainConf.showFullPath = showFullPath + self.mainConf.hideVScroll = hideVScroll + self.mainConf.hideHScroll = hideHScroll + + self.mainConf.confChanged = True + + return validEntries, needsRestart + + ## + # Slots + ## + + def _selectFont(self): + """Open the QFontDialog and set a font for the font style. + """ + currFont = QFont() + currFont.setFamily(self.mainConf.guiFont) + currFont.setPointSize(self.mainConf.guiFontSize) + theFont, theStatus = QFontDialog.getFont(currFont, self) + if theStatus: + self.guiFont.setText(theFont.family()) + self.guiFontSize.setValue(theFont.pointSize()) + return + +# END Class GuiConfigEditGeneralTab + +class GuiConfigEditProjectsTab(QWidget): + + def __init__(self, theParent): + QWidget.__init__(self, theParent) + + self.mainConf = nw.CONFIG + self.theParent = theParent + self.theTheme = theParent.theTheme + + # The Form + self.mainForm = QConfigLayout() + self.mainForm.setHelpTextStyle(self.theTheme.helpText) + self.setLayout(self.mainForm) + # AutoSave Settings # ================= self.mainForm.addGroupLabel("Automatic Save") @@ -292,30 +383,12 @@ class GuiConfigEditGeneralTab(QWidget): validEntries = True needsRestart = False - guiTheme = self.selectTheme.currentData() - guiIcons = self.selectIcons.currentData() - guiDark = self.preferDarkIcons.isChecked() - guiFont = self.guiFont.text() - guiFontSize = self.guiFontSize.value() - showFullPath = self.showFullPath.isChecked() autoSaveDoc = self.autoSaveDoc.value() autoSaveProj = self.autoSaveProj.value() backupPath = self.backupPath backupOnClose = self.backupOnClose.isChecked() askBeforeBackup = self.askBeforeBackup.isChecked() - # Check if restart is needed - needsRestart |= self.mainConf.guiTheme != guiTheme - needsRestart |= self.mainConf.guiIcons != guiIcons - needsRestart |= self.mainConf.guiFont != guiFont - needsRestart |= self.mainConf.guiFontSize != guiFontSize - - self.mainConf.guiTheme = guiTheme - self.mainConf.guiIcons = guiIcons - self.mainConf.guiDark = guiDark - self.mainConf.guiFont = guiFont - self.mainConf.guiFontSize = guiFontSize - self.mainConf.showFullPath = showFullPath self.mainConf.autoSaveDoc = autoSaveDoc self.mainConf.autoSaveProj = autoSaveProj self.mainConf.backupPath = backupPath @@ -340,7 +413,7 @@ class GuiConfigEditGeneralTab(QWidget): dlgOpt = QFileDialog.Options() dlgOpt |= QFileDialog.ShowDirsOnly dlgOpt |= QFileDialog.DontUseNativeDialog - newDir = QFileDialog.getExistingDirectory( + newDir = QFileDialog.getExistingDirectory( self, "Backup Directory", currDir, options=dlgOpt ) if newDir: @@ -357,19 +430,7 @@ class GuiConfigEditGeneralTab(QWidget): self.askBeforeBackup.setEnabled(theState) return - def _selectFont(self): - """Open the QFontDialog and set a font for the font style. - """ - currFont = QFont() - currFont.setFamily(self.mainConf.guiFont) - currFont.setPointSize(self.mainConf.guiFontSize) - theFont, theStatus = QFontDialog.getFont(currFont, self) - if theStatus: - self.guiFont.setText(theFont.family()) - self.guiFontSize.setValue(theFont.pointSize()) - return - -# END Class GuiConfigEditGeneralTab +# END Class GuiConfigEditProjectsTab class GuiConfigEditLayoutTab(QWidget): @@ -494,6 +555,41 @@ class GuiConfigEditLayoutTab(QWidget): theUnit="px" ) + # Scroll Behaviour + # ================ + self.mainForm.addGroupLabel("Scroll Behaviour") + + ## Scroll Past End + self.scrollPastEnd = QSwitch() + self.scrollPastEnd.setChecked(self.mainConf.scrollPastEnd) + self.mainForm.addRow( + "Scroll past end of the document", + self.scrollPastEnd, + "Allows scrolling until last line is at the top." + ) + + ## Typewriter Scrolling + self.autoScroll = QSwitch() + self.autoScroll.setChecked(self.mainConf.autoScroll) + self.mainForm.addRow( + "Typewriter style scrolling when you type", + self.autoScroll, + "Tries to keep the cursor at a fixed vertical position." + ) + + ## Font Size + self.autoScrollPos = QSpinBox(self) + self.autoScrollPos.setMinimum(10) + self.autoScrollPos.setMaximum(90) + self.autoScrollPos.setSingleStep(1) + self.autoScrollPos.setValue(int(self.mainConf.autoScrollPos)) + self.mainForm.addRow( + "Minimum position for Typewriter scrolling", + self.autoScrollPos, + "In units of percentage of the editor height.", + theUnit = "%" + ) + return def saveValues(self): @@ -511,6 +607,9 @@ class GuiConfigEditLayoutTab(QWidget): doJustify = self.textJustify.isChecked() textMargin = self.textMargin.value() tabWidth = self.tabWidth.value() + scrollPastEnd = self.scrollPastEnd.isChecked() + autoScroll = self.autoScroll.isChecked() + autoScrollPos = self.autoScrollPos.value() self.mainConf.textFont = textFont self.mainConf.textSize = textSize @@ -521,6 +620,9 @@ class GuiConfigEditLayoutTab(QWidget): self.mainConf.doJustify = doJustify self.mainConf.textMargin = textMargin self.mainConf.tabWidth = tabWidth + self.mainConf.scrollPastEnd = scrollPastEnd + self.mainConf.autoScroll = autoScroll + self.mainConf.autoScrollPos = autoScrollPos self.mainConf.confChanged = True diff --git a/nw/gui/projload.py b/nw/gui/projload.py index 8f18f7bb..35621032 100644 --- a/nw/gui/projload.py +++ b/nw/gui/projload.py @@ -3,7 +3,7 @@ novelWriter – GUI Open Project ================================ - New and open project dialog + Class holding the load/browse/new project dialog File History: Created: 2020-02-26 [0.4.5] diff --git a/nw/gui/projsettings.py b/nw/gui/projsettings.py index 998ec83c..d51d8006 100644 --- a/nw/gui/projsettings.py +++ b/nw/gui/projsettings.py @@ -270,11 +270,12 @@ class GuiProjectEditMeta(QWidget): self.revLabel = QLabel("Revision count:") self.revLabel.setIndent(xInd) - self.revValue = QLabel("{:n}".format(self.theProject.saveCount)) + self.revValue = QLabel(f"{self.theProject.saveCount:n}") + editHours = self.theProject.editTime/3600 self.editLabel = QLabel("Edit time:") self.editLabel.setIndent(xInd) - self.editValue = QLabel("{:.2f} hours".format(self.theProject.editTime/3600)) + self.editValue = QLabel(f"{editHours:.2f} hours") self.statsLabel = QLabel("Project Stats") @@ -282,19 +283,19 @@ class GuiProjectEditMeta(QWidget): self.nRootLabel = QLabel("Root folders:") self.nRootLabel.setIndent(xInd) - self.nRootValue = QLabel("{:n}".format(nR)) + self.nRootValue = QLabel(f"{nR:n}") self.nDirLabel = QLabel("Folders:") self.nDirLabel.setIndent(xInd) - self.nDirValue = QLabel("{:n}".format(nD)) + self.nDirValue = QLabel(f"{nD:n}") self.nFileLabel = QLabel("Documents:") self.nFileLabel.setIndent(xInd) - self.nFileValue = QLabel("{:n}".format(nF)) + self.nFileValue = QLabel(f"{nF:n}") self.wordsLabel = QLabel("Word count:") self.wordsLabel.setIndent(xInd) - self.wordsValue = QLabel("{:n}".format(self.theProject.currWCount)) + self.wordsValue = QLabel(f"{self.theProject.currWCount:n}") self.mainForm.addWidget(self.headLabel, 0, 0, 1, 2, Qt.AlignTop) self.mainForm.addWidget(self.nameLabel, 1, 0, 1, 1, Qt.AlignTop) diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index dfada052..89e41374 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""novelWriter GUI Document Tree +"""novelWriter GUI Project Tree - novelWriter – GUI Document Tree -================================= - Class holding the left side document tree view + novelWriter – GUI project Tree +================================ + Class holding the project tree view File History: Created: 2018-09-29 [0.0.1] GuiProjectTree @@ -115,6 +115,9 @@ class GuiProjectTree(QTreeWidget): # The last column should just auto-scale self.resizeColumnToContents(self.C_FLAGS) + # Set custom settings + self.initTree() + logger.debug("GuiProjectTree initialisation complete") # Internal Mapping @@ -122,6 +125,22 @@ class GuiProjectTree(QTreeWidget): return + def initTree(self): + """Set or update tree widget settings. + """ + # Scroll bars + if self.mainConf.hideVScroll: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + if self.mainConf.hideHScroll: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + else: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + return + ## # Class Methods ## @@ -200,7 +219,7 @@ class GuiProjectTree(QTreeWidget): pItem = self.theProject.projTree[pHandle] if pItem.itemType == nwItemType.FILE: nHandle = pHandle - pHandle = pItem.parHandle + pHandle = pItem.itemParent # If we again have no home, give up if pHandle is None: @@ -241,7 +260,8 @@ class GuiProjectTree(QTreeWidget): # Add the new item to the tree if tHandle is not None: self.revealNewTreeItem(tHandle, nHandle) - self.theParent.editItem(tHandle) + if self.mainConf.showGUI: + self.theParent.editItem(tHandle) return True @@ -250,7 +270,7 @@ class GuiProjectTree(QTreeWidget): """ nwItem = self.theProject.projTree[tHandle] trItem = self._addTreeItem(nwItem, nHandle) - pHandle = nwItem.parHandle + pHandle = nwItem.itemParent if pHandle is not None and pHandle in self.theMap: self.theMap[pHandle].setExpanded(True) self.clearSelection() @@ -401,7 +421,7 @@ class GuiProjectTree(QTreeWidget): if nwItemS is None: return False - wCount = int(trItemS.text(self.C_COUNT)) + wCount = int(trItemS.data(self.C_COUNT, Qt.UserRole)) if nwItemS.itemType == nwItemType.FILE: logger.debug("User requested file %s moved to trash" % tHandle) trItemP = trItemS.parent() @@ -410,7 +430,7 @@ class GuiProjectTree(QTreeWidget): logger.error("Could not delete item") return False - pHandle = nwItemS.parHandle + pHandle = nwItemS.itemParent if self.theProject.projTree.isTrashRoot(pHandle): # If the file is in the trash folder already, as the # user if they want to permanently delete the file. @@ -550,12 +570,13 @@ class GuiProjectTree(QTreeWidget): """ tItem = self._getTreeItem(tHandle) if tItem is not None: - tItem.setText(self.C_COUNT, str(theCount)) + tItem.setText(self.C_COUNT, f"{theCount:n}") + tItem.setData(self.C_COUNT, Qt.UserRole, int(theCount)) pItem = tItem.parent() if pItem is not None: pCount = 0 for i in range(pItem.childCount()): - pCount += int(pItem.child(i).text(self.C_COUNT)) + pCount += int(pItem.child(i).data(self.C_COUNT, Qt.UserRole)) pHandle = pItem.data(self.C_NAME, Qt.UserRole) if not nDepth > nwConst.maxDepth + 1 and pHandle != "": @@ -575,7 +596,7 @@ class GuiProjectTree(QTreeWidget): tItem = self.topLevelItem(n) if tItem == self.orphRoot: continue - nWords += int(tItem.text(self.C_COUNT)) + nWords += int(tItem.data(self.C_COUNT, Qt.UserRole)) self.theProject.setProjectWordCount(nWords) sWords = self.theProject.getSessionWordCount() @@ -715,7 +736,7 @@ class GuiProjectTree(QTreeWidget): self.makeAlert("The item cannot be moved to that location.", nwAlert.ERROR) return - wCount = int(sItem.text(self.C_COUNT)) + wCount = int(sItem.data(self.C_COUNT, Qt.UserRole)) isSame = snItem.itemClass == dnItem.itemClass isNone = snItem.itemClass == nwItemClass.NO_CLASS isNote = snItem.itemLayout == nwItemLayout.NOTE @@ -794,7 +815,7 @@ class GuiProjectTree(QTreeWidget): project tree. """ tHandle = nwItem.itemHandle - pHandle = nwItem.parHandle + pHandle = nwItem.itemParent tClass = nwItem.itemClass newItem = QTreeWidgetItem([""]*4) @@ -809,6 +830,7 @@ class GuiProjectTree(QTreeWidget): newItem.setTextAlignment(self.C_FLAGS, Qt.AlignLeft | Qt.AlignVCenter) newItem.setData(self.C_NAME, Qt.UserRole, tHandle) + newItem.setData(self.C_COUNT, Qt.UserRole, 0) self.theMap[tHandle] = newItem if pHandle is None: @@ -881,6 +903,7 @@ class GuiProjectTree(QTreeWidget): self.orphRoot = newItem newItem.setExpanded(True) newItem.setData(self.C_NAME, Qt.UserRole, "") + newItem.setData(self.C_COUNT, Qt.UserRole, 0) newItem.setIcon(self.C_NAME, self.theTheme.getIcon("proj_orphan")) return @@ -999,11 +1022,11 @@ class GuiProjectTreeMenu(QMenu): trashHandle = self.theTree.theProject.projTree.trashRoot() - inTrash = theItem.parHandle == trashHandle and trashHandle is not None + inTrash = theItem.itemParent == trashHandle and trashHandle is not None isTrash = theItem.itemHandle == trashHandle and trashHandle is not None isFile = theItem.itemType == nwItemType.FILE isArch = theRoot.itemClass == nwItemClass.ARCHIVE - isOrph = isFile and theItem.parHandle is None + isOrph = isFile and theItem.itemParent is None showOpen = isFile showView = isFile diff --git a/nw/gui/statusbar.py b/nw/gui/statusbar.py index 914fedae..ace755ae 100644 --- a/nw/gui/statusbar.py +++ b/nw/gui/statusbar.py @@ -35,6 +35,7 @@ from PyQt5.QtGui import QColor, QPainter from PyQt5.QtWidgets import qApp, QStatusBar, QLabel, QAbstractButton from nw.core import NWSpellCheck +from nw.common import formatTime logger = logging.getLogger(__name__) @@ -152,13 +153,17 @@ class GuiMainStatus(QStatusBar): qApp.processEvents() return - def setLanguage(self, theLanguage): + def setLanguage(self, theLanguage, theProvider=""): """Set the language code for the spell checker. """ if theLanguage is None: self.langText.setText("None") + self.langText.setToolTip("") else: self.langText.setText(NWSpellCheck.expandLanguage(theLanguage)) + self.langText.setToolTip( + "Provider: %s" % (theProvider if theProvider else "unknown") + ) return def setProjectStatus(self, isChanged): @@ -191,28 +196,18 @@ class GuiMainStatus(QStatusBar): self.statsText.setToolTip( "Project word count (session change)" ) - self.statsText.setText(( - "Words: {pWC:n} ({sWC:+n})" - ).format( - pWC = self.projWords, - sWC = self.sessWords, - )) + self.statsText.setText( + f"Words: {self.projWords:n} ({self.sessWords:+n})" + ) return def _updateTime(self): """Update the session clock. """ if self.refTime is None: - theTime = "00:00:00" + self.timeText.setText("00:00:00") else: - # This is much faster than using datetime format - tS = int(time() - self.refTime) - tM = int(tS/60) - tH = int(tM/60) - tM = tM - tH*60 - tS = tS - tM*60 - tH*3600 - theTime = "%02d:%02d:%02d" % (tH, tM, tS) - self.timeText.setText(theTime) + self.timeText.setText(formatTime(round(time() - self.refTime))) return # END Class GuiMainStatus @@ -233,7 +228,7 @@ class StatusLED(QAbstractButton): return ## - # Getters and Setters + # Setters ## def setState(self, theState): diff --git a/nw/gui/theme.py b/nw/gui/theme.py index 0af7df5e..2b5f31f5 100644 --- a/nw/gui/theme.py +++ b/nw/gui/theme.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""novelWriter Theme Class +"""novelWriter Theme and Icons Classes - novelWriter – Theme Class -=========================== - This class reads and store the main theme + novelWriter – Theme and Icons Classs +====================================== + Class managing and caching themes and icons File History: Created: 2019-05-18 [0.1.3] GuiTheme @@ -103,7 +103,7 @@ class GuiTheme: self.colDialN = [0, 0, 0] self.colDialD = [0, 0, 0] self.colDialS = [0, 0, 0] - self.colComm = [0, 0, 0] + self.colHidden = [0, 0, 0] self.colKey = [0, 0, 0] self.colVal = [0, 0, 0] self.colSpell = [0, 0, 0] @@ -356,7 +356,7 @@ class GuiTheme: self.colDialN = self._loadColour(confParser, cnfSec, "straightquotes") self.colDialD = self._loadColour(confParser, cnfSec, "doublequotes") self.colDialS = self._loadColour(confParser, cnfSec, "singlequotes") - self.colComm = self._loadColour(confParser, cnfSec, "hidden") + self.colHidden = self._loadColour(confParser, cnfSec, "hidden") self.colKey = self._loadColour(confParser, cnfSec, "keyword") self.colVal = self._loadColour(confParser, cnfSec, "value") self.colSpell = self._loadColour(confParser, cnfSec, "spellcheckline") @@ -529,6 +529,7 @@ class GuiIcons: "status_lang" : (None, None), "status_time" : (None, None), "status_stats" : (None, None), + "status_lines" : (None, None), "doc_h1" : (QStyle.SP_FileIcon, "x-office-document"), "doc_h2" : (QStyle.SP_FileIcon, "x-office-document"), "doc_h3" : (QStyle.SP_FileIcon, "x-office-document"), diff --git a/nw/gui/writingstats.py b/nw/gui/writingstats.py index c467615c..fb10f7a4 100644 --- a/nw/gui/writingstats.py +++ b/nw/gui/writingstats.py @@ -3,7 +3,7 @@ novelWriter – GUI Writing Statistics ====================================== - Class showing the word count and session statistics + Class holding the word count and session statistics dialog File History: Created: 2019-10-20 [0.3] @@ -39,6 +39,7 @@ from PyQt5.QtWidgets import ( QLabel, QGroupBox, QMenu, QAction, QFileDialog, QSpinBox, QHBoxLayout ) +from nw.common import formatTime from nw.constants import nwConst, nwFiles, nwAlert from nw.gui.custom import QSwitch @@ -123,11 +124,11 @@ class GuiWritingStats(QDialog): self.infoForm = QGridLayout(self) self.infoBox.setLayout(self.infoForm) - self.labelTotal = QLabel(self._formatTime(0)) + self.labelTotal = QLabel(formatTime(0)) self.labelTotal.setFont(self.theTheme.guiFontFixed) self.labelTotal.setAlignment(Qt.AlignVCenter | Qt.AlignRight) - self.labelFilter = QLabel(self._formatTime(0)) + self.labelFilter = QLabel(formatTime(0)) self.labelFilter.setFont(self.theTheme.guiFontFixed) self.labelFilter.setAlignment(Qt.AlignVCenter | Qt.AlignRight) @@ -334,12 +335,11 @@ class GuiWritingStats(QDialog): dlgOpt = QFileDialog.Options() dlgOpt |= QFileDialog.DontUseNativeDialog - saveTo = QFileDialog.getSaveFileName( + savePath, _ = QFileDialog.getSaveFileName( self, "Save Document As", savePath, options=dlgOpt ) - if saveTo: - savePath = saveTo[0] - else: + + if not savePath: return False self.mainConf.setLastPath(savePath) @@ -363,37 +363,33 @@ class GuiWritingStats(QDialog): "novelWords": wA, "noteWords": wB, }) - outFile.write(json.dumps(jsonData, indent=2)) + json.dump(jsonData, outFile, indent=2) wSuccess = True elif dataFmt == self.FMT_CSV: outFile.write( - "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"\n" % ( - "Date", "Length (sec)", "Words Changed", "Novel Words", "Note Words" - ) + '"Date","Length (sec)","Words Changed","Novel Words","Note Words"\n' ) for _, sD, tT, wD, wA, wB in self.filterData: - outFile.write( - "\"%s\",%d,%d,%d,%d\n" % (sD, tT, wD, wA, wB) - ) + outFile.write(f'"{sD}",{tT:.0f},{wD},{wA},{wB}\n') wSuccess = True else: errMsg = "Unknown format" except Exception as e: - errMsg = str(e) + errMsg = str(e).replace("\n", "