Compare commits
372 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83fca9fd94 | ||
|
|
4150a9740b | ||
|
|
f7021559d0 | ||
|
|
00d4c550f1 | ||
|
|
97903993c8 | ||
|
|
3d858ed9ab | ||
|
|
cc5cd50065 | ||
|
|
0c0d909dcb | ||
|
|
8dafaa13c0 | ||
|
|
05385ba73b | ||
|
|
924a2c3381 | ||
|
|
6502c424e2 | ||
|
|
243124a4ba | ||
|
|
4a2a14bc05 | ||
|
|
976328c9dc | ||
|
|
3b0086e75e | ||
|
|
7c38a748b6 | ||
|
|
4d98689304 | ||
|
|
8307822e20 | ||
|
|
f1548dc8ea | ||
|
|
25b33ab4d3 | ||
|
|
4af98dcab5 | ||
|
|
3cf916c845 | ||
|
|
bcac05ca20 | ||
|
|
07c61450c7 | ||
|
|
43d95462e0 | ||
|
|
c091e0b9bb | ||
|
|
18c70ffe3e | ||
|
|
ff470028be | ||
|
|
8463188336 | ||
|
|
2808c36753 | ||
|
|
05b1fdd7f2 | ||
|
|
00017e8065 | ||
|
|
ff34112532 | ||
|
|
4cadd1597e | ||
|
|
e30d2cbbf1 | ||
|
|
0449cf824d | ||
|
|
32d899dbfb | ||
|
|
9ec9c55c3f | ||
|
|
01b2b53fd5 | ||
|
|
be6888f8c8 | ||
|
|
c1ed0647fe | ||
|
|
1bf42ceb55 | ||
|
|
ca8bd8d271 | ||
|
|
bf23a4920f | ||
|
|
cfb048ec19 | ||
|
|
5284bea0f0 | ||
|
|
1244a4058c | ||
|
|
8c51363651 | ||
|
|
893f18c7ad | ||
|
|
909c97feda | ||
|
|
fea989c1f8 | ||
|
|
a64ed405f3 | ||
|
|
ffe6cd0a65 | ||
|
|
e0b27317f3 | ||
|
|
7ed619cf2e | ||
|
|
d55f53be0a | ||
|
|
55eed706fb | ||
|
|
2f71ec68d8 | ||
|
|
a45ab2f017 | ||
|
|
26b4da9eb4 | ||
|
|
4897f6ad69 | ||
|
|
bbe8683d59 | ||
|
|
072be78991 | ||
|
|
3f41e62e21 | ||
|
|
ec01336c21 | ||
|
|
5dcfe5c874 | ||
|
|
72f1bc2d77 | ||
|
|
54685c8184 | ||
|
|
3532306833 | ||
|
|
442b065834 | ||
|
|
53e70c118c | ||
|
|
a5575992d6 | ||
|
|
df0244095f | ||
|
|
337f05f60e | ||
|
|
4f8e898440 | ||
|
|
7991b9b27c | ||
|
|
ef42e1956c | ||
|
|
27b3dde6f6 | ||
|
|
cbe557507f | ||
|
|
7a9da5f219 | ||
|
|
d944a70063 | ||
|
|
c20f970405 | ||
|
|
49bb7f3a34 | ||
|
|
021827ff2d | ||
|
|
97312aecf5 | ||
|
|
f3c9a77989 | ||
|
|
2c031bc093 | ||
|
|
69147c2480 | ||
|
|
22ca0045bc | ||
|
|
af431a4766 | ||
|
|
5a8045131b | ||
|
|
7c0a6f1d02 | ||
|
|
4fc69ef7c6 | ||
|
|
91e502deb6 | ||
|
|
1ee191220f | ||
|
|
a96ccf8422 | ||
|
|
57641ecd7d | ||
|
|
ed431aa30f | ||
|
|
7a111bb173 | ||
|
|
220be06631 | ||
|
|
d8c14f6712 | ||
|
|
e514b1be4f | ||
|
|
5e7ef1d25a | ||
|
|
c2b3eb4ac2 | ||
|
|
1bd8370864 | ||
|
|
8c952953e3 | ||
|
|
c065c9b30f | ||
|
|
a18eb1158e | ||
|
|
08ef352288 | ||
|
|
25297b8b0b | ||
|
|
ea591fe707 | ||
|
|
b0bcf8605c | ||
|
|
b28921d6c2 | ||
|
|
87ae7585e8 | ||
|
|
b39b37fb2f | ||
|
|
cd9e0de9dd | ||
|
|
5107849909 | ||
|
|
5c988960f5 | ||
|
|
44792778aa | ||
|
|
0a7bd68258 | ||
|
|
30118eceaa | ||
|
|
87512eb13b | ||
|
|
9919e1a49e | ||
|
|
d04a21b70f | ||
|
|
fc6e0a577f | ||
|
|
0c659422b7 | ||
|
|
2018023dc7 | ||
|
|
04645cab71 | ||
|
|
bec082c0ae | ||
|
|
83b244a8a3 | ||
|
|
ae261381aa | ||
|
|
b088b79ee8 | ||
|
|
0b59ee8634 | ||
|
|
f62b755903 | ||
|
|
04036ef35c | ||
|
|
e4a2ca2d19 | ||
|
|
89f452f3fc | ||
|
|
843f8e0d39 | ||
|
|
8618306230 | ||
|
|
34a3e17326 | ||
|
|
5af49dfc6f | ||
|
|
837d3060b8 | ||
|
|
2754f78083 | ||
|
|
1b509ad60b | ||
|
|
82e923b181 | ||
|
|
e92b8400c7 | ||
|
|
1ad6a80682 | ||
|
|
f355b82bba | ||
|
|
66567b6d6f | ||
|
|
d9582436d4 | ||
|
|
31c75ea0b1 | ||
|
|
057fc10565 | ||
|
|
bbb07c9963 | ||
|
|
dd09d3af2f | ||
|
|
ccb5f8418b | ||
|
|
55d2605222 | ||
|
|
7f49979bc9 | ||
|
|
b529d5b125 | ||
|
|
f2e36d9974 | ||
|
|
ca2b509d54 | ||
|
|
0ba0674b9e | ||
|
|
a2b09fdfec | ||
|
|
fa0bef5500 | ||
|
|
92d148b03d | ||
|
|
3d935e29eb | ||
|
|
3749c9f21d | ||
|
|
2a3d3e46c2 | ||
|
|
9bc1561e01 | ||
|
|
d987246062 | ||
|
|
c6a22e2d96 | ||
|
|
42f904f1ea | ||
|
|
f92521c93a | ||
|
|
041183f914 | ||
|
|
857b6ddc55 | ||
|
|
f2d2b4c4f2 | ||
|
|
8725edaf7e | ||
|
|
4eace705e3 | ||
|
|
b2d71acd93 | ||
|
|
e424fcfe53 | ||
|
|
eefb40cb1f | ||
|
|
785cd3aea9 | ||
|
|
07c700f666 | ||
|
|
8db0928724 | ||
|
|
b25ba9de27 | ||
|
|
bf478a6706 | ||
|
|
a1a58f51b1 | ||
|
|
8850161641 | ||
|
|
f931148594 | ||
|
|
c31b0bcf17 | ||
|
|
ba260ac608 | ||
|
|
f2fc19b168 | ||
|
|
ceabc666c3 | ||
|
|
6c217c2450 | ||
|
|
7ff8921420 | ||
|
|
fe56c88309 | ||
|
|
b13b91c8e8 | ||
|
|
d71435df53 | ||
|
|
058758122c | ||
|
|
759cfd77e9 | ||
|
|
37268b873a | ||
|
|
439a04c603 | ||
|
|
9f642d501a | ||
|
|
7f8e2c66a6 | ||
|
|
9962fd4a55 | ||
|
|
c27343534f | ||
|
|
46ecb2cf70 | ||
|
|
88aa3f38c2 | ||
|
|
9214da3756 | ||
|
|
41e5152771 | ||
|
|
6b01bd7ffe | ||
|
|
76a9f8e3df | ||
|
|
e15941520f | ||
|
|
ff7404d01d | ||
|
|
560540618c | ||
|
|
bfa82f6bb3 | ||
|
|
f77cd1c752 | ||
|
|
8461b0c15d | ||
|
|
2938a0f122 | ||
|
|
beccd1a7d1 | ||
|
|
c6883908f1 | ||
|
|
36d8547f78 | ||
|
|
b01c91c0a3 | ||
|
|
3712b2e93f | ||
|
|
0ddc1131dc | ||
|
|
f4a941cd93 | ||
|
|
db72f2a722 | ||
|
|
16023676ea | ||
|
|
52e85835df | ||
|
|
fe63770d21 | ||
|
|
bd5442c9e5 | ||
|
|
c7554f2b8d | ||
|
|
63f9cd0bac | ||
|
|
c537d0f396 | ||
|
|
e759aa8d1b | ||
|
|
2d8664b3ce | ||
|
|
16076ebfb6 | ||
|
|
38ca447c8a | ||
|
|
f91006631d | ||
|
|
3cc5e4cc7a | ||
|
|
75178b7874 | ||
|
|
ce3f6dae20 | ||
|
|
bd82392d71 | ||
|
|
836a3d353a | ||
|
|
a4d2a9ea6f | ||
|
|
b0fc8d63a6 | ||
|
|
ec7781fb9f | ||
|
|
787e1a46d1 | ||
|
|
148d46fcbc | ||
|
|
7e1a676e0a | ||
|
|
e9220c5e77 | ||
|
|
72d41d4883 | ||
|
|
de6c3e9b45 | ||
|
|
a0ab507ed1 | ||
|
|
3c76d0e7e3 | ||
|
|
34127d78bf | ||
|
|
ca33f488f1 | ||
|
|
717aac9dc8 | ||
|
|
4b07dbadb2 | ||
|
|
9bbebd2085 | ||
|
|
d60b29e0a2 | ||
|
|
80d2dfd9a4 | ||
|
|
464454e7a6 | ||
|
|
d145b65237 | ||
|
|
912198cc40 | ||
|
|
bffab79ba7 | ||
|
|
c82f7fd9ca | ||
|
|
53afe528e8 | ||
|
|
225befe57e | ||
|
|
40817e73b4 | ||
|
|
17c93c2172 | ||
|
|
c8d7ccadb7 | ||
|
|
7bb292404a | ||
|
|
d2ac0ed53f | ||
|
|
c633a56bfb | ||
|
|
34696ba3a8 | ||
|
|
93c105fe86 | ||
|
|
29eac0b4e5 | ||
|
|
fc27078954 | ||
|
|
759a8e84f0 | ||
|
|
b2c7da319e | ||
|
|
b56095224a | ||
|
|
2f12a8cc93 | ||
|
|
140f85f214 | ||
|
|
7405c7e20f | ||
|
|
f29b13679a | ||
|
|
9b9ed53cf9 | ||
|
|
2447ae5cff | ||
|
|
7379b8b521 | ||
|
|
22473823c9 | ||
|
|
d8bb9950d3 | ||
|
|
279fe2ae18 | ||
|
|
1e812f3f06 | ||
|
|
0f1401eb71 | ||
|
|
57e739c3ef | ||
|
|
1b457585d9 | ||
|
|
c9eab0b01e | ||
|
|
3da5074d14 | ||
|
|
e97a476c7f | ||
|
|
a716f31b17 | ||
|
|
b2e760a284 | ||
|
|
7cc48aeb7f | ||
|
|
a6494b4226 | ||
|
|
5ed03e29f3 | ||
|
|
5c07148eb2 | ||
|
|
17b4a7be4d | ||
|
|
9b08ebbbd5 | ||
|
|
37c469dbc7 | ||
|
|
c08033d153 | ||
|
|
9c63f621a8 | ||
|
|
c3cb12e5e2 | ||
|
|
d3799bc30a | ||
|
|
cb8933b493 | ||
|
|
1aa58c047f | ||
|
|
52b3084bee | ||
|
|
70b05c9e07 | ||
|
|
cafa440aee | ||
|
|
681cb7226a | ||
|
|
b36e49e43f | ||
|
|
90a6c2d98d | ||
|
|
21d21846ca | ||
|
|
cc8d7d9912 | ||
|
|
10527afbda | ||
|
|
d16ece2a81 | ||
|
|
e7f7dc8d60 | ||
|
|
2f2943452f | ||
|
|
08ae2b5351 | ||
|
|
eda1a1c4dd | ||
|
|
238abdb567 | ||
|
|
c3f9ceb03a | ||
|
|
b95e9fcda3 | ||
|
|
b3f8a2c18c | ||
|
|
18bbf7d86f | ||
|
|
8e3ad6d874 | ||
|
|
7a63ac83d2 | ||
|
|
8dc28ddbee | ||
|
|
15803cf0a3 | ||
|
|
5ac001781c | ||
|
|
66986b88f8 | ||
|
|
f4e12a63a5 | ||
|
|
09b0f34199 | ||
|
|
095c005c3f | ||
|
|
e414b6458d | ||
|
|
ece73784c2 | ||
|
|
8dd9644c13 | ||
|
|
556e8db104 | ||
|
|
ce979b6c84 | ||
|
|
d2f422a441 | ||
|
|
e4daff8956 | ||
|
|
c146c311c1 | ||
|
|
2e1b17dcdf | ||
|
|
2ef11f56e5 | ||
|
|
fdaeca78a7 | ||
|
|
93e7343fe9 | ||
|
|
5167608f7a | ||
|
|
7fe3a8520d | ||
|
|
d4a0881e77 | ||
|
|
7f42210f2f | ||
|
|
33497bc92d | ||
|
|
06aa2e6c07 | ||
|
|
3bffaa01dc | ||
|
|
66237c35ea | ||
|
|
f532857345 | ||
|
|
5936e1a68b | ||
|
|
17b3b2b770 | ||
|
|
826e805b06 | ||
|
|
5419d76606 | ||
|
|
0d84653f7d | ||
|
|
b7da56ac42 | ||
|
|
7140788b48 | ||
|
|
a43bd5a50e | ||
|
|
81dd98e83b |
9
.github/workflows/build_linux.yml
vendored
@@ -32,12 +32,15 @@ jobs:
|
||||
path: novelwriter/assets
|
||||
|
||||
- name: Build AppImage
|
||||
run: python pkgutils.py build-appimage --linux-tag $LINUX_TAG --python-version $PYTHON_VERSION
|
||||
id: build
|
||||
run: |
|
||||
echo "BUILD_VERSION=$(python pkgutils.py version)" >> $GITHUB_OUTPUT
|
||||
python pkgutils.py build-appimage --linux-tag $LINUX_TAG --python-version $PYTHON_VERSION
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Linux-AppImage
|
||||
path: dist_appimage
|
||||
name: novelWriter-${{ steps.build.outputs.BUILD_VERSION }}-Linux-AppImage
|
||||
path: dist_appimage/*.AppImage*
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
18
.github/workflows/build_mac.yml
vendored
@@ -25,13 +25,16 @@ jobs:
|
||||
path: novelwriter/assets
|
||||
|
||||
- name: Build App Bundle
|
||||
run: ./setup/macos/build.sh $PYTHON_VERSION $PACKAGE_ARCH $MINICONDA_ARCH
|
||||
id: build
|
||||
run: |
|
||||
echo "BUILD_VERSION=$(python pkgutils.py version)" >> $GITHUB_OUTPUT
|
||||
./setup/macos/build.sh $PYTHON_VERSION $PACKAGE_ARCH $MINICONDA_ARCH
|
||||
|
||||
- name: Upload DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MacOS-AMD64-DMG
|
||||
path: dist_macos
|
||||
name: novelWriter-${{ steps.build.outputs.BUILD_VERSION }}-MacOS-AMD64-DMG
|
||||
path: dist_macos/*.dmg*
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
@@ -53,12 +56,15 @@ jobs:
|
||||
path: novelwriter/assets
|
||||
|
||||
- name: Build App Bundle
|
||||
run: ./setup/macos/build.sh $PYTHON_VERSION $PACKAGE_ARCH $MINICONDA_ARCH
|
||||
id: build
|
||||
run: |
|
||||
echo "BUILD_VERSION=$(python pkgutils.py version)" >> $GITHUB_OUTPUT
|
||||
./setup/macos/build.sh $PYTHON_VERSION $PACKAGE_ARCH $MINICONDA_ARCH
|
||||
|
||||
- name: Upload DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MacOS-M1-DMG
|
||||
path: dist_macos
|
||||
name: novelWriter-${{ steps.build.outputs.BUILD_VERSION }}-MacOS-M1-DMG
|
||||
path: dist_macos/*.dmg*
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
31
.github/workflows/build_win.yml
vendored
@@ -26,12 +26,37 @@ jobs:
|
||||
path: novelwriter/assets
|
||||
|
||||
- name: Build Setup Installer
|
||||
run: python pkgutils.py build-win-exe
|
||||
id: build
|
||||
run: |
|
||||
echo "BUILD_VERSION=$(python pkgutils.py version)" >> $Env:GITHUB_OUTPUT
|
||||
python pkgutils.py build-win-exe
|
||||
|
||||
- name: Upload Artifacts
|
||||
- name: Upload Unsigned Artifacts
|
||||
id: upload-unsigned-artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Win-Setup
|
||||
name: novelWriter-${{ steps.build.outputs.BUILD_VERSION }}-Win-Setup
|
||||
path: dist/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Submit Signing Request
|
||||
uses: signpath/github-action-submit-signing-request@v1
|
||||
with:
|
||||
api-token: "${{ secrets.SIGNPATH_API_TOKEN }}"
|
||||
organization-id: "0471e52c-66fa-4e9a-bfb9-36167095ca3f"
|
||||
project-slug: "novelWriter"
|
||||
signing-policy-slug: "release-signing "
|
||||
github-artifact-id: "${{ steps.upload-unsigned-artifact.outputs.artifact-id }}"
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: "dist/"
|
||||
parameters: |
|
||||
version: "${{ steps.build.outputs.BUILD_VERSION }}"
|
||||
|
||||
- name: Upload Signed Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: novelWriter-${{ steps.build.outputs.BUILD_VERSION }}-Win-Setup-Signed
|
||||
path: dist/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
2
.github/workflows/test_linux.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
testLinux:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
223
CHANGELOG.md
@@ -1,5 +1,228 @@
|
||||
# novelWriter Changelog
|
||||
|
||||
## Version 2.6 Beta 2 [2024-12-23]
|
||||
|
||||
### Release Notes
|
||||
|
||||
This is a beta release of the next release version, and is intended for testing purposes. Please be
|
||||
careful when using this version on live writing projects, and make sure you take frequent backups.
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**New Features**
|
||||
|
||||
* The project tree has been completely rewritten under the hood. The main difference in the GUI is
|
||||
a lifting of restrictions on drag and drop. You can now select multiple items in multiple places
|
||||
in the tree, and drag them to a new location. It is still not allowed to drag root level items,
|
||||
or drop items on the root level. PR #2119.
|
||||
* You can now drag and drop items from the project tree onto the editor and viewer to load the
|
||||
documents there. PR #2119.
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
* Make line height in DocX scale correctly. Issue #2099. PR #2102.
|
||||
* Make margins scale with font size in ODT and HTML output. PR #2102.
|
||||
* Fix DocX page count to work with Office 365. PR #2102.
|
||||
* Fix font scaling issues for PDF output. Issue #2100. PR #2104.
|
||||
* Fix an issue that cleared the editor when the return key was used on a folder item. PR #2113.
|
||||
* Fix an issue where the viewer and preview would not reset text emphasis when selecting a default
|
||||
font style with emphasis features. Issue #2121. PR #2122.
|
||||
* Change how custom fonts are loaded to ensure that the Qt framework don't take shortcuts by using
|
||||
a similar font instead of the chosen font. This is a side effect of the Qt font matching feature.
|
||||
Issue #2118. PR #2122.
|
||||
|
||||
**Improvements**
|
||||
|
||||
* Move processing of alternative apostrophe and dash to a later point in the tokenization process.
|
||||
It needs to happen after dialogue is processed to work as intended. PR #2095.
|
||||
* Use icons for text margin settings in Build Settings. Issue #2098. PR #2102.
|
||||
* Replace the toolbar icon in the editor. PR #2103.
|
||||
* Turn off auto-default button on the Build dialog. Issue #2101. PR #2105.
|
||||
* Add ANSI colours to log output for easier debugging. PRs #2114 and #2117.
|
||||
* Document header is now aligned to the bottom of the top page margin in the same way for ODT and
|
||||
DocX output. Issue #2120. PR #2128.
|
||||
* Dialogue detection for quoted dialogue has been improved to better support Chinese, and other
|
||||
languages that don't use whitespaces to separate words. Some improvements have also been made to
|
||||
how single quoted dialogues are processed when apostrophes are present. Issue #2131. PR #2138.
|
||||
|
||||
**Code Improvements**
|
||||
|
||||
* Completely rewrite the project tree into a model/view set with one single definition if the
|
||||
project structure. PR #2109.
|
||||
* The auto-replace feature in the editor has been refactored. It is now better separated from the
|
||||
editor code, and may have some minor performance improvements. Issue #2129. PR #2132.
|
||||
|
||||
----
|
||||
|
||||
## Version 2.6 Beta 1 [2024-11-12]
|
||||
|
||||
### Release Notes
|
||||
|
||||
This is a beta release of the next release version, and is intended for testing purposes. Please be
|
||||
careful when using this version on live writing projects, and make sure you take frequent backups.
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**New Features**
|
||||
|
||||
* Added PDF build format. Issue #2046. PR #2048.
|
||||
* Added DocX (MS Word) format. Issue #1537. PR #2056.
|
||||
* The Build settings tool has the following changes: Ability to control page break and centring of
|
||||
main titles, an option to disable styling for headings altogether, and the ODT section has been
|
||||
generalised to apply to all document formats. Issue #2023. PR #2045.
|
||||
* Text and heading margin can now be control in the Manuscript Build tool. Issue #2023. PR #2051.
|
||||
* Word counts and other such statistics can now be inserted into the manuscript. This is done
|
||||
through new insert actions in the Insert menu, and processed at build time. Issue #2024.
|
||||
PR #2073.
|
||||
* Forced line breaks can now be inserted as a shortcode. This is useful when line breaks in
|
||||
paragraphs are ignored when building the manuscript. Issue #1991. PR #2063.
|
||||
* A new `@mentions` keyword has been added that can list any tag in the project as being mentioned
|
||||
in the current section. These are listed separately from tags present in the story section.
|
||||
Issue #1822. PR #2064.
|
||||
* Tags in novel documents can now be referenced with the `@story` keyword. Issue #1990. PR #2072.
|
||||
* URLs starting with http/https are now recognised and clickable in the editor, viewer, and
|
||||
manuscript preview. Issue #1426. PR #2067.
|
||||
* Added dialogue highlighting for Portuguese and Polish dialogue styles. Issues #2066 and #2070.
|
||||
Related to #1773. PRs #2068, #2079, #2081 and #2082.
|
||||
* Four dashes in the editor are now replaced by a horizontal bar. This is useful as a replacement
|
||||
symbol for em dash when using dialogue highlighting to avoid triggering it. The horizontal bar is
|
||||
replaced with an em dash in the manuscript. Issue #2070. PR #2079.
|
||||
* Manuscript Build settings can now be duplicated. Issue #1931. PR #2084.
|
||||
* Page breaks can optionally be made visible in the Manuscript preview. PR #2086.
|
||||
* The document status label can now be selected in the Outline View. Issue #1909. PR #2088.
|
||||
* The document viewer now has an edit button to open the document in the editor. Issue #1963.
|
||||
PR #2089.
|
||||
|
||||
**User Interface**
|
||||
|
||||
* The Manuscript Build settings have now been moved into a single scrollable page, using the same
|
||||
layout as in Preferences. The heading and filtering pages are still separate forms, as they are
|
||||
of a more complex nature. PR #2031.
|
||||
* The currently open document in the editor is now highlighted in the project tree, just like it is
|
||||
in the novel view. Issue #1981. PR #2077.
|
||||
* The details panel in the Outline view is now resizable. PR #2078.
|
||||
|
||||
**Improvements**
|
||||
|
||||
* Add an option to ignore a word in the spell checker. An ignored word is ignored only for the
|
||||
current writing session. PR #2030.
|
||||
* Word counts and other such statistics is now exported as custom variables to ODT files, and can
|
||||
be inserted in the text in LibreOffice, and similar. Issue #2033. PR #2035.
|
||||
|
||||
**Code Improvements**
|
||||
|
||||
* Added Python 3.13 coverage to ensure the new Python release is supported. Issue #2040. PR #2044.
|
||||
* Preparation for Qt6: Clean up Qt enums. PR #2025.
|
||||
* Preparation for Qt6: Replace Qt regular expression tools with the internal Python re module for
|
||||
markup processing and syntax highlighting. It is both slightly faster, and there are issues with
|
||||
text encoding in at least some versions of Qt6 or PyQt6. PRs #2028 and #2043.
|
||||
* Preparation for Qt6: Added a wrapper function for connecting signals to slots that has a
|
||||
different function signature. Python lambdas generate warnings inn Qt6. PR #2075.
|
||||
* Refactored manuscript formats and moved most of the processing to the Tokenizer class to simplify
|
||||
the format classes and also make them more consistent. PRs #2060, #2061 and #2062.
|
||||
* The document builder has been refactored to support more generalised format classes. PR #2047.
|
||||
* Action parent assignment has been rafactored in the main menu. #2075.
|
||||
* Raw text format output has been refactored and reduced to just appending documents. PR #2087.
|
||||
|
||||
----
|
||||
|
||||
## Version 2.5.3 [2024-11-26]
|
||||
|
||||
### Release Notes
|
||||
|
||||
This is a patch release that fixes a few minor bugs in the user interface, and with the HTML
|
||||
manuscript format. This patch also adds a Russian translation, and updated the German and
|
||||
Portuguese translations.
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
* Fixed the red/green LEDs on the statusbar for the Default Light theme. The colours were swapped.
|
||||
Issue #2057. PR #2059.
|
||||
* Moved the styles tag into the head tag for HTML manuscript output, which is the correct placement
|
||||
for them. Issue #2080. PR #2085.
|
||||
* Fixed a bug in the project tree where it was possible to drag content to root level on some Qt
|
||||
versions where the disallow flag is not obeyed. The illegal action is now additionally blocked by
|
||||
code. Issue #2108. PR #2109.
|
||||
|
||||
**Documentation**
|
||||
|
||||
* Updated a dead link to the spell check library with the new one. Issue #2042. PR #2059.
|
||||
|
||||
**Internationalisation**
|
||||
|
||||
* Russian translation added by Konstantin Tan (@k1kimosha). PR #2126
|
||||
* Updated German and Portuguese translations. PR #2126
|
||||
|
||||
----
|
||||
|
||||
## Version 2.5.2 [2024-09-17]
|
||||
|
||||
### Release Notes
|
||||
|
||||
This is a patch release that fixes a series of issues mostly affecting dialogue highlighting in the
|
||||
document viewer. The way the text is formatted in the viewer changed a lot in 2.5, and there were a
|
||||
few issues with the new implementation. The tab stop distance setting was also ignored, but has now
|
||||
been fixed as well.
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
* Fixed an issue where the tab stop distance setting was being ignored in the document viewer. It
|
||||
must be set again every time a document is loaded. Issue #1996. PR #2014.
|
||||
* Fixed an issue with overlapping formatting markers when processing text into new formats. This
|
||||
issues was introduced when dialogue highlighting was added to the document viewer and manuscript
|
||||
build. It arises because start and end of dialogue can occur on the same character as other
|
||||
formatting, and then the order these markers are processed matters. This was not properly handled
|
||||
in the code. Issue #2012. PR #2014.
|
||||
* Dialogue highlighting is now only applied to plain text paragraphs, not comments, headings, and
|
||||
other places where there should be no dialogue. Issue #2013. PR #2014.
|
||||
* Note documents no longer get dialogue highlighting. Issue #2011. PR #2014.
|
||||
|
||||
**Documentation**
|
||||
|
||||
* Added a section about spell checking in the documentation. PR #2015.
|
||||
|
||||
----
|
||||
|
||||
## Version 2.5.1 [2024-07-28]
|
||||
|
||||
### Release Notes
|
||||
|
||||
This is a patch release that fixes an issue with the lock file being left in the project when the
|
||||
project open is cancelled, a few issues with custom input boxes in Preferences, and a usability
|
||||
issue when selecting to edit a tag or reference from the Tags and References panel as well as fixed
|
||||
an issue with the Importance labels not being updated in the tabs for tags.
|
||||
|
||||
### Detailed Changelog
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
* Fixes an issue where the project lock file was created before certain checks where the user could
|
||||
cancel the project open process, leaving a lock file in the project folder behind. Issue #1977.
|
||||
PR #1978.
|
||||
* Fixes an issue where the user could add spaces to the pad before and after Preference settings,
|
||||
which would pad every space entered into the text. The Preferences settings no longer accept any
|
||||
white spaces in these boxes (they are removed on save) and also strips duplicate character
|
||||
entries. Issue #1985. PR #1986.
|
||||
* The narrator break and dialog symbol Preference text boxes will now be cleared if the settings is
|
||||
a white space. PR #1986.
|
||||
* Fixed an issue where Importance labels were not updated in the viewer panel's tags tabs when they
|
||||
where changed in Project Settings. Issue #1992. PR #1993.
|
||||
|
||||
**Usability**
|
||||
|
||||
* When selecting to edit a tag or reference from the panel below the Document Viewer, the editor
|
||||
now scrolls to the relevant heading as it does in the viewer. Issue #1983. PR #1987.
|
||||
|
||||
**Internationalisation**
|
||||
|
||||
* Latin American Spanish translation updated by Tommy Marplatt (@tmarplatt). PR #1989.
|
||||
|
||||
----
|
||||
|
||||
## Version 2.5 [2024-07-10]
|
||||
|
||||
### Release Notes
|
||||
|
||||
@@ -30,6 +30,7 @@ translators for the languages currently available:
|
||||
* **Norwegian:** Veronica Berglyd Olsen (vkbo)
|
||||
* **Polish:** Anna Maria Polak (Nauthiz)
|
||||
* **Portuguese:** Bruno Meneguello (bkmeneguello)
|
||||
* **Russian:** Konstantin Tan (k1kimosha)
|
||||
* **Simplified Chinese:** Qianzhi Long (longqzh)
|
||||
|
||||
Additional larger translation contributions:
|
||||
|
||||
@@ -29,6 +29,15 @@ documentation.
|
||||
* PyPi Project: [pypi.org/project/novelWriter](https://pypi.org/project/novelWriter)
|
||||
* Social Media: [fosstodon.org/@novelwriter](https://fosstodon.org/@novelwriter)
|
||||
|
||||
## Sponsors
|
||||
|
||||
<table style="border: none;">
|
||||
<tr>
|
||||
<td><img align="left" style="height: 25px;" src="https://raw.githubusercontent.com/vkbo/novelWriter/main/setup/signpath_logo.png"></td>
|
||||
<td>Free code signing on Windows provided by <a href="https://about.signpath.io/">SignPath.io</a>, certificate by <a href="https://signpath.org/">SignPath Foundation</a>.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Implementation
|
||||
|
||||
novelWriter is written with Python 3 (3.9+) using Qt5 and PyQt5 (5.15 only), and is released on
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Customisations
|
||||
**************
|
||||
|
||||
.. _Enchant: https://abiword.github.io/enchant
|
||||
.. _Enchant: https://rrthomas.github.io/enchant/
|
||||
.. _Free Desktop: https://cgit.freedesktop.org/libreoffice/dictionaries/tree/
|
||||
|
||||
There are a few ways you can customise novelWriter yourself. Currently, you can add new GUI themes,
|
||||
|
||||
@@ -118,6 +118,9 @@ The root folders are closely tied to the tags and reference system. Each folder
|
||||
the categories of tags that can be used to reference them. For more information about the tags
|
||||
listed, see :ref:`a_references_references`.
|
||||
|
||||
There is also a ``@mention`` keyword that can be used to reference any tag.
|
||||
See :ref:`a_references_references` for more details.
|
||||
|
||||
.. note::
|
||||
You can rename root folders to whatever you want. However, this doesn't change the reference
|
||||
keyword or what they do.
|
||||
|
||||
@@ -162,6 +162,12 @@ reference keywords allow multiple values.
|
||||
Custom references in the current section. The target must be a note tag in a **Custom** type
|
||||
root folder. The custom folder are for any other category of notes you may want to use.
|
||||
|
||||
``@mention``
|
||||
Anything mentioned, but not present in the current section. It is intended for those cases where
|
||||
you reveal details about a character or place in a scene without it being otherwise a part of
|
||||
it. This can be useful when checking for consistency later. Any tag in any root note folder can
|
||||
be listed under mentions.
|
||||
|
||||
The syntax highlighter will alert the user that the tags and references are used correctly, and
|
||||
that the tags referenced exist.
|
||||
|
||||
|
||||
@@ -165,6 +165,7 @@ a key or key combination for the inserted content.
|
||||
":kbd:`Ctrl+K`, :kbd:`G`", "Insert a ``@tag`` keyword"
|
||||
":kbd:`Ctrl+K`, :kbd:`H`", "Insert a short description comment"
|
||||
":kbd:`Ctrl+K`, :kbd:`L`", "Insert a ``@location`` keyword"
|
||||
":kbd:`Ctrl+K`, :kbd:`M`", "Insert a ``@mention`` keyword"
|
||||
":kbd:`Ctrl+K`, :kbd:`O`", "Insert an ``@object`` keyword"
|
||||
":kbd:`Ctrl+K`, :kbd:`P`", "Insert a ``@plot`` keyword"
|
||||
":kbd:`Ctrl+K`, :kbd:`S`", "Insert a synopsis comment"
|
||||
|
||||
@@ -40,6 +40,24 @@ Any :term:`references<reference>` in the editor can be opened in the viewer by m
|
||||
the label and pressing :kbd:`Ctrl+Return`. You can also control-click them with your mouse.
|
||||
|
||||
|
||||
Spell Checking
|
||||
--------------
|
||||
|
||||
A third party library called Enchant is used for spell checking in the editor. The controls for
|
||||
spell checking can be found in the **Tools** menu. You can also set spell checking language in
|
||||
**Project Settings**.
|
||||
|
||||
This spell checking library comes with support for custom words that you can add by selecting
|
||||
"Add Word to Dictionary" from the context menu when a word is highlighted by the spell checker as
|
||||
misspelled. The custom words are managed on a per-project basis, and can the list of words can be
|
||||
edited from the **Project Word List** tool available from the **Tools** menu.
|
||||
|
||||
.. note::
|
||||
|
||||
Generally, spell checking dictionaries are collected from your operating system, but on Windows
|
||||
they are not. See :ref:`a_custom_dict` for how to add spell checking dictionaries on Windows.
|
||||
|
||||
|
||||
Editor Auto-Completer
|
||||
---------------------
|
||||
|
||||
|
||||
621
i18n/nw_base.ts
647
i18n/nw_de_DE.ts
621
i18n/nw_en_US.ts
1021
i18n/nw_es_419.ts
621
i18n/nw_fr_FR.ts
621
i18n/nw_it_IT.ts
621
i18n/nw_ja_JP.ts
621
i18n/nw_nb_NO.ts
621
i18n/nw_nl_NL.ts
621
i18n/nw_pl_PL.ts
635
i18n/nw_pt_BR.ts
4991
i18n/nw_ru_RU.ts
Normal file
621
i18n/nw_zh_CN.ts
@@ -3,7 +3,7 @@ novelWriter – Init File
|
||||
=======================
|
||||
|
||||
File History:
|
||||
Created: 2018-09-22 [0.0.1]
|
||||
Created: 2018-09-22 [0.0.1] main
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
@@ -47,9 +47,9 @@ __license__ = "GPLv3"
|
||||
__author__ = "Veronica Berglyd Olsen"
|
||||
__maintainer__ = "Veronica Berglyd Olsen"
|
||||
__email__ = "code@vkbo.net"
|
||||
__version__ = "2.5"
|
||||
__hexversion__ = "0x020500f0"
|
||||
__date__ = "2024-07-10"
|
||||
__version__ = "2.6b2"
|
||||
__hexversion__ = "0x020600b2"
|
||||
__date__ = "2024-12-23"
|
||||
__status__ = "Stable"
|
||||
__domain__ = "novelwriter.io"
|
||||
|
||||
@@ -60,10 +60,26 @@ logger = logging.getLogger(__name__)
|
||||
# Main Program
|
||||
##
|
||||
|
||||
# Global config and data singletons
|
||||
# Globals Singletons
|
||||
CONFIG = Config()
|
||||
SHARED = SharedData()
|
||||
|
||||
# ANSI Colours
|
||||
C_RED = "\033[91m"
|
||||
C_GREEN = "\033[92m"
|
||||
C_YELLOW = "\033[93m"
|
||||
C_BLUE = "\033[94m"
|
||||
C_WHITE = "\033[97m"
|
||||
C_END = "\033[0m"
|
||||
|
||||
# Log Formats
|
||||
L_TIME = "[{asctime:}]"
|
||||
L_FILE = "{filename:>18}"
|
||||
L_LINE = "{lineno:<4d}"
|
||||
L_LVLP = "{levelname:8}"
|
||||
L_LVLC = "{levelname:17}"
|
||||
L_TEXT = "{message:}"
|
||||
|
||||
|
||||
def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
"""Parse command line, set up logging, and launch main GUI."""
|
||||
@@ -77,6 +93,7 @@ def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
"version",
|
||||
"info",
|
||||
"debug",
|
||||
"color",
|
||||
"style=",
|
||||
"config=",
|
||||
"data=",
|
||||
@@ -98,6 +115,7 @@ def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
" -v, --version Print program version and exit.\n"
|
||||
" --info Print additional runtime information.\n"
|
||||
" --debug Print debug output. Includes --info.\n"
|
||||
" --color Add ANSI colors to log output.\n"
|
||||
" --meminfo Show memory usage information in the status bar.\n"
|
||||
" --style= Sets Qt5 style flag. Defaults to 'Fusion'.\n"
|
||||
" --config= Alternative config file.\n"
|
||||
@@ -106,12 +124,12 @@ def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
|
||||
# Defaults
|
||||
logLevel = logging.WARN
|
||||
logFormat = "{levelname:8} {message:}"
|
||||
fmtFlags = 0b00
|
||||
confPath = None
|
||||
dataPath = None
|
||||
testMode = False
|
||||
qtStyle = "Fusion"
|
||||
cmdOpen = None
|
||||
qtStyle = "Fusion"
|
||||
cmdOpen = None
|
||||
|
||||
# Parse Options
|
||||
try:
|
||||
@@ -135,8 +153,10 @@ def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
logLevel = logging.INFO
|
||||
elif inOpt == "--debug":
|
||||
CONFIG.isDebug = True
|
||||
fmtFlags = fmtFlags | 0b10
|
||||
logLevel = logging.DEBUG
|
||||
logFormat = "[{asctime:}] {filename:>18}:{lineno:<4d} {levelname:8} {message:}"
|
||||
elif inOpt == "--color":
|
||||
fmtFlags = fmtFlags | 0b01
|
||||
elif inOpt == "--style":
|
||||
qtStyle = inArg
|
||||
elif inOpt == "--config":
|
||||
@@ -148,13 +168,32 @@ def main(sysArgs: list | None = None) -> GuiMain | None:
|
||||
elif inOpt == "--meminfo":
|
||||
CONFIG.memInfo = True
|
||||
|
||||
if fmtFlags & 0b01:
|
||||
# This will overwrite the default level names, and also ensure that
|
||||
# they can be converted back to integer levels
|
||||
logging.addLevelName(logging.DEBUG, f"{C_BLUE}DEBUG{C_END}")
|
||||
logging.addLevelName(logging.INFO, f"{C_GREEN}INFO{C_END}")
|
||||
logging.addLevelName(logging.WARNING, f"{C_YELLOW}WARNING{C_END}")
|
||||
logging.addLevelName(logging.ERROR, f"{C_RED}ERROR{C_END}")
|
||||
logging.addLevelName(logging.CRITICAL, f"{C_RED}CRITICAL{C_END}")
|
||||
|
||||
# Determine Log Format
|
||||
if fmtFlags == 0b00:
|
||||
logFmt = f"{L_LVLP} {L_TEXT}"
|
||||
elif fmtFlags == 0b01:
|
||||
logFmt = f"{L_LVLC} {L_TEXT}"
|
||||
elif fmtFlags == 0b10:
|
||||
logFmt = f"{L_TIME} {L_FILE}:{L_LINE} {L_LVLP} {L_TEXT}"
|
||||
elif fmtFlags == 0b11:
|
||||
logFmt = f"{L_TIME} {C_BLUE}{L_FILE}{C_END}:{C_WHITE}{L_LINE}{C_END} {L_LVLC} {L_TEXT}"
|
||||
|
||||
# Setup Logging
|
||||
pkgLogger = logging.getLogger(__package__)
|
||||
pkgLogger.setLevel(logLevel)
|
||||
if len(pkgLogger.handlers) == 0:
|
||||
# Make sure we only create one logger (mostly an issue with tests)
|
||||
cHandle = logging.StreamHandler()
|
||||
cHandle.setFormatter(logging.Formatter(fmt=logFormat, style="{"))
|
||||
cHandle.setFormatter(logging.Formatter(fmt=logFmt, style="{"))
|
||||
pkgLogger.addHandler(cHandle)
|
||||
|
||||
logger.info("Starting novelWriter %s (%s) %s", __version__, __hexversion__, __date__)
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"Notes": "Notizen",
|
||||
"Tag": "Schlagwort",
|
||||
"Point of View": "Perspektive",
|
||||
"Focus": "Mittelpunkt",
|
||||
"Focus": "Fokus",
|
||||
"Characters": "Charaktere",
|
||||
"Plot": "Handlungen",
|
||||
"Plot": "Handlung",
|
||||
"Timeline": "Zeitleiste",
|
||||
"Locations": "Schauplätze",
|
||||
"Objects": "Objekte",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"Objects": "Objects",
|
||||
"Entities": "Entities",
|
||||
"Custom": "Custom",
|
||||
"New Page": "New Page",
|
||||
"0": "Zero",
|
||||
"1": "One",
|
||||
"2": "Two",
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
{
|
||||
"Synopsis": "Сводка",
|
||||
"Short Description": "Краткое описание",
|
||||
"Comment": "Комментарий",
|
||||
"Notes": "Заметки",
|
||||
"Tag": "Тэг",
|
||||
"Point of View": "Точка зрения",
|
||||
"Focus": "Фокус",
|
||||
"Characters": "Персонажей",
|
||||
"Plot": "Сюжет",
|
||||
"Timeline": "Хронология",
|
||||
"Locations": "Локации",
|
||||
"Objects": "Объекты",
|
||||
"Entities": "Сущности",
|
||||
"Custom": "Другое",
|
||||
"0": "Ноль",
|
||||
"1": "Один",
|
||||
"2": "Два",
|
||||
|
||||
@@ -44,6 +44,7 @@ cls_template = mixed_document-new.svg
|
||||
cls_timeline = typ_calendar.svg
|
||||
cls_trash = typ_trash.svg
|
||||
cls_world = typ_location.svg
|
||||
copy = mixed_copy.svg
|
||||
cross = typ_times.svg
|
||||
document = typ_document.svg
|
||||
down = typ_chevron-down.svg
|
||||
@@ -63,6 +64,10 @@ font = nw_font.svg
|
||||
forward = typ_chevron-right.svg
|
||||
import = mixed_import.svg
|
||||
list = typ_th-list.svg
|
||||
margin_bottom = mixed_margin-bottom.svg
|
||||
margin_left = mixed_margin-left.svg
|
||||
margin_right = mixed_margin-right.svg
|
||||
margin_top = mixed_margin-top.svg
|
||||
maximise = typ_arrow-maximise.svg
|
||||
menu = typ_th-dot-menu.svg
|
||||
minimise = typ_arrow-minimise.svg
|
||||
@@ -93,6 +98,8 @@ search_regex = nw_search-regex.svg
|
||||
search_replace = mixed_search-replace.svg
|
||||
search_word = nw_search-word.svg
|
||||
settings = typ_cog.svg
|
||||
size_height = mixed_size-height.svg
|
||||
size_width = mixed_size-width.svg
|
||||
star = typ_star.svg
|
||||
status_idle = typ_media-pause-grey.svg
|
||||
status_lang = typ_globe-grey.svg
|
||||
@@ -101,6 +108,7 @@ status_stats = typ_chart-bar-grey.svg
|
||||
status_time = typ_stopwatch-grey.svg
|
||||
sticky-off = typ_pin-outline.svg
|
||||
sticky-on = typ_pin.svg
|
||||
toolbar = nw_toolbar.svg
|
||||
unchecked = mixed_input-unchecked.svg
|
||||
unfold-hide = typ_unfold-hidden.svg
|
||||
unfold-show = typ_unfold-visible.svg
|
||||
|
||||
4
novelwriter/assets/icons/typicons_dark/mixed_copy.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m7.8824 2c-1.2988 0-2.3529 1.0541-2.3529 2.3529v2.3529h-1.1765c-1.2988 0-2.3529 1.0541-2.3529 2.3529v10.588c0 1.2988 1.0541 2.3529 2.3529 2.3529h10.588c1.2988 0 2.3529-1.0541 2.3529-2.3529v-1.1765h2.3529c1.2988 0 2.3529-1.0541 2.3529-2.3529v-11.765c0-1.2988-1.0541-2.3529-2.3529-2.3529zm0 2.3529h11.765v11.765h-2.3529v-7.0588c0-1.2988-1.0541-2.3529-2.3529-2.3529h-7.0588zm-3.5294 4.7059h10.588c0.32353 0 0-0.32353 0 0v10.588h-10.588v-9.8943z" fill="#69c" stroke-width="1.1765"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 633 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".14902" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="18" width="20" height="4" rx="1" ry="1" fill="#848484" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 8.5959c0.78104 0.79361 0.78104 2.0837 0 2.8773l-6.4141 6.5268-6.4141-6.5268c-0.391-0.3968-0.58601-0.91769-0.58601-1.4386 0-0.52092 0.195-1.0418 0.58601-1.4386 0.78104-0.79464 2.047-0.79464 2.8281 0l3.5861 3.6475 3.5861-3.6475c0.78104-0.79464 2.047-0.79464 2.8281 0z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 713 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".14902" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="2" width="4" height="20" rx="1" ry="1" fill="#848484" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m15.404 18.5c-0.79361 0.78104-2.0837 0.78104-2.8773 0l-6.5268-6.4141 6.5268-6.4141c0.3968-0.391 0.91769-0.58601 1.4386-0.58601 0.52092 0 1.0418 0.195 1.4386 0.58601 0.79464 0.78104 0.79464 2.047 0 2.8281l-3.6475 3.5861 3.6475 3.5861c0.79464 0.78104 0.79464 2.047 0 2.8281z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".14902" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="18" y="2" width="4" height="20" rx="1" ry="1" fill="#848484" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m8.5959 18.5c0.79361 0.78104 2.0837 0.78104 2.8773 0l6.5268-6.4141-6.5268-6.4141c-0.3968-0.391-0.91769-0.58601-1.4386-0.58601-0.52092 0-1.0418 0.195-1.4386 0.58601-0.79464 0.78104-0.79464 2.047 0 2.8281l3.6475 3.5861-3.6475 3.5861c-0.79464 0.78104-0.79464 2.047 0 2.8281z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".14902" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="2" width="20" height="4" rx="1" ry="1" fill="#848484" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 15.404c0.78104-0.79361 0.78104-2.0837 0-2.8773l-6.4141-6.5268-6.4141 6.5268c-0.391 0.3968-0.58601 0.91769-0.58601 1.4386 0 0.52092 0.195 1.0418 0.58601 1.4386 0.78104 0.79464 2.047 0.79464 2.8281 0l3.5861-3.6475 3.5861 3.6475c0.78104 0.79464 2.047 0.79464 2.8281 0z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 712 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".14902" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 10.404c0.78104-0.79361 0.78104-2.0837 0-2.8773l-6.4141-6.5268-6.4141 6.5268c-0.391 0.3968-0.58601 0.91769-0.58601 1.4386 0 0.52092 0.195 1.0418 0.58601 1.4386 0.78104 0.79464 2.047 0.79464 2.8281 0l3.5861-3.6475 3.5861 3.6475c0.78104 0.79464 2.047 0.79464 2.8281 0z" fill="#69c" stroke-width="1.0087"/>
|
||||
<path d="m18.414 13.596c0.78104 0.79361 0.78104 2.0837 0 2.8773l-6.4141 6.5268-6.4141-6.5268c-0.391-0.3968-0.58601-0.91769-0.58601-1.4386 0-0.52092 0.195-1.0418 0.58601-1.4386 0.78104-0.79464 2.047-0.79464 2.8281 0l3.5861 3.6475 3.5861-3.6475c0.78104-0.79464 2.047-0.79464 2.8281 0z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 921 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#e6e6e6" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m13.596 18.414c0.79361 0.78104 2.0837 0.78104 2.8773 0l6.5268-6.4141-6.5268-6.4141c-0.3968-0.391-0.91769-0.58601-1.4386-0.58601-0.52092 0-1.0418 0.195-1.4386 0.58601-0.79464 0.78104-0.79464 2.047 0 2.8281l3.6475 3.5861-3.6475 3.5861c-0.79464 0.78104-0.79464 2.047 0 2.8281z" fill="#69c" stroke-width="1.0087"/>
|
||||
<path d="m10.404 18.414c-0.79361 0.78104-2.0837 0.78104-2.8773 0l-6.5268-6.4141 6.5268-6.4141c0.3968-0.391 0.91769-0.58601 1.4386-0.58601 0.52092 0 1.0418 0.195 1.4386 0.58601 0.79464 0.78104 0.79464 2.047 0 2.8281l-3.6475 3.5861 3.6475 3.5861c0.79464 0.78104 0.79464 2.047 0 2.8281z" fill="#69c" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 919 B |
5
novelwriter/assets/icons/typicons_dark/nw_toolbar.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="2" width="8" height="8" rx="1" ry="1" fill="none" stroke="#69c" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="8" y="14" width="8" height="8" rx="1" ry="1" fill="none" stroke="#69c" stroke-linecap="round" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -44,6 +44,7 @@ cls_template = mixed_document-new.svg
|
||||
cls_timeline = typ_calendar.svg
|
||||
cls_trash = typ_trash.svg
|
||||
cls_world = typ_location.svg
|
||||
copy = mixed_copy.svg
|
||||
cross = typ_times.svg
|
||||
document = typ_document.svg
|
||||
down = typ_chevron-down.svg
|
||||
@@ -63,6 +64,10 @@ font = nw_font.svg
|
||||
forward = typ_chevron-right.svg
|
||||
import = mixed_import.svg
|
||||
list = typ_th-list.svg
|
||||
margin_bottom = mixed_margin-bottom.svg
|
||||
margin_left = mixed_margin-left.svg
|
||||
margin_right = mixed_margin-right.svg
|
||||
margin_top = mixed_margin-top.svg
|
||||
maximise = typ_arrow-maximise.svg
|
||||
menu = typ_th-dot-menu.svg
|
||||
minimise = typ_arrow-minimise.svg
|
||||
@@ -93,6 +98,8 @@ search_regex = nw_search-regex.svg
|
||||
search_replace = mixed_search-replace.svg
|
||||
search_word = nw_search-word.svg
|
||||
settings = typ_cog.svg
|
||||
size_height = mixed_size-height.svg
|
||||
size_width = mixed_size-width.svg
|
||||
star = typ_star.svg
|
||||
status_idle = typ_media-pause-grey.svg
|
||||
status_lang = typ_globe-grey.svg
|
||||
@@ -101,6 +108,7 @@ status_stats = typ_chart-bar-grey.svg
|
||||
status_time = typ_stopwatch-grey.svg
|
||||
sticky-off = typ_pin-outline.svg
|
||||
sticky-on = typ_pin.svg
|
||||
toolbar = nw_toolbar.svg
|
||||
unchecked = mixed_input-unchecked.svg
|
||||
unfold-hide = typ_unfold-hidden.svg
|
||||
unfold-show = typ_unfold-visible.svg
|
||||
|
||||
4
novelwriter/assets/icons/typicons_light/mixed_copy.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m7.8824 2c-1.2988 0-2.3529 1.0541-2.3529 2.3529v2.3529h-1.1765c-1.2988 0-2.3529 1.0541-2.3529 2.3529v10.588c0 1.2988 1.0541 2.3529 2.3529 2.3529h10.588c1.2988 0 2.3529-1.0541 2.3529-2.3529v-1.1765h2.3529c1.2988 0 2.3529-1.0541 2.3529-2.3529v-11.765c0-1.2988-1.0541-2.3529-2.3529-2.3529zm0 2.3529h11.765v11.765h-2.3529v-7.0588c0-1.2988-1.0541-2.3529-2.3529-2.3529h-7.0588zm-3.5294 4.7059h10.588c0.32353 0 0-0.32353 0 0v10.588h-10.588v-9.8943z" fill="#4271ae" stroke-width="1.1765"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 636 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="18" width="20" height="4" rx="1" ry="1" fill="#333" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 8.5959c0.78104 0.79361 0.78104 2.0837 0 2.8773l-6.4141 6.5268-6.4141-6.5268c-0.391-0.3968-0.58601-0.91769-0.58601-1.4386 0-0.52092 0.195-1.0418 0.58601-1.4386 0.78104-0.79464 2.047-0.79464 2.8281 0l3.5861 3.6475 3.5861-3.6475c0.78104-0.79464 2.047-0.79464 2.8281 0z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 707 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="2" width="4" height="20" rx="1" ry="1" fill="#333" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m15.404 18.5c-0.79361 0.78104-2.0837 0.78104-2.8773 0l-6.5268-6.4141 6.5268-6.4141c0.3968-0.391 0.91769-0.58601 1.4386-0.58601 0.52092 0 1.0418 0.195 1.4386 0.58601 0.79464 0.78104 0.79464 2.047 0 2.8281l-3.6475 3.5861 3.6475 3.5861c0.79464 0.78104 0.79464 2.047 0 2.8281z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 705 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="18" y="2" width="4" height="20" rx="1" ry="1" fill="#333" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m8.5959 18.5c0.79361 0.78104 2.0837 0.78104 2.8773 0l6.5268-6.4141-6.5268-6.4141c-0.3968-0.391-0.91769-0.58601-1.4386-0.58601-0.52092 0-1.0418 0.195-1.4386 0.58601-0.79464 0.78104-0.79464 2.047 0 2.8281l3.6475 3.5861-3.6475 3.5861c-0.79464 0.78104-0.79464 2.047 0 2.8281z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 705 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="2" y="2" width="20" height="4" rx="1" ry="1" fill="#333" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 15.404c0.78104-0.79361 0.78104-2.0837 0-2.8773l-6.4141-6.5268-6.4141 6.5268c-0.391 0.3968-0.58601 0.91769-0.58601 1.4386 0 0.52092 0.195 1.0418 0.58601 1.4386 0.78104 0.79464 2.047 0.79464 2.8281 0l3.5861-3.6475 3.5861 3.6475c0.78104 0.79464 2.047 0.79464 2.8281 0z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 706 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m18.414 10.404c0.78104-0.79361 0.78104-2.0837 0-2.8773l-6.4141-6.5268-6.4141 6.5268c-0.391 0.3968-0.58601 0.91769-0.58601 1.4386 0 0.52092 0.195 1.0418 0.58601 1.4386 0.78104 0.79464 2.047 0.79464 2.8281 0l3.5861-3.6475 3.5861 3.6475c0.78104 0.79464 2.047 0.79464 2.8281 0z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
<path d="m18.414 13.596c0.78104 0.79361 0.78104 2.0837 0 2.8773l-6.4141 6.5268-6.4141-6.5268c-0.391-0.3968-0.58601-0.91769-0.58601-1.4386 0-0.52092 0.195-1.0418 0.58601-1.4386 0.78104-0.79464 2.047-0.79464 2.8281 0l3.5861 3.6475 3.5861-3.6475c0.78104-0.79464 2.047-0.79464 2.8281 0z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 921 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="1" ry="1" fill="#333" fill-opacity=".15" stroke-linecap="round" stroke-width="2"/>
|
||||
<path d="m13.596 18.414c0.79361 0.78104 2.0837 0.78104 2.8773 0l6.5268-6.4141-6.5268-6.4141c-0.3968-0.391-0.91769-0.58601-1.4386-0.58601-0.52092 0-1.0418 0.195-1.4386 0.58601-0.79464 0.78104-0.79464 2.047 0 2.8281l3.6475 3.5861-3.6475 3.5861c-0.79464 0.78104-0.79464 2.047 0 2.8281z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
<path d="m10.404 18.414c-0.79361 0.78104-2.0837 0.78104-2.8773 0l-6.5268-6.4141 6.5268-6.4141c0.3968-0.391 0.91769-0.58601 1.4386-0.58601 0.52092 0 1.0418 0.195 1.4386 0.58601 0.79464 0.78104 0.79464 2.047 0 2.8281l-3.6475 3.5861 3.6475 3.5861c0.79464 0.78104 0.79464 2.047 0 2.8281z" fill="#4271ae" stroke-width="1.0087"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 922 B |
5
novelwriter/assets/icons/typicons_light/nw_toolbar.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="2" width="8" height="8" rx="1" ry="1" fill="none" stroke="#4271ae" stroke-linecap="round" stroke-width="2"/>
|
||||
<rect x="8" y="14" width="8" height="8" rx="1" ry="1" fill="none" stroke="#4271ae" stroke-linecap="round" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 395 B |
@@ -35,6 +35,7 @@ translators for the languages currently available:</p>
|
||||
<li><b>Norwegian:</b> Veronica Berglyd Olsen (vkbo)</li>
|
||||
<li><b>Polish:</b> Anna Maria Polak (Nauthiz)</li>
|
||||
<li><b>Portuguese:</b> Bruno Meneguello (bkmeneguello)</li>
|
||||
<li><b>Russian:</b> Konstantin Tan (k1kimosha)</li>
|
||||
<li><b>Simplified Chinese:</b> Qianzhi Long (longqzh)</li>
|
||||
</ul>
|
||||
|
||||
|
||||
@@ -29,5 +29,5 @@ helptext = 92, 92, 92
|
||||
fadedtext = 108, 108, 108
|
||||
errortext = 255, 92, 92
|
||||
statusnone = 120, 120, 120
|
||||
statussaved = 200, 15, 39
|
||||
statusunsaved = 2, 133, 37
|
||||
statussaved = 2, 133, 37
|
||||
statusunsaved = 200, 15, 39
|
||||
|
||||
@@ -29,6 +29,7 @@ import unicodedata
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from collections.abc import Callable
|
||||
from configparser import ConfigParser
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -36,8 +37,8 @@ from typing import TYPE_CHECKING, Any, Literal, TypeVar
|
||||
from urllib.parse import urljoin
|
||||
from urllib.request import pathname2url
|
||||
|
||||
from PyQt5.QtCore import QCoreApplication, QUrl
|
||||
from PyQt5.QtGui import QColor, QDesktopServices, QFont, QFontInfo
|
||||
from PyQt5.QtCore import QCoreApplication, QMimeData, QUrl
|
||||
from PyQt5.QtGui import QColor, QDesktopServices, QFont, QFontDatabase, QFontInfo
|
||||
|
||||
from novelwriter.constants import nwConst, nwLabels, nwUnicode, trConst
|
||||
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
|
||||
@@ -204,6 +205,14 @@ def checkIntTuple(value: int, valid: tuple | list | set, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def firstFloat(*args: Any) -> float:
|
||||
"""Return the first value that is a float."""
|
||||
for arg in args:
|
||||
if isinstance(arg, float):
|
||||
return arg
|
||||
return 0.0
|
||||
|
||||
|
||||
##
|
||||
# Formatting Functions
|
||||
##
|
||||
@@ -279,6 +288,16 @@ def simplified(text: str) -> str:
|
||||
return " ".join(str(text).strip().split())
|
||||
|
||||
|
||||
def compact(text: str) -> str:
|
||||
"""Compact a string by removing spaces."""
|
||||
return "".join(str(text).split())
|
||||
|
||||
|
||||
def uniqueCompact(text: str) -> str:
|
||||
"""Return a unique, compact and sorted string."""
|
||||
return "".join(sorted(set(compact(text))))
|
||||
|
||||
|
||||
def elide(text: str, length: int) -> str:
|
||||
"""Elide a piece of text to a maximum length."""
|
||||
if len(text) > (cut := max(4, length)):
|
||||
@@ -396,6 +415,10 @@ def numberToRoman(value: int, toLower: bool = False) -> str:
|
||||
return roman.lower() if toLower else roman
|
||||
|
||||
|
||||
##
|
||||
# Qt Helpers
|
||||
##
|
||||
|
||||
def cssCol(col: QColor, alpha: int | None = None) -> str:
|
||||
"""Convert a QColor object to an rgba entry to use in CSS."""
|
||||
return f"rgba({col.red()}, {col.green()}, {col.blue()}, {alpha or col.alpha()})"
|
||||
@@ -411,6 +434,48 @@ def describeFont(font: QFont) -> str:
|
||||
return "Error"
|
||||
|
||||
|
||||
def fontMatcher(font: QFont) -> QFont:
|
||||
"""Make sure the font is the correct family, if possible. This
|
||||
ensures that Qt doesn't re-use another font under the hood. The
|
||||
default Qt5 font matching algorithm doesn't handle well changing
|
||||
application fonts at runtime.
|
||||
"""
|
||||
info = QFontInfo(font)
|
||||
if (famRequest := font.family()) != (famActual := info.family()):
|
||||
logger.warning("Font mismatch: Requested '%s', but got '%s'", famRequest, famActual)
|
||||
db = QFontDatabase()
|
||||
if famRequest in db.families():
|
||||
styleRequest, sizeRequest = font.styleName(), font.pointSize()
|
||||
logger.info("Lookup: %s, %s, %d pt", famRequest, styleRequest, sizeRequest)
|
||||
temp = db.font(famRequest, styleRequest, sizeRequest)
|
||||
temp.setPointSize(sizeRequest) # Make sure it isn't changed
|
||||
famFound, styleFound, sizeFound = temp.family(), temp.styleName(), temp.pointSize()
|
||||
if famFound == famRequest:
|
||||
logger.info("Found: %s, %s, %d pt", famFound, styleFound, sizeFound)
|
||||
return temp
|
||||
logger.warning("Could not find a font match in the font database")
|
||||
logger.warning("If you just changed font, you may need to restart the application")
|
||||
return font
|
||||
|
||||
|
||||
def qtLambda(func: Callable, *args: Any, **kwargs: Any) -> Callable:
|
||||
"""A replacement for Python lambdas that works for Qt slots."""
|
||||
def wrapper(*a_: Any) -> None:
|
||||
func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def encodeMimeHandles(mimeData: QMimeData, handles: list[str]) -> None:
|
||||
"""Encode handles into a mime data object."""
|
||||
mimeData.setData(nwConst.MIME_HANDLE, b"|".join(h.encode() for h in handles))
|
||||
return
|
||||
|
||||
|
||||
def decodeMimeHandles(mimeData: QMimeData) -> list[str]:
|
||||
"""Decode and split a mime data object with handles."""
|
||||
return mimeData.data(nwConst.MIME_HANDLE).data().decode().split("|")
|
||||
|
||||
|
||||
##
|
||||
# Encoder Functions
|
||||
##
|
||||
@@ -462,6 +527,10 @@ def jsonEncode(data: dict | list | tuple, n: int = 0, nmax: int = 0) -> str:
|
||||
return "".join(buffer)
|
||||
|
||||
|
||||
##
|
||||
# XML Helpers
|
||||
##
|
||||
|
||||
def xmlIndent(tree: ET.Element | ET.ElementTree) -> None:
|
||||
"""A modified version of the XML indent function in the standard
|
||||
library. It behaves more closely to how the one from lxml does.
|
||||
@@ -505,6 +574,45 @@ def xmlIndent(tree: ET.Element | ET.ElementTree) -> None:
|
||||
return
|
||||
|
||||
|
||||
def xmlElement(
|
||||
tag: str,
|
||||
text: str | int | float | bool | None = None,
|
||||
*,
|
||||
attrib: dict | None = None,
|
||||
tail: str | None = None,
|
||||
) -> ET.Element:
|
||||
"""A custom implementation of Element with more arguments."""
|
||||
xSub = ET.Element(tag, attrib=attrib or {})
|
||||
if text is not None:
|
||||
if isinstance(text, bool):
|
||||
xSub.text = str(text).lower()
|
||||
else:
|
||||
xSub.text = str(text)
|
||||
if tail is not None:
|
||||
xSub.tail = tail
|
||||
return xSub
|
||||
|
||||
|
||||
def xmlSubElem(
|
||||
parent: ET.Element,
|
||||
tag: str,
|
||||
text: str | int | float | bool | None = None,
|
||||
*,
|
||||
attrib: dict | None = None,
|
||||
tail: str | None = None,
|
||||
) -> ET.Element:
|
||||
"""A custom implementation of SubElement with more arguments."""
|
||||
xSub = ET.SubElement(parent, tag, attrib=attrib or {})
|
||||
if text is not None:
|
||||
if isinstance(text, bool):
|
||||
xSub.text = str(text).lower()
|
||||
else:
|
||||
xSub.text = str(text)
|
||||
if tail is not None:
|
||||
xSub.tail = tail
|
||||
return xSub
|
||||
|
||||
|
||||
##
|
||||
# File and File System Functions
|
||||
##
|
||||
|
||||
@@ -40,7 +40,10 @@ from PyQt5.QtCore import (
|
||||
from PyQt5.QtGui import QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from novelwriter.common import NWConfigParser, checkInt, checkPath, describeFont, formatTimeStamp
|
||||
from novelwriter.common import (
|
||||
NWConfigParser, checkInt, checkPath, describeFont, fontMatcher,
|
||||
formatTimeStamp
|
||||
)
|
||||
from novelwriter.constants import nwFiles, nwUnicode
|
||||
from novelwriter.error import formatException, logException
|
||||
|
||||
@@ -161,8 +164,9 @@ class Config:
|
||||
|
||||
self.dialogStyle = 2 # Quote type to use for dialogue
|
||||
self.allowOpenDial = True # Allow open-ended dialogue quotes
|
||||
self.narratorBreak = "" # Symbol to use for narrator break
|
||||
self.dialogLine = "" # Symbol to use for dialogue line
|
||||
self.narratorBreak = "" # Symbol to use for narrator break
|
||||
self.narratorDialog = "" # Symbol for alternating between dialogue and narrator
|
||||
self.altDialogOpen = "" # Alternative dialog symbol, open
|
||||
self.altDialogClose = "" # Alternative dialog symbol, close
|
||||
self.highlightEmph = True # Add colour to text emphasis
|
||||
@@ -252,6 +256,10 @@ class Config:
|
||||
def hasError(self) -> bool:
|
||||
return self._hasError
|
||||
|
||||
@property
|
||||
def locale(self) -> QLocale:
|
||||
return self._dLocale
|
||||
|
||||
@property
|
||||
def recentProjects(self) -> RecentProjects:
|
||||
return self._recentProjects
|
||||
@@ -364,10 +372,11 @@ class Config:
|
||||
def setGuiFont(self, value: QFont | str | None) -> None:
|
||||
"""Update the GUI's font style from settings."""
|
||||
if isinstance(value, QFont):
|
||||
self.guiFont = value
|
||||
self.guiFont = fontMatcher(value)
|
||||
elif value and isinstance(value, str):
|
||||
self.guiFont = QFont()
|
||||
self.guiFont.fromString(value)
|
||||
font = QFont()
|
||||
font.fromString(value)
|
||||
self.guiFont = fontMatcher(font)
|
||||
else:
|
||||
font = QFont()
|
||||
fontDB = QFontDatabase()
|
||||
@@ -377,11 +386,9 @@ class Config:
|
||||
font.setPointSize(10)
|
||||
else:
|
||||
font = fontDB.systemFont(QFontDatabase.SystemFont.GeneralFont)
|
||||
self.guiFont = font
|
||||
self.guiFont = fontMatcher(font)
|
||||
logger.debug("GUI font set to: %s", describeFont(font))
|
||||
|
||||
QApplication.setFont(self.guiFont)
|
||||
|
||||
return
|
||||
|
||||
def setTextFont(self, value: QFont | str | None) -> None:
|
||||
@@ -389,10 +396,11 @@ class Config:
|
||||
set to default font.
|
||||
"""
|
||||
if isinstance(value, QFont):
|
||||
self.textFont = value
|
||||
self.textFont = fontMatcher(value)
|
||||
elif value and isinstance(value, str):
|
||||
self.textFont = QFont()
|
||||
self.textFont.fromString(value)
|
||||
font = QFont()
|
||||
font.fromString(value)
|
||||
self.textFont = fontMatcher(font)
|
||||
else:
|
||||
fontDB = QFontDatabase()
|
||||
fontFam = fontDB.families()
|
||||
@@ -406,8 +414,8 @@ class Config:
|
||||
font.setPointSize(12)
|
||||
else:
|
||||
font = fontDB.systemFont(QFontDatabase.SystemFont.GeneralFont)
|
||||
self.textFont = font
|
||||
logger.debug("Text font set to: %s", describeFont(font))
|
||||
self.textFont = fontMatcher(font)
|
||||
logger.debug("Text font set to: %s", describeFont(self.textFont))
|
||||
return
|
||||
|
||||
##
|
||||
@@ -653,10 +661,11 @@ class Config:
|
||||
self.showFullPath = conf.rdBool(sec, "showfullpath", self.showFullPath)
|
||||
self.dialogStyle = conf.rdInt(sec, "dialogstyle", self.dialogStyle)
|
||||
self.allowOpenDial = conf.rdBool(sec, "allowopendial", self.allowOpenDial)
|
||||
self.dialogLine = conf.rdStr(sec, "dialogline", self.dialogLine)
|
||||
self.narratorBreak = conf.rdStr(sec, "narratorbreak", self.narratorBreak)
|
||||
self.narratorDialog = conf.rdStr(sec, "narratordialog", self.narratorDialog)
|
||||
self.altDialogOpen = conf.rdStr(sec, "altdialogopen", self.altDialogOpen)
|
||||
self.altDialogClose = conf.rdStr(sec, "altdialogclose", self.altDialogClose)
|
||||
self.dialogLine = conf.rdStr(sec, "dialogline", self.dialogLine)
|
||||
self.highlightEmph = conf.rdBool(sec, "highlightemph", self.highlightEmph)
|
||||
self.stopWhenIdle = conf.rdBool(sec, "stopwhenidle", self.stopWhenIdle)
|
||||
self.userIdleTime = conf.rdInt(sec, "useridletime", self.userIdleTime)
|
||||
@@ -762,10 +771,11 @@ class Config:
|
||||
"showfullpath": str(self.showFullPath),
|
||||
"dialogstyle": str(self.dialogStyle),
|
||||
"allowopendial": str(self.allowOpenDial),
|
||||
"dialogline": str(self.dialogLine),
|
||||
"narratorbreak": str(self.narratorBreak),
|
||||
"narratordialog": str(self.narratorDialog),
|
||||
"altdialogopen": str(self.altDialogOpen),
|
||||
"altdialogclose": str(self.altDialogClose),
|
||||
"dialogline": str(self.dialogLine),
|
||||
"highlightemph": str(self.highlightEmph),
|
||||
"stopwhenidle": str(self.stopWhenIdle),
|
||||
"useridletime": str(self.userIdleTime),
|
||||
|
||||
@@ -53,6 +53,9 @@ class nwConst:
|
||||
# Requests
|
||||
USER_AGENT = "Mozilla/5.0 (compatible; novelWriter (Python))"
|
||||
|
||||
# Mime Types
|
||||
MIME_HANDLE = "text/vnd.novelwriter.handle"
|
||||
|
||||
# Gui Settings
|
||||
STATUS_MSG_TIMEOUT = 15000 # milliseconds
|
||||
MAX_SEARCH_RESULT = 1000
|
||||
@@ -60,11 +63,14 @@ class nwConst:
|
||||
|
||||
class nwRegEx:
|
||||
|
||||
URL = r"https?://(?:www\.|(?!www))[\w/()@:%_\+-.~#?&=]+"
|
||||
WORDS = r"\b[^\s\-\+\/–—\[\]:]+\b"
|
||||
BREAK = r"(?i)(?<!\\)(\[br\]\n?)"
|
||||
FMT_EI = r"(?<![\w\\])(_)(?![\s_])(.+?)(?<![\s\\])(\1)(?!\w)"
|
||||
FMT_EB = r"(?<![\w\\])(\*{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
|
||||
FMT_ST = r"(?<![\w\\])(~{2})(?![\s~])(.+?)(?<![\s\\])(\1)(?!\w)"
|
||||
FMT_SC = r"(?i)(?<!\\)(\[[\/\!]?(?:b|i|s|u|m|sup|sub)\])"
|
||||
FMT_SV = r"(?i)(?<!\\)(\[(?:footnote):)(.+?)(?<!\\)(\])"
|
||||
FMT_SC = r"(?i)(?<!\\)(\[(?:b|/b|i|/i|s|/s|u|/u|m|/m|sup|/sup|sub|/sub|br)\])"
|
||||
FMT_SV = r"(?i)(?<!\\)(\[(?:footnote|field):)(.+?)(?<!\\)(\])"
|
||||
|
||||
|
||||
class nwShortcode:
|
||||
@@ -83,21 +89,49 @@ class nwShortcode:
|
||||
SUP_C = "[/sup]"
|
||||
SUB_O = "[sub]"
|
||||
SUB_C = "[/sub]"
|
||||
BREAK = "[br]"
|
||||
|
||||
FOOTNOTE_B = "[footnote:"
|
||||
FIELD_B = "[field:"
|
||||
|
||||
COMMENT_STYLES = {
|
||||
nwComment.FOOTNOTE: "[footnote:{0}]",
|
||||
nwComment.COMMENT: "[comment:{0}]",
|
||||
}
|
||||
|
||||
FIELD_VALUE = "[field:{0}]"
|
||||
|
||||
class nwHeaders:
|
||||
|
||||
class nwStyles:
|
||||
|
||||
H_VALID = ("H0", "H1", "H2", "H3", "H4")
|
||||
H_LEVEL = {"H0": 0, "H1": 1, "H2": 2, "H3": 3, "H4": 4}
|
||||
H_SIZES = {0: 2.50, 1: 2.00, 2: 1.75, 3: 1.50, 4: 1.25}
|
||||
|
||||
T_NORMAL = 1.0
|
||||
T_SMALL = 0.8
|
||||
|
||||
T_LABEL = {
|
||||
"H0": QT_TRANSLATE_NOOP("Constant", "Title"),
|
||||
"H1": QT_TRANSLATE_NOOP("Constant", "Heading 1 (Partition)"),
|
||||
"H2": QT_TRANSLATE_NOOP("Constant", "Heading 2 (Chapter)"),
|
||||
"H3": QT_TRANSLATE_NOOP("Constant", "Heading 3 (Scene)"),
|
||||
"H4": QT_TRANSLATE_NOOP("Constant", "Heading 4 (Section)"),
|
||||
"TT": QT_TRANSLATE_NOOP("Constant", "Text Paragraph"),
|
||||
"SP": QT_TRANSLATE_NOOP("Constant", "Scene Separator"),
|
||||
}
|
||||
T_MARGIN = {
|
||||
"H0": (1.50, 0.60), # Title margins (top, bottom)
|
||||
"H1": (1.50, 0.60), # Heading 1 margins (top, bottom)
|
||||
"H2": (1.50, 0.60), # Heading 2 margins (top, bottom)
|
||||
"H3": (1.20, 0.60), # Heading 3 margins (top, bottom)
|
||||
"H4": (1.20, 0.60), # Heading 4 margins (top, bottom)
|
||||
"TT": (0.00, 0.60), # Text margins (top, bottom)
|
||||
"SP": (1.20, 1.20), # Separator margins (top, bottom)
|
||||
"MT": (0.00, 0.60), # Meta margins (top, bottom)
|
||||
"FT": (1.40, 0.40), # Footnote margins (left, bottom)
|
||||
}
|
||||
|
||||
|
||||
class nwFiles:
|
||||
|
||||
@@ -121,34 +155,40 @@ class nwFiles:
|
||||
|
||||
class nwKeyWords:
|
||||
|
||||
TAG_KEY = "@tag"
|
||||
POV_KEY = "@pov"
|
||||
FOCUS_KEY = "@focus"
|
||||
CHAR_KEY = "@char"
|
||||
PLOT_KEY = "@plot"
|
||||
TIME_KEY = "@time"
|
||||
WORLD_KEY = "@location"
|
||||
OBJECT_KEY = "@object"
|
||||
ENTITY_KEY = "@entity"
|
||||
CUSTOM_KEY = "@custom"
|
||||
TAG_KEY = "@tag"
|
||||
POV_KEY = "@pov"
|
||||
FOCUS_KEY = "@focus"
|
||||
CHAR_KEY = "@char"
|
||||
PLOT_KEY = "@plot"
|
||||
TIME_KEY = "@time"
|
||||
WORLD_KEY = "@location"
|
||||
OBJECT_KEY = "@object"
|
||||
ENTITY_KEY = "@entity"
|
||||
CUSTOM_KEY = "@custom"
|
||||
STORY_KEY = "@story"
|
||||
MENTION_KEY = "@mention"
|
||||
|
||||
# Note: The order here affects the order of menu entries
|
||||
ALL_KEYS = [
|
||||
TAG_KEY, POV_KEY, FOCUS_KEY, CHAR_KEY, PLOT_KEY, TIME_KEY, WORLD_KEY,
|
||||
OBJECT_KEY, ENTITY_KEY, CUSTOM_KEY, STORY_KEY, MENTION_KEY,
|
||||
]
|
||||
|
||||
# Set of Valid Keys
|
||||
VALID_KEYS = {
|
||||
TAG_KEY, POV_KEY, FOCUS_KEY, CHAR_KEY, PLOT_KEY, TIME_KEY,
|
||||
WORLD_KEY, OBJECT_KEY, ENTITY_KEY, CUSTOM_KEY
|
||||
}
|
||||
VALID_KEYS = set(ALL_KEYS)
|
||||
|
||||
# Map from Keys to Item Class
|
||||
KEY_CLASS = {
|
||||
POV_KEY: nwItemClass.CHARACTER,
|
||||
FOCUS_KEY: nwItemClass.CHARACTER,
|
||||
CHAR_KEY: nwItemClass.CHARACTER,
|
||||
PLOT_KEY: nwItemClass.PLOT,
|
||||
TIME_KEY: nwItemClass.TIMELINE,
|
||||
WORLD_KEY: nwItemClass.WORLD,
|
||||
OBJECT_KEY: nwItemClass.OBJECT,
|
||||
ENTITY_KEY: nwItemClass.ENTITY,
|
||||
CUSTOM_KEY: nwItemClass.CUSTOM,
|
||||
POV_KEY: nwItemClass.CHARACTER,
|
||||
FOCUS_KEY: nwItemClass.CHARACTER,
|
||||
CHAR_KEY: nwItemClass.CHARACTER,
|
||||
PLOT_KEY: nwItemClass.PLOT,
|
||||
TIME_KEY: nwItemClass.TIMELINE,
|
||||
WORLD_KEY: nwItemClass.WORLD,
|
||||
OBJECT_KEY: nwItemClass.OBJECT,
|
||||
ENTITY_KEY: nwItemClass.ENTITY,
|
||||
CUSTOM_KEY: nwItemClass.CUSTOM,
|
||||
STORY_KEY: nwItemClass.NOVEL,
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +205,29 @@ class nwLists:
|
||||
]
|
||||
|
||||
|
||||
class nwStats:
|
||||
|
||||
CHARS_ALL = "allChars"
|
||||
CHARS_TEXT = "textChars"
|
||||
CHARS_TITLE = "titleChars"
|
||||
PARAGRAPHS = "paragraphCount"
|
||||
TITLES = "titleCount"
|
||||
WCHARS_ALL = "allWordChars"
|
||||
WCHARS_TEXT = "textWordChars"
|
||||
WCHARS_TITLE = "titleWordChars"
|
||||
WORDS_ALL = "allWords"
|
||||
WORDS_TEXT = "textWords"
|
||||
WORDS_TITLE = "titleWords"
|
||||
|
||||
# Note: The order here affects the order of menu entries
|
||||
ALL_FIELDS = [
|
||||
WORDS_ALL, WORDS_TEXT, WORDS_TITLE,
|
||||
CHARS_ALL, CHARS_TEXT, CHARS_TITLE,
|
||||
WCHARS_ALL, WCHARS_TEXT, WCHARS_TITLE,
|
||||
PARAGRAPHS, TITLES,
|
||||
]
|
||||
|
||||
|
||||
class nwLabels:
|
||||
|
||||
CLASS_NAME = {
|
||||
@@ -211,54 +274,94 @@ class nwLabels:
|
||||
"doc_h4": QT_TRANSLATE_NOOP("Constant", "Novel Section"),
|
||||
"note": QT_TRANSLATE_NOOP("Constant", "Project Note"),
|
||||
}
|
||||
ACTIVE_NAME = {
|
||||
"checked": QT_TRANSLATE_NOOP("Constant", "Active"),
|
||||
"unchecked": QT_TRANSLATE_NOOP("Constant", "Inactive"),
|
||||
}
|
||||
KEY_NAME = {
|
||||
nwKeyWords.TAG_KEY: QT_TRANSLATE_NOOP("Constant", "Tag"),
|
||||
nwKeyWords.POV_KEY: QT_TRANSLATE_NOOP("Constant", "Point of View"),
|
||||
nwKeyWords.FOCUS_KEY: QT_TRANSLATE_NOOP("Constant", "Focus"),
|
||||
nwKeyWords.CHAR_KEY: QT_TRANSLATE_NOOP("Constant", "Characters"),
|
||||
nwKeyWords.PLOT_KEY: QT_TRANSLATE_NOOP("Constant", "Plot"),
|
||||
nwKeyWords.TIME_KEY: QT_TRANSLATE_NOOP("Constant", "Timeline"),
|
||||
nwKeyWords.WORLD_KEY: QT_TRANSLATE_NOOP("Constant", "Locations"),
|
||||
nwKeyWords.OBJECT_KEY: QT_TRANSLATE_NOOP("Constant", "Objects"),
|
||||
nwKeyWords.ENTITY_KEY: QT_TRANSLATE_NOOP("Constant", "Entities"),
|
||||
nwKeyWords.CUSTOM_KEY: QT_TRANSLATE_NOOP("Constant", "Custom"),
|
||||
nwKeyWords.TAG_KEY: QT_TRANSLATE_NOOP("Constant", "Tag"),
|
||||
nwKeyWords.POV_KEY: QT_TRANSLATE_NOOP("Constant", "Point of View"),
|
||||
nwKeyWords.FOCUS_KEY: QT_TRANSLATE_NOOP("Constant", "Focus"),
|
||||
nwKeyWords.CHAR_KEY: QT_TRANSLATE_NOOP("Constant", "Characters"),
|
||||
nwKeyWords.PLOT_KEY: QT_TRANSLATE_NOOP("Constant", "Plot"),
|
||||
nwKeyWords.TIME_KEY: QT_TRANSLATE_NOOP("Constant", "Timeline"),
|
||||
nwKeyWords.WORLD_KEY: QT_TRANSLATE_NOOP("Constant", "Locations"),
|
||||
nwKeyWords.OBJECT_KEY: QT_TRANSLATE_NOOP("Constant", "Objects"),
|
||||
nwKeyWords.ENTITY_KEY: QT_TRANSLATE_NOOP("Constant", "Entities"),
|
||||
nwKeyWords.CUSTOM_KEY: QT_TRANSLATE_NOOP("Constant", "Custom"),
|
||||
nwKeyWords.STORY_KEY: QT_TRANSLATE_NOOP("Constant", "Story"),
|
||||
nwKeyWords.MENTION_KEY: QT_TRANSLATE_NOOP("Constant", "Mentions"),
|
||||
}
|
||||
KEY_SHORTCUT = {
|
||||
nwKeyWords.TAG_KEY: "Ctrl+K, G",
|
||||
nwKeyWords.POV_KEY: "Ctrl+K, V",
|
||||
nwKeyWords.FOCUS_KEY: "Ctrl+K, F",
|
||||
nwKeyWords.CHAR_KEY: "Ctrl+K, C",
|
||||
nwKeyWords.PLOT_KEY: "Ctrl+K, P",
|
||||
nwKeyWords.TIME_KEY: "Ctrl+K, T",
|
||||
nwKeyWords.WORLD_KEY: "Ctrl+K, L",
|
||||
nwKeyWords.OBJECT_KEY: "Ctrl+K, O",
|
||||
nwKeyWords.ENTITY_KEY: "Ctrl+K, E",
|
||||
nwKeyWords.CUSTOM_KEY: "Ctrl+K, X",
|
||||
nwKeyWords.STORY_KEY: "Ctrl+K, N",
|
||||
nwKeyWords.MENTION_KEY: "Ctrl+K, M",
|
||||
}
|
||||
OUTLINE_COLS = {
|
||||
nwOutline.TITLE: QT_TRANSLATE_NOOP("Constant", "Title"),
|
||||
nwOutline.LEVEL: QT_TRANSLATE_NOOP("Constant", "Level"),
|
||||
nwOutline.LABEL: QT_TRANSLATE_NOOP("Constant", "Document"),
|
||||
nwOutline.LINE: QT_TRANSLATE_NOOP("Constant", "Line"),
|
||||
nwOutline.CCOUNT: QT_TRANSLATE_NOOP("Constant", "Chars"),
|
||||
nwOutline.WCOUNT: QT_TRANSLATE_NOOP("Constant", "Words"),
|
||||
nwOutline.PCOUNT: QT_TRANSLATE_NOOP("Constant", "Pars"),
|
||||
nwOutline.POV: QT_TRANSLATE_NOOP("Constant", "POV"),
|
||||
nwOutline.FOCUS: QT_TRANSLATE_NOOP("Constant", "Focus"),
|
||||
nwOutline.CHAR: KEY_NAME[nwKeyWords.CHAR_KEY],
|
||||
nwOutline.PLOT: KEY_NAME[nwKeyWords.PLOT_KEY],
|
||||
nwOutline.WORLD: KEY_NAME[nwKeyWords.WORLD_KEY],
|
||||
nwOutline.TIME: KEY_NAME[nwKeyWords.TIME_KEY],
|
||||
nwOutline.OBJECT: KEY_NAME[nwKeyWords.OBJECT_KEY],
|
||||
nwOutline.ENTITY: KEY_NAME[nwKeyWords.ENTITY_KEY],
|
||||
nwOutline.CUSTOM: KEY_NAME[nwKeyWords.CUSTOM_KEY],
|
||||
nwOutline.SYNOP: QT_TRANSLATE_NOOP("Constant", "Synopsis"),
|
||||
nwOutline.TITLE: QT_TRANSLATE_NOOP("Constant", "Title"),
|
||||
nwOutline.LEVEL: QT_TRANSLATE_NOOP("Constant", "Level"),
|
||||
nwOutline.LABEL: QT_TRANSLATE_NOOP("Constant", "Document"),
|
||||
nwOutline.LINE: QT_TRANSLATE_NOOP("Constant", "Line"),
|
||||
nwOutline.STATUS: QT_TRANSLATE_NOOP("Constant", "Status"),
|
||||
nwOutline.CCOUNT: QT_TRANSLATE_NOOP("Constant", "Chars"),
|
||||
nwOutline.WCOUNT: QT_TRANSLATE_NOOP("Constant", "Words"),
|
||||
nwOutline.PCOUNT: QT_TRANSLATE_NOOP("Constant", "Pars"),
|
||||
nwOutline.POV: QT_TRANSLATE_NOOP("Constant", "POV"),
|
||||
nwOutline.FOCUS: QT_TRANSLATE_NOOP("Constant", "Focus"),
|
||||
nwOutline.CHAR: KEY_NAME[nwKeyWords.CHAR_KEY],
|
||||
nwOutline.PLOT: KEY_NAME[nwKeyWords.PLOT_KEY],
|
||||
nwOutline.WORLD: KEY_NAME[nwKeyWords.WORLD_KEY],
|
||||
nwOutline.TIME: KEY_NAME[nwKeyWords.TIME_KEY],
|
||||
nwOutline.OBJECT: KEY_NAME[nwKeyWords.OBJECT_KEY],
|
||||
nwOutline.ENTITY: KEY_NAME[nwKeyWords.ENTITY_KEY],
|
||||
nwOutline.CUSTOM: KEY_NAME[nwKeyWords.CUSTOM_KEY],
|
||||
nwOutline.STORY: KEY_NAME[nwKeyWords.STORY_KEY],
|
||||
nwOutline.MENTION: KEY_NAME[nwKeyWords.MENTION_KEY],
|
||||
nwOutline.SYNOP: QT_TRANSLATE_NOOP("Constant", "Synopsis"),
|
||||
}
|
||||
STATS_NAME = {
|
||||
nwStats.CHARS_ALL: QT_TRANSLATE_NOOP("Constant", "Characters"),
|
||||
nwStats.CHARS_TEXT: QT_TRANSLATE_NOOP("Constant", "Characters in Text"),
|
||||
nwStats.CHARS_TITLE: QT_TRANSLATE_NOOP("Constant", "Characters in Headings"),
|
||||
nwStats.PARAGRAPHS: QT_TRANSLATE_NOOP("Constant", "Paragraphs"),
|
||||
nwStats.TITLES: QT_TRANSLATE_NOOP("Constant", "Headings"),
|
||||
nwStats.WCHARS_ALL: QT_TRANSLATE_NOOP("Constant", "Characters, No Spaces"),
|
||||
nwStats.WCHARS_TEXT: QT_TRANSLATE_NOOP("Constant", "Characters in Text, No Spaces"),
|
||||
nwStats.WCHARS_TITLE: QT_TRANSLATE_NOOP("Constant", "Characters in Headings, No Spaces"),
|
||||
nwStats.WORDS_ALL: QT_TRANSLATE_NOOP("Constant", "Words"),
|
||||
nwStats.WORDS_TEXT: QT_TRANSLATE_NOOP("Constant", "Words in Text"),
|
||||
nwStats.WORDS_TITLE: QT_TRANSLATE_NOOP("Constant", "Words in Headings"),
|
||||
}
|
||||
BUILD_FMT = {
|
||||
nwBuildFmt.ODT: QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)"),
|
||||
nwBuildFmt.FODT: QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)"),
|
||||
nwBuildFmt.HTML: QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)"),
|
||||
nwBuildFmt.DOCX: QT_TRANSLATE_NOOP("Constant", "Microsoft Word Document (.docx)"),
|
||||
nwBuildFmt.HTML: QT_TRANSLATE_NOOP("Constant", "HTML 5 (.html)"),
|
||||
nwBuildFmt.NWD: QT_TRANSLATE_NOOP("Constant", "novelWriter Markup (.txt)"),
|
||||
nwBuildFmt.STD_MD: QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)"),
|
||||
nwBuildFmt.EXT_MD: QT_TRANSLATE_NOOP("Constant", "Extended Markdown (.md)"),
|
||||
nwBuildFmt.J_HTML: QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter HTML (.json)"),
|
||||
nwBuildFmt.PDF: QT_TRANSLATE_NOOP("Constant", "Portable Document Format (.pdf)"),
|
||||
nwBuildFmt.J_HTML: QT_TRANSLATE_NOOP("Constant", "JSON + HTML 5 (.json)"),
|
||||
nwBuildFmt.J_NWD: QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter Markup (.json)"),
|
||||
}
|
||||
BUILD_EXT = {
|
||||
nwBuildFmt.ODT: ".odt",
|
||||
nwBuildFmt.FODT: ".fodt",
|
||||
nwBuildFmt.DOCX: ".docx",
|
||||
nwBuildFmt.HTML: ".html",
|
||||
nwBuildFmt.NWD: ".txt",
|
||||
nwBuildFmt.STD_MD: ".md",
|
||||
nwBuildFmt.EXT_MD: ".md",
|
||||
nwBuildFmt.PDF: ".pdf",
|
||||
nwBuildFmt.J_HTML: ".json",
|
||||
nwBuildFmt.J_NWD: ".json",
|
||||
}
|
||||
@@ -343,11 +446,11 @@ class nwHeadFmt:
|
||||
CHAR_POV, CHAR_FOCUS
|
||||
]
|
||||
|
||||
# ODT Document Page Header
|
||||
ODT_PROJECT = "{Project}"
|
||||
ODT_AUTHOR = "{Author}"
|
||||
ODT_PAGE = "{Page}"
|
||||
ODT_AUTO = "{Project} / {Author} / {Page}"
|
||||
# Document Page Header
|
||||
DOC_PROJECT = "{Project}"
|
||||
DOC_AUTHOR = "{Author}"
|
||||
DOC_PAGE = "{Page}"
|
||||
DOC_AUTO = "{Project} / {Author} / {Page}"
|
||||
|
||||
|
||||
class nwQuotes:
|
||||
@@ -449,6 +552,11 @@ class nwUnicode:
|
||||
U_LTRI = "\u25c0" # Left-pointing triangle
|
||||
U_LTRIS = "\u25c2" # Left-pointing triangle, small
|
||||
|
||||
# Special
|
||||
U_UNKN = "\ufffd" # Unknown character
|
||||
U_NAC1 = "\ufffe" # Not a character
|
||||
U_NAC2 = "\uffff" # Not a character
|
||||
|
||||
# HTML Equivalents
|
||||
# ================
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ from PyQt5.QtCore import QT_TRANSLATE_NOOP, QCoreApplication
|
||||
|
||||
from novelwriter import CONFIG
|
||||
from novelwriter.common import checkUuid, isHandle, jsonEncode
|
||||
from novelwriter.constants import nwFiles, nwHeadFmt
|
||||
from novelwriter.constants import nwFiles, nwHeadFmt, nwStyles
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.enum import nwBuildFmt
|
||||
from novelwriter.error import logException
|
||||
@@ -45,29 +45,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# The Settings Template
|
||||
# =====================
|
||||
# Each entry contains a tuple on the form:
|
||||
# (type, default, [min value, max value])
|
||||
# Each entry contains a tuple on the form: (type, default)
|
||||
|
||||
SETTINGS_TEMPLATE = {
|
||||
SETTINGS_TEMPLATE: dict[str, tuple[type, str | int | float | bool]] = {
|
||||
"filter.includeNovel": (bool, True),
|
||||
"filter.includeNotes": (bool, False),
|
||||
"filter.includeInactive": (bool, False),
|
||||
"headings.fmtTitle": (str, nwHeadFmt.TITLE),
|
||||
"headings.fmtPart": (str, nwHeadFmt.TITLE),
|
||||
"headings.fmtChapter": (str, nwHeadFmt.TITLE),
|
||||
"headings.fmtUnnumbered": (str, nwHeadFmt.TITLE),
|
||||
"headings.fmtScene": (str, "* * *"),
|
||||
"headings.fmtAltScene": (str, ""),
|
||||
"headings.fmtSection": (str, ""),
|
||||
"headings.hideTitle": (bool, False),
|
||||
"headings.hidePart": (bool, False),
|
||||
"headings.hideChapter": (bool, False),
|
||||
"headings.hideUnnumbered": (bool, False),
|
||||
"headings.hideScene": (bool, False),
|
||||
"headings.hideAltScene": (bool, False),
|
||||
"headings.hideSection": (bool, True),
|
||||
"headings.centerTitle": (bool, True),
|
||||
"headings.centerPart": (bool, True),
|
||||
"headings.centerChapter": (bool, False),
|
||||
"headings.centerScene": (bool, False),
|
||||
"headings.breakTitle": (bool, True),
|
||||
"headings.breakTitle": (bool, False),
|
||||
"headings.breakPart": (bool, True),
|
||||
"headings.breakChapter": (bool, True),
|
||||
"headings.breakScene": (bool, False),
|
||||
"text.includeSynopsis": (bool, False),
|
||||
@@ -77,7 +78,7 @@ SETTINGS_TEMPLATE = {
|
||||
"text.ignoredKeywords": (str, ""),
|
||||
"text.addNoteHeadings": (bool, True),
|
||||
"format.textFont": (str, CONFIG.textFont.toString()),
|
||||
"format.lineHeight": (float, 1.15, 0.75, 3.0),
|
||||
"format.lineHeight": (float, 1.15),
|
||||
"format.justifyText": (bool, False),
|
||||
"format.stripUnicode": (bool, False),
|
||||
"format.replaceTabs": (bool, False),
|
||||
@@ -86,6 +87,20 @@ SETTINGS_TEMPLATE = {
|
||||
"format.firstLineIndent": (bool, False),
|
||||
"format.firstIndentWidth": (float, 1.4),
|
||||
"format.indentFirstPar": (bool, False),
|
||||
"format.titleMarginT": (float, nwStyles.T_MARGIN["H0"][0]),
|
||||
"format.titleMarginB": (float, nwStyles.T_MARGIN["H0"][1]),
|
||||
"format.h1MarginT": (float, nwStyles.T_MARGIN["H1"][0]),
|
||||
"format.h1MarginB": (float, nwStyles.T_MARGIN["H1"][1]),
|
||||
"format.h2MarginT": (float, nwStyles.T_MARGIN["H2"][0]),
|
||||
"format.h2MarginB": (float, nwStyles.T_MARGIN["H2"][1]),
|
||||
"format.h3MarginT": (float, nwStyles.T_MARGIN["H3"][0]),
|
||||
"format.h3MarginB": (float, nwStyles.T_MARGIN["H3"][1]),
|
||||
"format.h4MarginT": (float, nwStyles.T_MARGIN["H4"][0]),
|
||||
"format.h4MarginB": (float, nwStyles.T_MARGIN["H4"][1]),
|
||||
"format.textMarginT": (float, nwStyles.T_MARGIN["TT"][0]),
|
||||
"format.textMarginB": (float, nwStyles.T_MARGIN["TT"][1]),
|
||||
"format.sepMarginT": (float, nwStyles.T_MARGIN["SP"][0]),
|
||||
"format.sepMarginB": (float, nwStyles.T_MARGIN["SP"][1]),
|
||||
"format.pageUnit": (str, "cm"),
|
||||
"format.pageSize": (str, "A4"),
|
||||
"format.pageWidth": (float, 21.0),
|
||||
@@ -94,9 +109,11 @@ SETTINGS_TEMPLATE = {
|
||||
"format.bottomMargin": (float, 2.0),
|
||||
"format.leftMargin": (float, 2.0),
|
||||
"format.rightMargin": (float, 2.0),
|
||||
"odt.addColours": (bool, True),
|
||||
"odt.pageHeader": (str, nwHeadFmt.ODT_AUTO),
|
||||
"odt.pageCountOffset": (int, 0),
|
||||
"doc.pageHeader": (str, nwHeadFmt.DOC_AUTO),
|
||||
"doc.pageCountOffset": (int, 0),
|
||||
"doc.colorHeadings": (bool, True),
|
||||
"doc.scaleHeadings": (bool, True),
|
||||
"doc.boldHeadings": (bool, True),
|
||||
"html.addStyles": (bool, True),
|
||||
"html.preserveTabs": (bool, False),
|
||||
}
|
||||
@@ -108,12 +125,16 @@ SETTINGS_LABELS = {
|
||||
"filter.includeInactive": QT_TRANSLATE_NOOP("Builds", "Inactive Documents"),
|
||||
|
||||
"headings": QT_TRANSLATE_NOOP("Builds", "Headings"),
|
||||
"headings.fmtTitle": QT_TRANSLATE_NOOP("Builds", "Partition Format"),
|
||||
"headings.fmtPart": QT_TRANSLATE_NOOP("Builds", "Partition Format"),
|
||||
"headings.fmtChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Format"),
|
||||
"headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Unnumbered Format"),
|
||||
"headings.fmtScene": QT_TRANSLATE_NOOP("Builds", "Scene Format"),
|
||||
"headings.fmtAltScene": QT_TRANSLATE_NOOP("Builds", "Alt. Scene Format"),
|
||||
"headings.fmtSection": QT_TRANSLATE_NOOP("Builds", "Section Format"),
|
||||
"headings.styleTitle": QT_TRANSLATE_NOOP("Builds", "Title Styling"),
|
||||
"headings.stylePart": QT_TRANSLATE_NOOP("Builds", "Partition Styling"),
|
||||
"headings.styleChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Styling"),
|
||||
"headings.styleScene": QT_TRANSLATE_NOOP("Builds", "Scene Styling"),
|
||||
|
||||
"text.grpContent": QT_TRANSLATE_NOOP("Builds", "Text Content"),
|
||||
"text.includeSynopsis": QT_TRANSLATE_NOOP("Builds", "Include Synopsis"),
|
||||
@@ -121,13 +142,11 @@ SETTINGS_LABELS = {
|
||||
"text.includeKeywords": QT_TRANSLATE_NOOP("Builds", "Include Keywords"),
|
||||
"text.includeBodyText": QT_TRANSLATE_NOOP("Builds", "Include Body Text"),
|
||||
"text.ignoredKeywords": QT_TRANSLATE_NOOP("Builds", "Ignore These Keywords"),
|
||||
"text.grpInsert": QT_TRANSLATE_NOOP("Builds", "Insert Content"),
|
||||
"text.addNoteHeadings": QT_TRANSLATE_NOOP("Builds", "Add Titles for Notes"),
|
||||
|
||||
"format.grpFormat": QT_TRANSLATE_NOOP("Builds", "Text Format"),
|
||||
"format.textFont": QT_TRANSLATE_NOOP("Builds", "Text Font"),
|
||||
"format.lineHeight": QT_TRANSLATE_NOOP("Builds", "Line Height"),
|
||||
"format.grpOptions": QT_TRANSLATE_NOOP("Builds", "Text Options"),
|
||||
"format.justifyText": QT_TRANSLATE_NOOP("Builds", "Justify Text Margins"),
|
||||
"format.stripUnicode": QT_TRANSLATE_NOOP("Builds", "Replace Unicode Characters"),
|
||||
"format.replaceTabs": QT_TRANSLATE_NOOP("Builds", "Replace Tabs with Spaces"),
|
||||
@@ -139,26 +158,31 @@ SETTINGS_LABELS = {
|
||||
"format.firstIndentWidth": QT_TRANSLATE_NOOP("Builds", "Indent Width"),
|
||||
"format.indentFirstPar": QT_TRANSLATE_NOOP("Builds", "Indent First Paragraph"),
|
||||
|
||||
"format.grpMargins": QT_TRANSLATE_NOOP("Builds", "Text Margins"),
|
||||
|
||||
"format.grpPage": QT_TRANSLATE_NOOP("Builds", "Page Layout"),
|
||||
"format.pageUnit": QT_TRANSLATE_NOOP("Builds", "Unit"),
|
||||
"format.pageSize": QT_TRANSLATE_NOOP("Builds", "Page Size"),
|
||||
"format.pageWidth": QT_TRANSLATE_NOOP("Builds", "Page Width"),
|
||||
"format.pageHeight": QT_TRANSLATE_NOOP("Builds", "Page Height"),
|
||||
"format.topMargin": QT_TRANSLATE_NOOP("Builds", "Top Margin"),
|
||||
"format.bottomMargin": QT_TRANSLATE_NOOP("Builds", "Bottom Margin"),
|
||||
"format.leftMargin": QT_TRANSLATE_NOOP("Builds", "Left Margin"),
|
||||
"format.rightMargin": QT_TRANSLATE_NOOP("Builds", "Right Margin"),
|
||||
"format.pageMargins": QT_TRANSLATE_NOOP("Builds", "Page Margins"),
|
||||
|
||||
"odt": QT_TRANSLATE_NOOP("Builds", "Open Document (.odt)"),
|
||||
"odt.addColours": QT_TRANSLATE_NOOP("Builds", "Add Highlight Colours"),
|
||||
"odt.pageHeader": QT_TRANSLATE_NOOP("Builds", "Page Header"),
|
||||
"odt.pageCountOffset": QT_TRANSLATE_NOOP("Builds", "Page Counter Offset"),
|
||||
"doc": QT_TRANSLATE_NOOP("Builds", "Document Style"),
|
||||
"doc.pageHeader": QT_TRANSLATE_NOOP("Builds", "Page Header"),
|
||||
"doc.pageCountOffset": QT_TRANSLATE_NOOP("Builds", "Page Counter Offset"),
|
||||
"doc.colorHeadings": QT_TRANSLATE_NOOP("Builds", "Add Colours to Headings"),
|
||||
"doc.scaleHeadings": QT_TRANSLATE_NOOP("Builds", "Increase Size of Headings"),
|
||||
"doc.boldHeadings": QT_TRANSLATE_NOOP("Builds", "Bold Headings"),
|
||||
|
||||
"html": QT_TRANSLATE_NOOP("Builds", "HTML (.html)"),
|
||||
"html": QT_TRANSLATE_NOOP("Builds", "HTML Options"),
|
||||
"html.addStyles": QT_TRANSLATE_NOOP("Builds", "Add CSS Styles"),
|
||||
"html.preserveTabs": QT_TRANSLATE_NOOP("Builds", "Preserve Tab Characters"),
|
||||
}
|
||||
|
||||
RENAMED = {
|
||||
"odt.addColours": "doc.addColours",
|
||||
"odt.pageHeader": "doc.pageHeader",
|
||||
"odt.pageCountOffset": "doc.pageCountOffset",
|
||||
}
|
||||
|
||||
|
||||
class FilterMode(Enum):
|
||||
"""The decision reason for an item in a filtered project."""
|
||||
@@ -348,18 +372,12 @@ class BuildSettings:
|
||||
self._changed = True
|
||||
return
|
||||
|
||||
def setValue(self, key: str, value: str | int | bool | float) -> bool:
|
||||
def setValue(self, key: str, value: str | int | float | bool) -> None:
|
||||
"""Set a specific value for a build setting."""
|
||||
if key not in SETTINGS_TEMPLATE:
|
||||
return False
|
||||
definition = SETTINGS_TEMPLATE[key]
|
||||
if not isinstance(value, definition[0]):
|
||||
return False
|
||||
if len(definition) == 4 and isinstance(value, (int, float)):
|
||||
value = min(max(value, definition[2]), definition[3])
|
||||
self._changed = value != self._settings[key]
|
||||
self._settings[key] = value
|
||||
return True
|
||||
if (d := SETTINGS_TEMPLATE.get(key)) and len(d) == 2 and isinstance(value, d[0]):
|
||||
self._changed = value != self._settings[key]
|
||||
self._settings[key] = value
|
||||
return
|
||||
|
||||
##
|
||||
# Methods
|
||||
@@ -478,12 +496,21 @@ class BuildSettings:
|
||||
self._settings = {k: v[1] for k, v in SETTINGS_TEMPLATE.items()}
|
||||
if isinstance(settings, dict):
|
||||
for key, value in settings.items():
|
||||
self.setValue(key, value)
|
||||
self.setValue(RENAMED.get(key, key), value)
|
||||
|
||||
self._changed = False
|
||||
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def duplicate(cls, source: BuildSettings) -> BuildSettings:
|
||||
"""Make a copy of another build."""
|
||||
cls = BuildSettings()
|
||||
cls.unpack(source.pack())
|
||||
cls._uuid = str(uuid.uuid4())
|
||||
cls._name = f"{source.name} 2"
|
||||
return cls
|
||||
|
||||
|
||||
class BuildCollection:
|
||||
"""Core: Build Collection Class
|
||||
|
||||
@@ -27,6 +27,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from collections.abc import Iterable
|
||||
@@ -34,11 +35,11 @@ from functools import partial
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile, is_zipfile
|
||||
|
||||
from PyQt5.QtCore import QCoreApplication, QRegularExpression
|
||||
from PyQt5.QtCore import QCoreApplication
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import isHandle, minmax, simplified
|
||||
from novelwriter.constants import nwConst, nwFiles, nwItemClass
|
||||
from novelwriter.constants import nwConst, nwFiles, nwItemClass, nwStats
|
||||
from novelwriter.core.item import NWItem
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.storage import NWStorageCreate
|
||||
@@ -55,10 +56,17 @@ class DocMerger:
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
self._project = project
|
||||
self._error = ""
|
||||
self._targetDoc = None
|
||||
self._targetText = []
|
||||
self._target = None
|
||||
self._text = []
|
||||
return
|
||||
|
||||
@property
|
||||
def targetHandle(self) -> str | None:
|
||||
"""Get the handle of the target document."""
|
||||
if self._target:
|
||||
return self._target.itemHandle
|
||||
return None
|
||||
|
||||
##
|
||||
# Methods
|
||||
##
|
||||
@@ -71,63 +79,56 @@ class DocMerger:
|
||||
"""Set the target document for the merging. Calling this
|
||||
function resets the class.
|
||||
"""
|
||||
self._targetDoc = tHandle
|
||||
self._targetText = []
|
||||
self._target = self._project.tree[tHandle]
|
||||
self._text = []
|
||||
return
|
||||
|
||||
def newTargetDoc(self, srcHandle: str, docLabel: str) -> str | None:
|
||||
def newTargetDoc(self, sHandle: str, label: str) -> None:
|
||||
"""Create a brand new target document based on a source handle
|
||||
and a new doc label. Calling this function resets the class.
|
||||
"""
|
||||
srcItem = self._project.tree[srcHandle]
|
||||
if srcItem is None or srcItem.itemParent is None:
|
||||
return None
|
||||
sItem = self._project.tree[sHandle]
|
||||
if sItem and sItem.itemParent:
|
||||
tHandle = self._project.newFile(label, sItem.itemParent)
|
||||
if nwItem := self._project.tree[tHandle]:
|
||||
nwItem.setLayout(sItem.itemLayout)
|
||||
nwItem.setStatus(sItem.itemStatus)
|
||||
nwItem.setImport(sItem.itemImport)
|
||||
nwItem.notifyToRefresh()
|
||||
self._target = nwItem
|
||||
self._text = []
|
||||
return
|
||||
|
||||
newHandle = self._project.newFile(docLabel, srcItem.itemParent)
|
||||
newItem = self._project.tree[newHandle]
|
||||
if isinstance(newItem, NWItem):
|
||||
newItem.setLayout(srcItem.itemLayout)
|
||||
newItem.setStatus(srcItem.itemStatus)
|
||||
newItem.setImport(srcItem.itemImport)
|
||||
|
||||
self._targetDoc = newHandle
|
||||
self._targetText = []
|
||||
|
||||
return newHandle
|
||||
|
||||
def appendText(self, srcHandle: str, addComment: bool, cmtPrefix: str) -> bool:
|
||||
def appendText(self, sHandle: str, addComment: bool, cmtPrefix: str) -> None:
|
||||
"""Append text from an existing document to the text buffer."""
|
||||
srcItem = self._project.tree[srcHandle]
|
||||
if srcItem is None:
|
||||
return False
|
||||
|
||||
docText = self._project.storage.getDocumentText(srcHandle).rstrip("\n")
|
||||
if addComment:
|
||||
docInfo = srcItem.describeMe()
|
||||
docSt, _ = srcItem.getImportStatus()
|
||||
cmtLine = f"% {cmtPrefix} {docInfo}: {srcItem.itemName} [{docSt}]\n\n"
|
||||
docText = cmtLine + docText
|
||||
|
||||
self._targetText.append(docText)
|
||||
|
||||
return True
|
||||
if item := self._project.tree[sHandle]:
|
||||
text = self._project.storage.getDocumentText(sHandle).rstrip("\n")
|
||||
if addComment:
|
||||
info = item.describeMe()
|
||||
status, _ = item.getImportStatus()
|
||||
text = f"% {cmtPrefix} {info}: {item.itemName} [{status}]\n\n{text}"
|
||||
self._text.append(text)
|
||||
return
|
||||
|
||||
def writeTargetDoc(self) -> bool:
|
||||
"""Write the accumulated text into the designated target
|
||||
document, appending any existing text.
|
||||
"""
|
||||
if self._targetDoc is None:
|
||||
return False
|
||||
if self._target:
|
||||
outDoc = self._project.storage.getDocument(self._target.itemHandle)
|
||||
if text := (outDoc.readDocument() or "").rstrip("\n"):
|
||||
self._text.insert(0, text)
|
||||
|
||||
outDoc = self._project.storage.getDocument(self._targetDoc)
|
||||
if text := (outDoc.readDocument() or "").rstrip("\n"):
|
||||
self._targetText.insert(0, text)
|
||||
status = outDoc.writeDocument("\n\n".join(self._text) + "\n\n")
|
||||
if not status:
|
||||
self._error = outDoc.getError()
|
||||
|
||||
status = outDoc.writeDocument("\n\n".join(self._targetText) + "\n\n")
|
||||
if not status:
|
||||
self._error = outDoc.getError()
|
||||
self._project.index.reIndexHandle(self._target.itemHandle)
|
||||
self._target.notifyToRefresh()
|
||||
|
||||
return status
|
||||
return status
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class DocSplitter:
|
||||
@@ -171,23 +172,19 @@ class DocSplitter:
|
||||
self._inFolder = False
|
||||
return
|
||||
|
||||
def newParentFolder(self, pHandle: str, folderLabel: str) -> str | None:
|
||||
def newParentFolder(self, pHandle: str, folderLabel: str) -> None:
|
||||
"""Create a new folder that will be the top level parent item
|
||||
for the new documents.
|
||||
"""
|
||||
if self._srcItem is None:
|
||||
return None
|
||||
|
||||
newHandle = self._project.newFolder(folderLabel, pHandle)
|
||||
newItem = self._project.tree[newHandle]
|
||||
if isinstance(newItem, NWItem):
|
||||
newItem.setStatus(self._srcItem.itemStatus)
|
||||
newItem.setImport(self._srcItem.itemImport)
|
||||
|
||||
self._parHandle = newHandle
|
||||
self._inFolder = True
|
||||
|
||||
return newHandle
|
||||
if self._srcItem:
|
||||
nHandle = self._project.newFolder(folderLabel, pHandle)
|
||||
if nwItem := self._project.tree[nHandle]:
|
||||
nwItem.setStatus(self._srcItem.itemStatus)
|
||||
nwItem.setImport(self._srcItem.itemImport)
|
||||
nwItem.notifyToRefresh()
|
||||
self._parHandle = nHandle
|
||||
self._inFolder = True
|
||||
return
|
||||
|
||||
def splitDocument(self, splitData: list, splitText: list[str]) -> None:
|
||||
"""Loop through the split data record and perform the split job
|
||||
@@ -201,58 +198,50 @@ class DocSplitter:
|
||||
self._rawData.insert(0, (chunk, hLevel, hLabel))
|
||||
return
|
||||
|
||||
def writeDocuments(self, docHierarchy: bool) -> Iterable[tuple[bool, str | None, str | None]]:
|
||||
def writeDocuments(self, docHierarchy: bool) -> Iterable[bool]:
|
||||
"""An iterator that will write each document in the buffer, and
|
||||
return its new handle, parent handle, and sibling handle.
|
||||
"""
|
||||
if self._srcHandle is None or self._srcItem is None or self._parHandle is None:
|
||||
return
|
||||
if self._srcHandle and self._srcItem and self._parHandle:
|
||||
pHandle = self._parHandle
|
||||
hHandle = [self._parHandle, None, None, None, None]
|
||||
pLevel = 0
|
||||
for docText, hLevel, docLabel in self._rawData:
|
||||
|
||||
pHandle = self._parHandle
|
||||
nHandle = self._parHandle if self._inFolder else self._srcHandle
|
||||
hHandle = [self._parHandle, None, None, None, None]
|
||||
hLevel = minmax(hLevel, 1, 4)
|
||||
if pLevel == 0:
|
||||
pLevel = hLevel
|
||||
|
||||
pLevel = 0
|
||||
for docText, hLevel, docLabel in self._rawData:
|
||||
if docHierarchy:
|
||||
if hLevel == 1:
|
||||
pHandle = self._parHandle
|
||||
elif hLevel == 2:
|
||||
pHandle = hHandle[1] or hHandle[0]
|
||||
elif hLevel == 3:
|
||||
pHandle = hHandle[2] or hHandle[1] or hHandle[0]
|
||||
elif hLevel == 4:
|
||||
pHandle = hHandle[3] or hHandle[2] or hHandle[1] or hHandle[0]
|
||||
|
||||
hLevel = minmax(hLevel, 1, 4)
|
||||
if pLevel == 0:
|
||||
pLevel = hLevel
|
||||
if (
|
||||
(dHandle := self._project.newFile(docLabel, pHandle))
|
||||
and (nwItem := self._project.tree[dHandle])
|
||||
):
|
||||
hHandle[hLevel] = dHandle
|
||||
nwItem.setStatus(self._srcItem.itemStatus)
|
||||
nwItem.setImport(self._srcItem.itemImport)
|
||||
|
||||
if docHierarchy:
|
||||
if hLevel == 1:
|
||||
pHandle = self._parHandle
|
||||
elif hLevel == 2:
|
||||
pHandle = hHandle[1] or hHandle[0]
|
||||
elif hLevel == 3:
|
||||
pHandle = hHandle[2] or hHandle[1] or hHandle[0]
|
||||
elif hLevel == 4:
|
||||
pHandle = hHandle[3] or hHandle[2] or hHandle[1] or hHandle[0]
|
||||
outDoc = self._project.storage.getDocument(dHandle)
|
||||
status = outDoc.writeDocument("\n".join(docText))
|
||||
if not status:
|
||||
self._error = outDoc.getError()
|
||||
|
||||
if hLevel < pLevel:
|
||||
nHandle = hHandle[hLevel] or hHandle[0]
|
||||
elif hLevel > pLevel:
|
||||
nHandle = pHandle
|
||||
self._project.index.reIndexHandle(dHandle)
|
||||
nwItem.notifyToRefresh()
|
||||
|
||||
dHandle = self._project.newFile(docLabel, pHandle)
|
||||
hHandle[hLevel] = dHandle
|
||||
|
||||
newItem = self._project.tree[dHandle]
|
||||
if isinstance(newItem, NWItem):
|
||||
newItem.setStatus(self._srcItem.itemStatus)
|
||||
newItem.setImport(self._srcItem.itemImport)
|
||||
|
||||
outDoc = self._project.storage.getDocument(dHandle)
|
||||
status = outDoc.writeDocument("\n".join(docText))
|
||||
if not status:
|
||||
self._error = outDoc.getError()
|
||||
|
||||
yield status, dHandle, nHandle
|
||||
|
||||
hHandle[hLevel] = dHandle
|
||||
nHandle = dHandle
|
||||
pLevel = hLevel
|
||||
yield status
|
||||
|
||||
hHandle[hLevel] = dHandle
|
||||
pLevel = hLevel
|
||||
return
|
||||
|
||||
|
||||
@@ -269,36 +258,34 @@ class DocDuplicator:
|
||||
# Methods
|
||||
##
|
||||
|
||||
def duplicate(self, items: list[str]) -> Iterable[tuple[str, str | None]]:
|
||||
def duplicate(self, items: list[str]) -> list[str]:
|
||||
"""Run through a list of items, duplicate them, and copy the
|
||||
text content if they are documents.
|
||||
"""
|
||||
result = []
|
||||
after = True
|
||||
if items:
|
||||
nHandle = items[0]
|
||||
hMap: dict[str, str | None] = {t: None for t in items}
|
||||
for tHandle in items:
|
||||
newItem = self._project.tree.duplicate(tHandle)
|
||||
if newItem is None:
|
||||
return
|
||||
hMap[tHandle] = newItem.itemHandle
|
||||
if newItem.itemParent in hMap:
|
||||
newItem.setParent(hMap[newItem.itemParent])
|
||||
self._project.tree.updateItemData(newItem.itemHandle)
|
||||
if newItem.isFileType():
|
||||
newDoc = self._project.storage.getDocument(newItem.itemHandle)
|
||||
if newDoc.fileExists():
|
||||
return
|
||||
newDoc.writeDocument(self._project.storage.getDocumentText(tHandle))
|
||||
yield newItem.itemHandle, nHandle
|
||||
nHandle = None
|
||||
return
|
||||
if oldItem := self._project.tree[tHandle]:
|
||||
pHandle = hMap.get(oldItem.itemParent or "") or oldItem.itemParent
|
||||
if newItem := self._project.tree.duplicate(tHandle, pHandle, after):
|
||||
hMap[tHandle] = newItem.itemHandle
|
||||
if newItem.isFileType():
|
||||
self._project.copyFileContent(newItem.itemHandle, tHandle)
|
||||
newItem.notifyToRefresh()
|
||||
result.append(newItem.itemHandle)
|
||||
after = False
|
||||
else:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
class DocSearch:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._regEx = QRegularExpression()
|
||||
self.setCaseSensitive(False)
|
||||
self._regEx = re.compile("")
|
||||
self._opts = re.UNICODE | re.IGNORECASE
|
||||
self._words = False
|
||||
self._escape = True
|
||||
return
|
||||
@@ -309,10 +296,9 @@ class DocSearch:
|
||||
|
||||
def setCaseSensitive(self, state: bool) -> None:
|
||||
"""Set the case sensitive search flag."""
|
||||
opts = QRegularExpression.PatternOption.UseUnicodePropertiesOption
|
||||
self._opts = re.UNICODE
|
||||
if not state:
|
||||
opts |= QRegularExpression.PatternOption.CaseInsensitiveOption
|
||||
self._regEx.setPatternOptions(opts)
|
||||
self._opts |= re.IGNORECASE
|
||||
return
|
||||
|
||||
def setWholeWords(self, state: bool) -> None:
|
||||
@@ -329,8 +315,8 @@ class DocSearch:
|
||||
self, project: NWProject, search: str
|
||||
) -> Iterable[tuple[NWItem, list[tuple[int, int, str]], bool]]:
|
||||
"""Iteratively search through documents in a project."""
|
||||
self._regEx.setPattern(self._buildPattern(search))
|
||||
logger.debug("Searching with pattern '%s'", self._regEx.pattern())
|
||||
self._regEx = re.compile(self._buildPattern(search), self._opts)
|
||||
logger.debug("Searching with pattern '%s'", self._regEx.pattern)
|
||||
storage = project.storage
|
||||
for item in project.tree:
|
||||
if item.isFileType():
|
||||
@@ -340,14 +326,12 @@ class DocSearch:
|
||||
|
||||
def searchText(self, text: str) -> tuple[list[tuple[int, int, str]], bool]:
|
||||
"""Search a piece of text for RegEx matches."""
|
||||
rxItt = self._regEx.globalMatch(text)
|
||||
count = 0
|
||||
capped = False
|
||||
results = []
|
||||
while rxItt.hasNext():
|
||||
rxMatch = rxItt.next()
|
||||
pos = rxMatch.capturedStart()
|
||||
num = rxMatch.capturedLength()
|
||||
for res in self._regEx.finditer(text):
|
||||
pos = res.start(0)
|
||||
num = len(res.group(0))
|
||||
lim = text[:pos].rfind("\n") + 1
|
||||
cut = text[lim:pos].rfind(" ") + lim + 1
|
||||
context = text[cut:cut+100].partition("\n")[0]
|
||||
@@ -366,7 +350,7 @@ class DocSearch:
|
||||
def _buildPattern(self, search: str) -> str:
|
||||
"""Build the search pattern string."""
|
||||
if self._escape:
|
||||
search = QRegularExpression.escape(search)
|
||||
search = re.escape(search)
|
||||
if self._words:
|
||||
search = f"(?:^|\\b){search}(?:$|\\b)"
|
||||
return search
|
||||
@@ -430,7 +414,6 @@ class ProjectBuilder:
|
||||
|
||||
lblNewProject = self.tr("New Project")
|
||||
lblTitlePage = self.tr("Title Page")
|
||||
lblByAuthors = self.tr("By")
|
||||
|
||||
# Settings
|
||||
project.data.setUuid(None)
|
||||
@@ -443,14 +426,29 @@ class ProjectBuilder:
|
||||
# Add Root Folders
|
||||
hNovelRoot = project.newRoot(nwItemClass.NOVEL)
|
||||
hTitlePage = project.newFile(lblTitlePage, hNovelRoot)
|
||||
novelTitle = project.data.name
|
||||
|
||||
titlePage = f"#! {novelTitle}\n\n"
|
||||
if project.data.author:
|
||||
titlePage += f">> {lblByAuthors} {project.data.author} <<\n\n"
|
||||
|
||||
# Generate Title Page
|
||||
aDoc = project.storage.getDocument(hTitlePage)
|
||||
aDoc.writeDocument(titlePage)
|
||||
aDoc.writeDocument((
|
||||
"{author}[br]\n"
|
||||
"{address} 1[br]\n"
|
||||
"{address} 2 <<\n"
|
||||
"\n"
|
||||
"[vspace:5]\n"
|
||||
"\n"
|
||||
"#! {title}\n"
|
||||
"\n"
|
||||
">> **{by} {author}** <<\n"
|
||||
"\n"
|
||||
">> {count}: [field:{field}] <<\n"
|
||||
).format(
|
||||
author=project.data.author or "None",
|
||||
address=self.tr("Address"),
|
||||
title=project.data.name or "None",
|
||||
by=self.tr("By"),
|
||||
count=self.tr("Word Count"),
|
||||
field=nwStats.WORDS_TEXT,
|
||||
))
|
||||
|
||||
# Create a project structure based on selected root folders
|
||||
# and a number of chapters and scenes selected in the
|
||||
@@ -511,7 +509,7 @@ class ProjectBuilder:
|
||||
|
||||
# Also add the archive and trash folders
|
||||
project.newRoot(nwItemClass.ARCHIVE)
|
||||
project.trashFolder()
|
||||
project.tree.trash # Triggers the creation of Trash
|
||||
|
||||
project.saveProject()
|
||||
project.closeProject()
|
||||
|
||||
@@ -35,13 +35,15 @@ from novelwriter.constants import nwLabels
|
||||
from novelwriter.core.buildsettings import BuildSettings
|
||||
from novelwriter.core.item import NWItem
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.tohtml import ToHtml
|
||||
from novelwriter.core.tokenizer import Tokenizer
|
||||
from novelwriter.core.tomarkdown import ToMarkdown
|
||||
from novelwriter.core.toodt import ToOdt
|
||||
from novelwriter.core.toqdoc import TextDocumentTheme, ToQTextDocument
|
||||
from novelwriter.enum import nwBuildFmt
|
||||
from novelwriter.error import formatException, logException
|
||||
from novelwriter.formats.todocx import ToDocX
|
||||
from novelwriter.formats.tohtml import ToHtml
|
||||
from novelwriter.formats.tokenizer import Tokenizer
|
||||
from novelwriter.formats.tomarkdown import ToMarkdown
|
||||
from novelwriter.formats.toodt import ToOdt
|
||||
from novelwriter.formats.toqdoc import ToQTextDocument
|
||||
from novelwriter.formats.toraw import ToRaw
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,20 +87,6 @@ class NWBuildDocument:
|
||||
"""
|
||||
return self._cache
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setCountEnabled(self, state: bool) -> None:
|
||||
"""Turn on/off stats for builds."""
|
||||
self._count = state
|
||||
return
|
||||
|
||||
def setBuildOutline(self, state: bool) -> None:
|
||||
"""Turn on/off outline for builds."""
|
||||
self._outline = state
|
||||
return
|
||||
|
||||
##
|
||||
# Special Methods
|
||||
##
|
||||
@@ -125,182 +113,117 @@ class NWBuildDocument:
|
||||
self._queue.append(item.itemHandle)
|
||||
return
|
||||
|
||||
def iterBuildPreview(self, theme: TextDocumentTheme) -> Iterable[tuple[int, bool]]:
|
||||
def iterBuildPreview(self, newPage: bool) -> Iterable[tuple[int, bool]]:
|
||||
"""Build a preview QTextDocument."""
|
||||
makeObj = ToQTextDocument(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
|
||||
self._outline = True
|
||||
self._count = True
|
||||
|
||||
font = QFont()
|
||||
font.fromString(self._build.getStr("format.textFont"))
|
||||
|
||||
makeObj.initDocument(font, theme)
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle)
|
||||
else:
|
||||
yield i, False
|
||||
|
||||
makeObj.appendFootnotes()
|
||||
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
|
||||
return
|
||||
|
||||
def iterBuild(self, path: Path, bFormat: nwBuildFmt) -> Iterable[tuple[int, bool]]:
|
||||
"""Wrapper for builders based on format."""
|
||||
if bFormat in (nwBuildFmt.ODT, nwBuildFmt.FODT):
|
||||
yield from self.iterBuildOpenDocument(path, bFormat == nwBuildFmt.FODT)
|
||||
elif bFormat in (nwBuildFmt.HTML, nwBuildFmt.J_HTML):
|
||||
yield from self.iterBuildHTML(path, asJson=bFormat == nwBuildFmt.J_HTML)
|
||||
elif bFormat in (nwBuildFmt.STD_MD, nwBuildFmt.EXT_MD):
|
||||
yield from self.iterBuildMarkdown(path, bFormat == nwBuildFmt.EXT_MD)
|
||||
elif bFormat in (nwBuildFmt.NWD, nwBuildFmt.J_NWD):
|
||||
yield from self.iterBuildNWD(path, asJson=bFormat == nwBuildFmt.J_NWD)
|
||||
return
|
||||
|
||||
def iterBuildOpenDocument(self, path: Path, isFlat: bool) -> Iterable[tuple[int, bool]]:
|
||||
"""Build an Open Document file."""
|
||||
makeObj = ToOdt(self._project, isFlat=isFlat)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
makeObj.initDocument()
|
||||
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle)
|
||||
else:
|
||||
yield i, False
|
||||
|
||||
makeObj.setShowNewPage(newPage)
|
||||
self._outline = True
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
return
|
||||
|
||||
def iterBuildDocument(self, path: Path, bFormat: nwBuildFmt) -> Iterable[tuple[int, bool]]:
|
||||
"""Wrapper for builders based on format."""
|
||||
self._error = None
|
||||
self._cache = None
|
||||
|
||||
if bFormat in (nwBuildFmt.J_HTML, nwBuildFmt.J_NWD):
|
||||
# Ensure that JSON output has the correct extension
|
||||
path = path.with_suffix(".json")
|
||||
|
||||
if bFormat in (nwBuildFmt.ODT, nwBuildFmt.FODT):
|
||||
makeObj = ToOdt(self._project, bFormat == nwBuildFmt.FODT)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
makeObj.initDocument()
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
|
||||
elif bFormat in (nwBuildFmt.HTML, nwBuildFmt.J_HTML):
|
||||
makeObj = ToHtml(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
makeObj.initDocument()
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
if not self._build.getBool("html.preserveTabs"):
|
||||
makeObj.replaceTabs()
|
||||
|
||||
elif bFormat in (nwBuildFmt.STD_MD, nwBuildFmt.EXT_MD):
|
||||
makeObj = ToMarkdown(self._project, bFormat == nwBuildFmt.EXT_MD)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
if self._build.getBool("format.replaceTabs"):
|
||||
makeObj.replaceTabs(nSpaces=4, spaceChar=" ")
|
||||
|
||||
elif bFormat in (nwBuildFmt.NWD, nwBuildFmt.J_NWD):
|
||||
makeObj = ToRaw(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
if self._build.getBool("format.replaceTabs"):
|
||||
makeObj.replaceTabs(nSpaces=4, spaceChar=" ")
|
||||
|
||||
elif bFormat == nwBuildFmt.DOCX:
|
||||
makeObj = ToDocX(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
makeObj.initDocument()
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
|
||||
elif bFormat == nwBuildFmt.PDF:
|
||||
makeObj = ToQTextDocument(self._project)
|
||||
makeObj.disableAnchors()
|
||||
filtered = self._setupBuild(makeObj)
|
||||
makeObj.initDocument(pdf=True)
|
||||
yield from self._iterBuild(makeObj, filtered)
|
||||
makeObj.closeDocument()
|
||||
|
||||
else:
|
||||
logger.error("Unsupported document format")
|
||||
return
|
||||
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
|
||||
try:
|
||||
if isFlat:
|
||||
makeObj.saveFlatXML(path)
|
||||
else:
|
||||
makeObj.saveOpenDocText(path)
|
||||
makeObj.saveDocument(path)
|
||||
except Exception as exc:
|
||||
logException()
|
||||
self._error = formatException(exc)
|
||||
|
||||
return
|
||||
|
||||
def iterBuildHTML(self, path: Path | None, asJson: bool = False) -> Iterable[tuple[int, bool]]:
|
||||
"""Build an HTML file. If path is None, no file is saved. This
|
||||
is used for generating build previews.
|
||||
"""
|
||||
makeObj = ToHtml(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle)
|
||||
else:
|
||||
yield i, False
|
||||
|
||||
makeObj.appendFootnotes()
|
||||
|
||||
if not self._build.getBool("html.preserveTabs"):
|
||||
makeObj.replaceTabs()
|
||||
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
|
||||
if isinstance(path, Path):
|
||||
try:
|
||||
if asJson:
|
||||
makeObj.saveHtmlJson(path)
|
||||
else:
|
||||
makeObj.saveHtml5(path)
|
||||
except Exception as exc:
|
||||
logException()
|
||||
self._error = formatException(exc)
|
||||
|
||||
return
|
||||
|
||||
def iterBuildMarkdown(self, path: Path, extendedMd: bool) -> Iterable[tuple[int, bool]]:
|
||||
"""Build a Markdown file."""
|
||||
makeObj = ToMarkdown(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
|
||||
makeObj.setExtendedMarkdown(extendedMd)
|
||||
if self._build.getBool("format.replaceTabs"):
|
||||
makeObj.replaceTabs(nSpaces=4, spaceChar=" ")
|
||||
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle)
|
||||
else:
|
||||
yield i, False
|
||||
|
||||
makeObj.appendFootnotes()
|
||||
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
|
||||
try:
|
||||
makeObj.saveMarkdown(path)
|
||||
except Exception as exc:
|
||||
logException()
|
||||
self._error = formatException(exc)
|
||||
|
||||
return
|
||||
|
||||
def iterBuildNWD(self, path: Path | None, asJson: bool = False) -> Iterable[tuple[int, bool]]:
|
||||
"""Build a novelWriter Markdown file."""
|
||||
makeObj = ToMarkdown(self._project)
|
||||
filtered = self._setupBuild(makeObj)
|
||||
|
||||
makeObj.setKeepMarkdown(True)
|
||||
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle, convert=False)
|
||||
else:
|
||||
yield i, False
|
||||
|
||||
if self._build.getBool("format.replaceTabs"):
|
||||
makeObj.replaceTabs(nSpaces=4, spaceChar=" ")
|
||||
|
||||
self._error = None
|
||||
self._cache = makeObj
|
||||
|
||||
if isinstance(path, Path):
|
||||
try:
|
||||
if asJson:
|
||||
makeObj.saveRawMarkdownJSON(path)
|
||||
else:
|
||||
makeObj.saveRawMarkdown(path)
|
||||
except Exception as exc:
|
||||
logException()
|
||||
self._error = formatException(exc)
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _iterBuild(self, makeObj: Tokenizer, filtered: dict) -> Iterable[tuple[int, bool]]:
|
||||
"""Iterate over buildable documents."""
|
||||
self._count = True
|
||||
for i, tHandle in enumerate(self._queue):
|
||||
self._error = None
|
||||
if filtered.get(tHandle, (False, 0))[0]:
|
||||
yield i, self._doBuild(makeObj, tHandle)
|
||||
else:
|
||||
yield i, False
|
||||
return
|
||||
|
||||
def _setupBuild(self, bldObj: Tokenizer) -> dict:
|
||||
"""Configure the build object."""
|
||||
# Get Settings
|
||||
textFont = QFont(CONFIG.textFont)
|
||||
textFont.fromString(self._build.getStr("format.textFont"))
|
||||
bldObj.setFont(textFont)
|
||||
|
||||
bldObj.setTitleFormat(
|
||||
self._build.getStr("headings.fmtTitle"),
|
||||
self._build.getBool("headings.hideTitle")
|
||||
bldObj.setTextFont(textFont)
|
||||
bldObj.setLanguage(self._project.data.language)
|
||||
|
||||
bldObj.setPartitionFormat(
|
||||
self._build.getStr("headings.fmtPart"),
|
||||
self._build.getBool("headings.hidePart")
|
||||
)
|
||||
bldObj.setChapterFormat(
|
||||
self._build.getStr("headings.fmtChapter"),
|
||||
@@ -326,6 +249,10 @@ class NWBuildDocument:
|
||||
self._build.getBool("headings.centerTitle"),
|
||||
self._build.getBool("headings.breakTitle")
|
||||
)
|
||||
bldObj.setPartitionStyle(
|
||||
self._build.getBool("headings.centerPart"),
|
||||
self._build.getBool("headings.breakPart")
|
||||
)
|
||||
bldObj.setChapterStyle(
|
||||
self._build.getBool("headings.centerChapter"),
|
||||
self._build.getBool("headings.breakChapter")
|
||||
@@ -338,12 +265,46 @@ class NWBuildDocument:
|
||||
bldObj.setJustify(self._build.getBool("format.justifyText"))
|
||||
bldObj.setLineHeight(self._build.getFloat("format.lineHeight"))
|
||||
bldObj.setKeepLineBreaks(self._build.getBool("format.keepBreaks"))
|
||||
bldObj.setDialogueHighlight(self._build.getBool("format.showDialogue"))
|
||||
bldObj.setDialogHighlight(self._build.getBool("format.showDialogue"))
|
||||
bldObj.setFirstLineIndent(
|
||||
self._build.getBool("format.firstLineIndent"),
|
||||
self._build.getFloat("format.firstIndentWidth"),
|
||||
self._build.getBool("format.indentFirstPar"),
|
||||
)
|
||||
bldObj.setHeadingStyles(
|
||||
self._build.getBool("doc.colorHeadings"),
|
||||
self._build.getBool("doc.scaleHeadings"),
|
||||
self._build.getBool("doc.boldHeadings"),
|
||||
)
|
||||
|
||||
bldObj.setTitleMargins(
|
||||
self._build.getFloat("format.titleMarginT"),
|
||||
self._build.getFloat("format.titleMarginB"),
|
||||
)
|
||||
bldObj.setHead1Margins(
|
||||
self._build.getFloat("format.h1MarginT"),
|
||||
self._build.getFloat("format.h1MarginB"),
|
||||
)
|
||||
bldObj.setHead2Margins(
|
||||
self._build.getFloat("format.h2MarginT"),
|
||||
self._build.getFloat("format.h2MarginB"),
|
||||
)
|
||||
bldObj.setHead3Margins(
|
||||
self._build.getFloat("format.h3MarginT"),
|
||||
self._build.getFloat("format.h3MarginB"),
|
||||
)
|
||||
bldObj.setHead4Margins(
|
||||
self._build.getFloat("format.h4MarginT"),
|
||||
self._build.getFloat("format.h4MarginB"),
|
||||
)
|
||||
bldObj.setTextMargins(
|
||||
self._build.getFloat("format.textMarginT"),
|
||||
self._build.getFloat("format.textMarginB"),
|
||||
)
|
||||
bldObj.setSeparatorMargins(
|
||||
self._build.getFloat("format.sepMarginT"),
|
||||
self._build.getFloat("format.sepMarginB"),
|
||||
)
|
||||
|
||||
bldObj.setBodyText(self._build.getBool("text.includeBodyText"))
|
||||
bldObj.setSynopsis(self._build.getBool("text.includeSynopsis"))
|
||||
@@ -355,13 +316,13 @@ class NWBuildDocument:
|
||||
bldObj.setStyles(self._build.getBool("html.addStyles"))
|
||||
bldObj.setReplaceUnicode(self._build.getBool("format.stripUnicode"))
|
||||
|
||||
if isinstance(bldObj, ToOdt):
|
||||
bldObj.setColourHeaders(self._build.getBool("odt.addColours"))
|
||||
bldObj.setLanguage(self._project.data.language)
|
||||
if isinstance(bldObj, (ToOdt, ToDocX)):
|
||||
bldObj.setHeaderFormat(
|
||||
self._build.getStr("odt.pageHeader"), self._build.getInt("odt.pageCountOffset")
|
||||
self._build.getStr("doc.pageHeader"),
|
||||
self._build.getInt("doc.pageCountOffset"),
|
||||
)
|
||||
|
||||
if isinstance(bldObj, (ToOdt, ToDocX, ToQTextDocument)):
|
||||
scale = nwLabels.UNIT_SCALE.get(self._build.getStr("format.pageUnit"), 1.0)
|
||||
pW, pH = nwLabels.PAPER_SIZE.get(self._build.getStr("format.pageSize"), (-1.0, -1.0))
|
||||
bldObj.setPageLayout(
|
||||
@@ -384,14 +345,17 @@ class NWBuildDocument:
|
||||
tItem = self._project.tree[tHandle]
|
||||
if isinstance(tItem, NWItem):
|
||||
try:
|
||||
if tItem.isRootType() and not tItem.isNovelLike():
|
||||
bldObj.addRootHeading(tHandle)
|
||||
if convert:
|
||||
bldObj.doConvert()
|
||||
if self._count:
|
||||
bldObj.countStats()
|
||||
if self._outline:
|
||||
bldObj.buildOutline()
|
||||
if tItem.isRootType():
|
||||
if tItem.isNovelLike():
|
||||
bldObj.setBreakNext()
|
||||
else:
|
||||
bldObj.addRootHeading(tHandle)
|
||||
if convert:
|
||||
bldObj.doConvert()
|
||||
if self._count:
|
||||
bldObj.countStats()
|
||||
if self._outline:
|
||||
bldObj.buildOutline()
|
||||
elif tItem.isFileType():
|
||||
bldObj.setText(tHandle)
|
||||
bldObj.doPreProcessing()
|
||||
@@ -402,8 +366,6 @@ class NWBuildDocument:
|
||||
bldObj.buildOutline()
|
||||
if convert:
|
||||
bldObj.doConvert()
|
||||
else:
|
||||
logger.info(f"Build: Skipping '{tHandle}'")
|
||||
|
||||
except Exception:
|
||||
self._error = f"Build: Failed to build '{tHandle}'"
|
||||
|
||||
@@ -40,7 +40,7 @@ from novelwriter import SHARED
|
||||
from novelwriter.common import (
|
||||
checkInt, isHandle, isItemClass, isListInstance, isTitleTag, jsonEncode
|
||||
)
|
||||
from novelwriter.constants import nwFiles, nwHeaders, nwKeyWords
|
||||
from novelwriter.constants import nwFiles, nwKeyWords, nwStyles
|
||||
from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwItemType
|
||||
from novelwriter.error import logException
|
||||
from novelwriter.text.counting import standardCounter
|
||||
@@ -116,24 +116,24 @@ class NWIndex:
|
||||
# Public Methods
|
||||
##
|
||||
|
||||
def clearIndex(self) -> None:
|
||||
def clear(self) -> None:
|
||||
"""Clear the index dictionaries and time stamps."""
|
||||
self._tagsIndex.clear()
|
||||
self._itemIndex.clear()
|
||||
self._indexChange = 0.0
|
||||
self._rootChange = {}
|
||||
SHARED.indexSignalProxy({"event": "clearIndex"})
|
||||
SHARED.emitIndexCleared(self._project)
|
||||
return
|
||||
|
||||
def rebuildIndex(self) -> None:
|
||||
def rebuild(self) -> None:
|
||||
"""Rebuild the entire index from scratch."""
|
||||
self.clearIndex()
|
||||
self.clear()
|
||||
for nwItem in self._project.tree:
|
||||
if nwItem.isFileType():
|
||||
text = self._project.storage.getDocumentText(nwItem.itemHandle)
|
||||
self.scanText(nwItem.itemHandle, text, blockSignal=True)
|
||||
self._indexBroken = False
|
||||
SHARED.indexSignalProxy({"event": "buildIndex"})
|
||||
SHARED.emitIndexAvailable(self._project)
|
||||
return
|
||||
|
||||
def deleteHandle(self, tHandle: str) -> None:
|
||||
@@ -143,10 +143,7 @@ class NWIndex:
|
||||
for tTag in delTags:
|
||||
del self._tagsIndex[tTag]
|
||||
del self._itemIndex[tHandle]
|
||||
SHARED.indexSignalProxy({
|
||||
"event": "updateTags",
|
||||
"deleted": delTags,
|
||||
})
|
||||
SHARED.emitIndexChangedTags(self._project, [], delTags)
|
||||
return
|
||||
|
||||
def reIndexHandle(self, tHandle: str | None) -> None:
|
||||
@@ -212,7 +209,7 @@ class NWIndex:
|
||||
self.reIndexHandle(fHandle)
|
||||
|
||||
self._indexChange = time()
|
||||
SHARED.indexSignalProxy({"event": "buildIndex"})
|
||||
SHARED.emitIndexAvailable(self._project)
|
||||
|
||||
logger.debug("Index loaded in %.3f ms", (time() - tStart)*1000)
|
||||
|
||||
@@ -296,10 +293,7 @@ class NWIndex:
|
||||
self._indexChange = nowTime
|
||||
self._rootChange[tItem.itemRoot] = nowTime
|
||||
if not blockSignal:
|
||||
SHARED.indexSignalProxy({
|
||||
"event": "scanText",
|
||||
"handle": tHandle,
|
||||
})
|
||||
tItem.notifyToRefresh()
|
||||
|
||||
return True
|
||||
|
||||
@@ -370,11 +364,7 @@ class NWIndex:
|
||||
del self._tagsIndex[tTag]
|
||||
deleted.append(tTag)
|
||||
if updated or deleted:
|
||||
SHARED.indexSignalProxy({
|
||||
"event": "updateTags",
|
||||
"updated": updated,
|
||||
"deleted": deleted,
|
||||
})
|
||||
SHARED.emitIndexChangedTags(self._project, updated, deleted)
|
||||
|
||||
return
|
||||
|
||||
@@ -487,13 +477,14 @@ class NWIndex:
|
||||
if nBits == 0:
|
||||
return []
|
||||
|
||||
# Check that the key is valid
|
||||
isGood[0] = tBits[0] in nwKeyWords.VALID_KEYS
|
||||
# Check that the keyword is valid
|
||||
kBit = tBits[0]
|
||||
isGood[0] = kBit in nwKeyWords.VALID_KEYS
|
||||
if not isGood[0] or nBits == 1:
|
||||
return isGood
|
||||
|
||||
# For a tag, only the first value is accepted, the rest are ignored
|
||||
if tBits[0] == nwKeyWords.TAG_KEY and nBits > 1:
|
||||
if kBit == nwKeyWords.TAG_KEY and nBits > 1:
|
||||
check, _ = self.parseValue(tBits[1])
|
||||
if check in self._tagsIndex:
|
||||
isGood[1] = self._tagsIndex.tagHandle(check) == tHandle
|
||||
@@ -501,12 +492,16 @@ class NWIndex:
|
||||
isGood[1] = True
|
||||
return isGood
|
||||
|
||||
if kBit == nwKeyWords.MENTION_KEY and nBits > 1:
|
||||
isGood[1:nBits] = [aBit in self._tagsIndex for aBit in tBits[1:nBits]]
|
||||
return isGood
|
||||
|
||||
# If we're still here, we check that the references exist
|
||||
# Class references cannot have the | symbol in them
|
||||
refKey = nwKeyWords.KEY_CLASS[tBits[0]].name
|
||||
for n in range(1, nBits):
|
||||
if (aBit := tBits[n]) in self._tagsIndex:
|
||||
isGood[n] = self._tagsIndex.tagClass(aBit) == refKey and "|" not in aBit
|
||||
if rClass := nwKeyWords.KEY_CLASS.get(kBit):
|
||||
for n in range(1, nBits):
|
||||
if (aBit := tBits[n]) in self._tagsIndex:
|
||||
isGood[n] = self._tagsIndex.tagClass(aBit) == rClass.name and "|" not in aBit
|
||||
|
||||
return isGood
|
||||
|
||||
@@ -569,7 +564,7 @@ class NWIndex:
|
||||
for _, _, hItem in self._itemIndex.iterNovelStructure(
|
||||
rHandle=rootHandle, activeOnly=activeOnly
|
||||
):
|
||||
iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0)
|
||||
iLevel = nwStyles.H_LEVEL.get(hItem.level, 0)
|
||||
hCount[iLevel] += 1
|
||||
return hCount
|
||||
|
||||
@@ -591,7 +586,7 @@ class NWIndex:
|
||||
rHandle=rHandle, activeOnly=activeOnly
|
||||
):
|
||||
tKey = f"{tHandle}:{sTitle}"
|
||||
iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0)
|
||||
iLevel = nwStyles.H_LEVEL.get(hItem.level, 0)
|
||||
if iLevel > maxDepth:
|
||||
if pKey in tData:
|
||||
tData[pKey]["words"] += hItem.wordCount
|
||||
@@ -635,7 +630,7 @@ class NWIndex:
|
||||
"""Extract all references made in a file, and optionally title
|
||||
section.
|
||||
"""
|
||||
tRefs = {x: [] for x in nwKeyWords.KEY_CLASS}
|
||||
tRefs = {x: [] for x in nwKeyWords.VALID_KEYS}
|
||||
for rTitle, hItem in self._itemIndex.iterItemHeaders(tHandle):
|
||||
if sTitle is None or sTitle == rTitle:
|
||||
for aTag, refTypes in hItem.references.items():
|
||||
@@ -681,9 +676,10 @@ class NWIndex:
|
||||
"""Return all tags used by a specific document."""
|
||||
return self._itemIndex.allItemTags(tHandle) if tHandle else []
|
||||
|
||||
def getClassTags(self, itemClass: nwItemClass) -> list[str]:
|
||||
def getClassTags(self, itemClass: nwItemClass | None) -> list[str]:
|
||||
"""Return all tags based on itemClass."""
|
||||
return self._tagsIndex.filterTagNames(itemClass.name)
|
||||
name = None if itemClass is None else itemClass.name
|
||||
return self._tagsIndex.filterTagNames(name)
|
||||
|
||||
def getTagsData(
|
||||
self, activeOnly: bool = True
|
||||
@@ -780,11 +776,16 @@ class TagsIndex:
|
||||
"""Get the class of a given tag."""
|
||||
return self._tags.get(tagKey.lower(), {}).get("class", None)
|
||||
|
||||
def filterTagNames(self, className: str) -> list[str]:
|
||||
def filterTagNames(self, className: str | None) -> list[str]:
|
||||
"""Get a list of tag names for a given class."""
|
||||
return [
|
||||
x.get("name", "") for x in self._tags.values() if x.get("class", "") == className
|
||||
]
|
||||
if className is None:
|
||||
return [
|
||||
x.get("name", "") for x in self._tags.values()
|
||||
]
|
||||
else:
|
||||
return [
|
||||
x.get("name", "") for x in self._tags.values() if x.get("class", "") == className
|
||||
]
|
||||
|
||||
##
|
||||
# Pack/Unpack
|
||||
@@ -1251,7 +1252,7 @@ class IndexHeading:
|
||||
|
||||
def setLevel(self, level: str) -> None:
|
||||
"""Set the level of the heading if it's a valid value."""
|
||||
if level in nwHeaders.H_VALID:
|
||||
if level in nwStyles.H_VALID:
|
||||
self._level = level
|
||||
return
|
||||
|
||||
|
||||
@@ -27,13 +27,14 @@ import logging
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from PyQt5.QtGui import QIcon
|
||||
from PyQt5.QtGui import QFont, QIcon
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import (
|
||||
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified,
|
||||
yesNo
|
||||
)
|
||||
from novelwriter.constants import nwHeaders, nwLabels, trConst
|
||||
from novelwriter.constants import nwLabels, nwStyles, trConst
|
||||
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -256,6 +257,8 @@ class NWItem:
|
||||
self._paraCount = 0
|
||||
self._cursorPos = 0
|
||||
|
||||
self._initCount = self._wordCount
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
@@ -281,6 +284,15 @@ class NWItem:
|
||||
cls._initCount = source._initCount
|
||||
return cls
|
||||
|
||||
##
|
||||
# Action Methods
|
||||
##
|
||||
|
||||
def notifyToRefresh(self) -> None:
|
||||
"""Notify GUI that item info needs to be refreshed."""
|
||||
self._project.tree.refreshItems([self._handle])
|
||||
return
|
||||
|
||||
##
|
||||
# Lookup Methods
|
||||
##
|
||||
@@ -309,6 +321,19 @@ class NWItem:
|
||||
|
||||
return trConst(nwLabels.ITEM_DESCRIPTION.get(descKey, ""))
|
||||
|
||||
def getMainIcon(self) -> QIcon:
|
||||
"""Get the main item icon."""
|
||||
return SHARED.theme.getItemIcon(self._type, self._class, self._layout, self._heading)
|
||||
|
||||
def getMainFont(self) -> QFont:
|
||||
"""Get the main item icon."""
|
||||
if CONFIG.emphLabels and self._layout == nwItemLayout.DOCUMENT:
|
||||
if self._heading == "H1":
|
||||
return SHARED.theme.guiFontBU
|
||||
elif self._heading == "H2":
|
||||
return SHARED.theme.guiFontB
|
||||
return SHARED.theme.guiFont
|
||||
|
||||
def getImportStatus(self) -> tuple[str, QIcon]:
|
||||
"""Return the relevant importance or status label and icon for
|
||||
the current item based on its class.
|
||||
@@ -319,6 +344,19 @@ class NWItem:
|
||||
entry = self._project.data.itemImport[self._import]
|
||||
return entry.name, entry.icon
|
||||
|
||||
def getActiveStatus(self) -> tuple[str, QIcon]:
|
||||
"""Return the relevant active status label and icon for
|
||||
the current item based on its type.
|
||||
"""
|
||||
if self.isFileType():
|
||||
key = "checked" if self._active else "unchecked"
|
||||
text = trConst(nwLabels.ACTIVE_NAME[key])
|
||||
icon = SHARED.theme.getIcon(key)
|
||||
else:
|
||||
text = ""
|
||||
icon = SHARED.theme.getIcon("noncheckable")
|
||||
return text, icon
|
||||
|
||||
##
|
||||
# Checker Methods
|
||||
##
|
||||
@@ -518,7 +556,7 @@ class NWItem:
|
||||
|
||||
def setMainHeading(self, value: str) -> None:
|
||||
"""Set the main heading level."""
|
||||
if value in nwHeaders.H_LEVEL:
|
||||
if value in nwStyles.H_LEVEL:
|
||||
self._heading = value
|
||||
return
|
||||
|
||||
@@ -553,8 +591,3 @@ class NWItem:
|
||||
else:
|
||||
self._cursorPos = 0
|
||||
return
|
||||
|
||||
def saveInitialCount(self) -> None:
|
||||
"""Save the initial word count."""
|
||||
self._initCount = self._wordCount
|
||||
return
|
||||
|
||||
518
novelwriter/core/itemmodel.py
Normal file
@@ -0,0 +1,518 @@
|
||||
"""
|
||||
novelWriter – Project Item Model
|
||||
================================
|
||||
|
||||
File History:
|
||||
Created: 2024-11-16 [2.6b2] ProjectNode
|
||||
Created: 2024-11-16 [2.6b2] ProjectModel
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PyQt5.QtCore import QAbstractItemModel, QMimeData, QModelIndex, Qt
|
||||
from PyQt5.QtGui import QFont, QIcon
|
||||
|
||||
from novelwriter.common import decodeMimeHandles, encodeMimeHandles, minmax
|
||||
from novelwriter.constants import nwConst
|
||||
from novelwriter.core.item import NWItem
|
||||
from novelwriter.enum import nwItemClass
|
||||
from novelwriter.types import QtAlignRight
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from novelwriter.core.tree import NWTree
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INV_ROOT = "invisibleRoot"
|
||||
C_FACTOR = 0x0100
|
||||
|
||||
C_LABEL_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole
|
||||
C_LABEL_ICON = 0x0000 | Qt.ItemDataRole.DecorationRole
|
||||
C_LABEL_FONT = 0x0000 | Qt.ItemDataRole.FontRole
|
||||
C_COUNT_TEXT = 0x0100 | Qt.ItemDataRole.DisplayRole
|
||||
C_COUNT_ICON = 0x0100 | Qt.ItemDataRole.DecorationRole
|
||||
C_COUNT_ALIGN = 0x0100 | Qt.ItemDataRole.TextAlignmentRole
|
||||
C_ACTIVE_ICON = 0x0200 | Qt.ItemDataRole.DecorationRole
|
||||
C_ACTIVE_TIP = 0x0200 | Qt.ItemDataRole.ToolTipRole
|
||||
C_STATUS_ICON = 0x0300 | Qt.ItemDataRole.DecorationRole
|
||||
C_STATUS_TIP = 0x0300 | Qt.ItemDataRole.ToolTipRole
|
||||
|
||||
NODE_FLAGS = Qt.ItemFlag.ItemIsEnabled
|
||||
NODE_FLAGS |= Qt.ItemFlag.ItemIsSelectable
|
||||
NODE_FLAGS |= Qt.ItemFlag.ItemIsDropEnabled
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
# Requires Python 3.10
|
||||
T_NodeData = str | QIcon | QFont | Qt.AlignmentFlag | None
|
||||
|
||||
|
||||
class ProjectNode:
|
||||
"""Core: Project Model Node Class
|
||||
|
||||
The project tree structure is saved as nodes in a tree, starting
|
||||
from a root node. This class makes up these nodes.
|
||||
|
||||
Each node is a wrapper around an NWItem object. The NWItem is the
|
||||
object representing a single item in the project, and it only
|
||||
contains a reference to its parent as well as it top level root, but
|
||||
is itself not structured in a hierarchy in memory.
|
||||
|
||||
This class provides the necessary hierarchical structure, as well as
|
||||
the data entries needed for populating the GUI project tree. It also
|
||||
handles pushing and pulling information from its NWItem when
|
||||
necessary.
|
||||
|
||||
The data to be displayed could in principle be pulled from the
|
||||
NWItem whenever it is needed, but for performance reason it is
|
||||
cached, as the GUI will pull this information often.
|
||||
"""
|
||||
|
||||
C_NAME = 0
|
||||
C_COUNT = 1
|
||||
C_ACTIVE = 2
|
||||
C_STATUS = 3
|
||||
|
||||
__slots__ = ("_item", "_children", "_parent", "_row", "_cache", "_flags", "_count")
|
||||
|
||||
def __init__(self, item: NWItem) -> None:
|
||||
self._item = item
|
||||
self._children: list[ProjectNode] = []
|
||||
self._parent: ProjectNode | None = None
|
||||
self._row = 0
|
||||
self._cache: dict[int, T_NodeData] = {}
|
||||
self._flags = NODE_FLAGS
|
||||
self._count = 0
|
||||
self.refresh()
|
||||
self.updateCount()
|
||||
return
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<ProjectNode handle={self._item.itemHandle} "
|
||||
f"parent={self._parent.item.itemHandle if self._parent else None} "
|
||||
f"row={self._row} "
|
||||
f"children={len(self._children)}>"
|
||||
)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""A node should always evaluate to True."""
|
||||
return True
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def item(self) -> NWItem:
|
||||
"""The project item of the node."""
|
||||
return self._item
|
||||
|
||||
@property
|
||||
def children(self) -> list[ProjectNode]:
|
||||
"""All children of the node."""
|
||||
return self._children
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
"""The count of the node."""
|
||||
return self._count
|
||||
|
||||
##
|
||||
# Data Maintenance
|
||||
##
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Refresh data values."""
|
||||
# Label
|
||||
self._cache[C_LABEL_ICON] = self._item.getMainIcon()
|
||||
self._cache[C_LABEL_TEXT] = self._item.itemName
|
||||
self._cache[C_LABEL_FONT] = self._item.getMainFont()
|
||||
|
||||
# Count
|
||||
self._cache[C_COUNT_ALIGN] = QtAlignRight
|
||||
|
||||
# Active
|
||||
aText, aIcon = self._item.getActiveStatus()
|
||||
self._cache[C_ACTIVE_TIP] = aText
|
||||
self._cache[C_ACTIVE_ICON] = aIcon
|
||||
|
||||
# Status
|
||||
sText, sIcon = self._item.getImportStatus()
|
||||
self._cache[C_STATUS_TIP] = sText
|
||||
self._cache[C_STATUS_ICON] = sIcon
|
||||
|
||||
return
|
||||
|
||||
def updateCount(self, propagate: bool = True) -> None:
|
||||
"""Update counts, and propagate upwards in the tree."""
|
||||
self._count = self._item.wordCount + sum(c._count for c in self._children)
|
||||
self._cache[C_COUNT_TEXT] = f"{self._count:n}"
|
||||
if propagate and (parent := self._parent):
|
||||
parent.updateCount()
|
||||
return
|
||||
|
||||
##
|
||||
# Data Access
|
||||
##
|
||||
|
||||
def row(self) -> int:
|
||||
"""Return the node's row number."""
|
||||
return self._row
|
||||
|
||||
def childCount(self) -> int:
|
||||
"""Return the number of children of the node."""
|
||||
return len(self._children)
|
||||
|
||||
def data(self, column: int, role: Qt.ItemDataRole) -> T_NodeData:
|
||||
"""Return cached node data."""
|
||||
return self._cache.get(C_FACTOR*column | role)
|
||||
|
||||
def flags(self) -> Qt.ItemFlag:
|
||||
"""Return cached node flags."""
|
||||
return self._flags
|
||||
|
||||
def parent(self) -> ProjectNode | None:
|
||||
"""Return the parent of the node."""
|
||||
return self._parent
|
||||
|
||||
def child(self, row: int) -> ProjectNode | None:
|
||||
"""Return a child ofg the node."""
|
||||
if 0 <= row < len(self._children):
|
||||
return self._children[row]
|
||||
return None
|
||||
|
||||
def allChildren(self) -> list[ProjectNode]:
|
||||
"""Return a recursive list of all children."""
|
||||
nodes: list[ProjectNode] = []
|
||||
self._recursiveAppendChildren(nodes)
|
||||
return nodes
|
||||
|
||||
##
|
||||
# Data Edit
|
||||
##
|
||||
|
||||
def addChild(self, child: ProjectNode, pos: int = -1) -> None:
|
||||
"""Add a child item to this item."""
|
||||
child._parent = self
|
||||
self._updateRelationships(child)
|
||||
if 0 <= pos < len(self._children):
|
||||
self._children.insert(pos, child)
|
||||
else:
|
||||
child._row = len(self._children)
|
||||
self._children.append(child)
|
||||
self._refreshChildrenPos()
|
||||
return
|
||||
|
||||
def takeChild(self, pos: int) -> ProjectNode | None:
|
||||
"""Remove a child item and return it."""
|
||||
if 0 <= pos < len(self._children):
|
||||
node = self._children.pop(pos)
|
||||
self._refreshChildrenPos()
|
||||
self.updateCount()
|
||||
return node
|
||||
return None
|
||||
|
||||
def moveChild(self, source: int, target: int) -> None:
|
||||
"""Move a child internally."""
|
||||
count = len(self._children)
|
||||
if (source != target) and (0 <= source < count) and (0 <= target <= count):
|
||||
node = self._children.pop(source)
|
||||
self._children.insert(target, node)
|
||||
self._refreshChildrenPos()
|
||||
return
|
||||
|
||||
def setExpanded(self, state: bool) -> None:
|
||||
"""Set the node's expanded state."""
|
||||
if state and self._children:
|
||||
self._item.setExpanded(True)
|
||||
else:
|
||||
self._item.setExpanded(False)
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _recursiveAppendChildren(self, children: list[ProjectNode]) -> None:
|
||||
"""Recursively add all nodes to a list."""
|
||||
for node in self._children:
|
||||
children.append(node)
|
||||
node._recursiveAppendChildren(children)
|
||||
return
|
||||
|
||||
def _refreshChildrenPos(self) -> None:
|
||||
"""Update the row value on all children."""
|
||||
for n, child in enumerate(self._children):
|
||||
child._row = n
|
||||
child.item.setOrder(n)
|
||||
return
|
||||
|
||||
def _updateRelationships(self, child: ProjectNode) -> None:
|
||||
"""Update a child item's relationships."""
|
||||
if self._parent:
|
||||
child.item.setParent(self._item.itemHandle)
|
||||
child.item.setRoot(self._item.itemRoot)
|
||||
child.item.setClassDefaults(self._item.itemClass)
|
||||
child._flags = NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled
|
||||
else:
|
||||
child.item.setParent(None)
|
||||
child.item.setRoot(child.item.itemHandle)
|
||||
child.item.setClassDefaults(child.item.itemClass)
|
||||
return
|
||||
|
||||
|
||||
class ProjectModel(QAbstractItemModel):
|
||||
"""Core: Project Model Class
|
||||
|
||||
This class provides the interface for the tree widget used on the
|
||||
GUI. It implements the QModelIndex based interface required, adds
|
||||
support for drag and drop, and a few other novelWriter-specific
|
||||
methods needed primarily by the project tree GUI component.
|
||||
"""
|
||||
|
||||
__slots__ = ("_tree", "_root")
|
||||
|
||||
def __init__(self, tree: NWTree) -> None:
|
||||
super().__init__()
|
||||
self._tree = tree
|
||||
self._root = ProjectNode(NWItem(tree._project, INV_ROOT))
|
||||
self._root.item.setName("Invisible Root")
|
||||
logger.debug("Ready: ProjectModel")
|
||||
return
|
||||
|
||||
def __del__(self) -> None: # pragma: no cover
|
||||
logger.debug("Delete: ProjectModel")
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def root(self) -> ProjectNode:
|
||||
"""Return the model root item."""
|
||||
return self._root
|
||||
|
||||
##
|
||||
# Model Interface
|
||||
##
|
||||
|
||||
def rowCount(self, index: QModelIndex) -> int:
|
||||
"""Return the number of rows for an entry."""
|
||||
if index.isValid():
|
||||
return index.internalPointer().childCount()
|
||||
return self._root.childCount()
|
||||
|
||||
def columnCount(self, index: QModelIndex) -> int:
|
||||
"""Return the number of columns for an entry."""
|
||||
return 4
|
||||
|
||||
def parent(self, index: QModelIndex) -> QModelIndex:
|
||||
"""Get the parent model index of another index."""
|
||||
if index.isValid() and (parent := index.internalPointer().parent()):
|
||||
return self.createIndex(parent.row(), 0, parent)
|
||||
return QModelIndex()
|
||||
|
||||
def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex:
|
||||
"""get the index of a child item of a parent."""
|
||||
if self.hasIndex(row, column, parent):
|
||||
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
|
||||
if child := node.child(row):
|
||||
return self.createIndex(row, column, child)
|
||||
return QModelIndex()
|
||||
|
||||
def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData:
|
||||
"""Return display data for a project node."""
|
||||
if index.isValid():
|
||||
return index.internalPointer().data(index.column(), role)
|
||||
return None
|
||||
|
||||
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
||||
"""Return flags for a project node."""
|
||||
if index.isValid():
|
||||
return index.internalPointer().flags()
|
||||
return Qt.ItemFlag.NoItemFlags
|
||||
|
||||
##
|
||||
# Drag and Drop
|
||||
##
|
||||
|
||||
def supportedDropActions(self) -> Qt.DropAction:
|
||||
"""Return supported drop actions"""
|
||||
return Qt.DropAction.MoveAction
|
||||
|
||||
def mimeTypes(self) -> list[str]:
|
||||
"""Return the supported mime types of the model."""
|
||||
return [nwConst.MIME_HANDLE]
|
||||
|
||||
def mimeData(self, indices: list[QModelIndex]) -> QMimeData:
|
||||
"""Encode mime data about a selection."""
|
||||
handles = [
|
||||
i.internalPointer().item.itemHandle
|
||||
for i in indices if i.isValid() and i.column() == 0
|
||||
]
|
||||
mime = QMimeData()
|
||||
encodeMimeHandles(mime, handles)
|
||||
return mime
|
||||
|
||||
def canDropMimeData(
|
||||
self, data: QMimeData, action: Qt.DropAction,
|
||||
row: int, column: int, parent: QModelIndex
|
||||
) -> bool:
|
||||
"""Check if mime data can be dropped on the current location."""
|
||||
return data.hasFormat(nwConst.MIME_HANDLE) and action == Qt.DropAction.MoveAction
|
||||
|
||||
def dropMimeData(
|
||||
self, data: QMimeData, action: Qt.DropAction,
|
||||
row: int, column: int, parent: QModelIndex
|
||||
) -> bool:
|
||||
"""Process mime data drop."""
|
||||
if self.canDropMimeData(data, action, row, column, parent):
|
||||
items = []
|
||||
for handle in decodeMimeHandles(data):
|
||||
if (index := self.indexFromHandle(handle)).isValid():
|
||||
items.append(index)
|
||||
self.multiMove(items, parent, row)
|
||||
return True
|
||||
return False
|
||||
|
||||
##
|
||||
# Data Access
|
||||
##
|
||||
|
||||
def row(self, index: QModelIndex) -> int:
|
||||
"""Return the row number of the index."""
|
||||
if index.isValid():
|
||||
return index.internalPointer().row()
|
||||
return -1
|
||||
|
||||
def node(self, index: QModelIndex) -> ProjectNode | None:
|
||||
"""Return the node for a given model index."""
|
||||
if index.isValid():
|
||||
return index.internalPointer()
|
||||
return None
|
||||
|
||||
def nodes(self, indices: list[QModelIndex]) -> list[ProjectNode]:
|
||||
"""Return the nodes for a list of model indices."""
|
||||
return [i.internalPointer() for i in indices if i.isValid() and i.column() == 0]
|
||||
|
||||
def indexFromHandle(self, handle: str | None) -> QModelIndex:
|
||||
"""Get the index representing a node in the model."""
|
||||
if handle and (node := self._tree.nodes.get(handle)):
|
||||
return self.createIndex(node.row(), 0, node)
|
||||
return QModelIndex()
|
||||
|
||||
def indexFromNode(self, node: ProjectNode, column: int = 0) -> QModelIndex:
|
||||
"""Get the index representing a node in the model."""
|
||||
return self.createIndex(node.row(), column, node)
|
||||
|
||||
##
|
||||
# Model Edit
|
||||
##
|
||||
|
||||
def insertChild(self, child: ProjectNode, parent: QModelIndex, pos: int) -> None:
|
||||
"""Insert a node into the model at a given position."""
|
||||
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
|
||||
count = node.childCount()
|
||||
row = minmax(pos, 0, count) if pos >= 0 else count
|
||||
self.beginInsertRows(parent, row, row)
|
||||
node.addChild(child, row)
|
||||
self.endInsertRows()
|
||||
return
|
||||
|
||||
def removeChild(self, parent: QModelIndex, pos: int) -> ProjectNode | None:
|
||||
"""Remove a node from the model and return it."""
|
||||
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
|
||||
if 0 <= pos < node.childCount():
|
||||
self.beginRemoveRows(parent, pos, pos)
|
||||
child = node.takeChild(pos)
|
||||
self.endRemoveRows()
|
||||
return child
|
||||
return None
|
||||
|
||||
def internalMove(self, index: QModelIndex, step: int) -> None:
|
||||
"""Move an item internally among its siblings."""
|
||||
if index.isValid():
|
||||
node: ProjectNode = index.internalPointer()
|
||||
if parent := node.parent():
|
||||
pos = index.row()
|
||||
new = minmax(pos + step, 0, parent.childCount() - 1)
|
||||
if new != pos:
|
||||
end = new if new < pos else new + 1
|
||||
self.beginMoveRows(index.parent(), pos, pos, index.parent(), end)
|
||||
parent.moveChild(pos, new)
|
||||
self.endMoveRows()
|
||||
return
|
||||
|
||||
def multiMove(self, indices: list[QModelIndex], target: QModelIndex, pos: int = -1) -> None:
|
||||
"""Move multiple items to a new location."""
|
||||
if target.isValid():
|
||||
# This is a two pass process. First we only select unique
|
||||
# non-root items for move, then we do a second pass and only
|
||||
# move those items that don't have a parent also scheduled
|
||||
# for moving or have already been moved. Child items are
|
||||
# moved with the parent.
|
||||
pruned = []
|
||||
handles = set()
|
||||
for index in indices:
|
||||
if index.isValid():
|
||||
node: ProjectNode = index.internalPointer()
|
||||
handle = node.item.itemHandle
|
||||
if node.item.isRootType() is False and handle not in handles:
|
||||
pruned.append(node)
|
||||
handles.add(handle)
|
||||
for node in (reversed(pruned) if pos >= 0 else pruned):
|
||||
if node.item.itemParent not in handles:
|
||||
index = self.indexFromNode(node)
|
||||
if temp := self.removeChild(index.parent(), index.row()):
|
||||
self.insertChild(temp, target, pos)
|
||||
for child in reversed(node.allChildren()):
|
||||
node._updateRelationships(child)
|
||||
child.item.notifyToRefresh()
|
||||
node.item.notifyToRefresh()
|
||||
return
|
||||
|
||||
##
|
||||
# Other Methods
|
||||
##
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the project model."""
|
||||
self._root._children.clear()
|
||||
return
|
||||
|
||||
def allExpanded(self) -> list[QModelIndex]:
|
||||
"""Return a list of all expanded items."""
|
||||
expanded = []
|
||||
for node in self._root.allChildren():
|
||||
if node._item.isExpanded:
|
||||
expanded.append(self.createIndex(node.row(), 0, node))
|
||||
return expanded
|
||||
|
||||
def trashSelection(self, indices: list[QModelIndex]) -> bool:
|
||||
"""Check if a selection of indices are all in trash or not."""
|
||||
for index in indices:
|
||||
if index.isValid():
|
||||
node: ProjectNode = index.internalPointer()
|
||||
if node.item.itemClass != nwItemClass.TRASH:
|
||||
return False
|
||||
return True
|
||||
@@ -60,7 +60,7 @@ VALID_MAP: dict[str, set[str]] = {
|
||||
},
|
||||
"GuiManuscript": {
|
||||
"winWidth", "winHeight", "optsWidth", "viewWidth", "listHeight",
|
||||
"detailsHeight", "detailsWidth", "detailsExpanded",
|
||||
"detailsHeight", "detailsWidth", "detailsExpanded", "showNewPage",
|
||||
},
|
||||
"GuiManuscriptBuild": {
|
||||
"winWidth", "winHeight", "fmtWidth", "sumWidth",
|
||||
@@ -73,6 +73,9 @@ VALID_MAP: dict[str, set[str]] = {
|
||||
"widthCol3", "widthCol4", "wordsPerPage", "countFrom", "clearDouble",
|
||||
"novelRoot",
|
||||
},
|
||||
"GuiOutlineDetails": {
|
||||
"detailsWidth", "tagsWidth",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from collections.abc import Iterable
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
@@ -52,7 +51,8 @@ from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
|
||||
from novelwriter.error import logException
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from novelwriter.core.item import NWItem
|
||||
# Requires Python 3.10
|
||||
from novelwriter.core.status import T_StatusKind, T_UpdateEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,6 +67,11 @@ class NWProjectState(Enum):
|
||||
|
||||
class NWProject:
|
||||
|
||||
__slots__ = (
|
||||
"_options", "_storage", "_data", "_tree", "_index", "_session",
|
||||
"_langData", "_changed", "_valid", "_state", "tr",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
# Core Elements
|
||||
@@ -94,6 +99,12 @@ class NWProject:
|
||||
logger.debug("Delete: NWProject")
|
||||
return
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the project."""
|
||||
self._tree.clear()
|
||||
self._index.clear()
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
@@ -150,24 +161,46 @@ class NWProject:
|
||||
"""Return total edit time, including the current session."""
|
||||
return self._data.editTime + round(time() - self._session.start)
|
||||
|
||||
@property
|
||||
def currentTotalCount(self) -> int:
|
||||
"""Return the current total word count from the tree."""
|
||||
return self._tree.model.root.count
|
||||
|
||||
##
|
||||
# Item Methods
|
||||
##
|
||||
|
||||
def newRoot(self, itemClass: nwItemClass, label: str | None = None) -> str:
|
||||
def newRoot(self, itemClass: nwItemClass, pos: int = -1) -> str:
|
||||
"""Add a new root folder to the project. If label is not set,
|
||||
use the class label.
|
||||
"""
|
||||
label = label or trConst(nwLabels.CLASS_NAME[itemClass])
|
||||
return self._tree.create(label, None, nwItemType.ROOT, itemClass)
|
||||
label = trConst(nwLabels.CLASS_NAME[itemClass])
|
||||
return self._tree.create(label, None, nwItemType.ROOT, itemClass=itemClass, pos=pos)
|
||||
|
||||
def newFolder(self, label: str, parent: str) -> str | None:
|
||||
def newFolder(self, label: str, parent: str, pos: int = -1) -> str | None:
|
||||
"""Add a new folder with a given label and parent item."""
|
||||
return self._tree.create(label, parent, nwItemType.FOLDER)
|
||||
return self._tree.create(label, parent, nwItemType.FOLDER, pos=pos)
|
||||
|
||||
def newFile(self, label: str, parent: str) -> str | None:
|
||||
def newFile(self, label: str, parent: str, pos: int = -1) -> str | None:
|
||||
"""Add a new file with a given label and parent item."""
|
||||
return self._tree.create(label, parent, nwItemType.FILE)
|
||||
return self._tree.create(label, parent, nwItemType.FILE, pos=pos)
|
||||
|
||||
def removeItem(self, tHandle: str) -> bool:
|
||||
"""Remove an item from the project. This will delete both the
|
||||
project entry and a document file if it exists.
|
||||
"""
|
||||
if self._tree.checkType(tHandle, nwItemType.FILE):
|
||||
SHARED.closeDocument(tHandle)
|
||||
doc = self._storage.getDocument(tHandle)
|
||||
if not doc.deleteDocument():
|
||||
SHARED.error(
|
||||
self.tr("Could not delete document file."),
|
||||
info=doc.getError()
|
||||
)
|
||||
return False
|
||||
self._index.deleteHandle(tHandle)
|
||||
self._tree.remove(tHandle)
|
||||
return True
|
||||
|
||||
def writeNewFile(self, tHandle: str, hLevel: int, isDocument: bool, text: str = "") -> bool:
|
||||
"""Write content to a new document after it is created. This
|
||||
@@ -209,35 +242,21 @@ class NWProject:
|
||||
text = self._storage.getDocumentText(sHandle)
|
||||
self._storage.getDocument(tHandle).writeDocument(text)
|
||||
sItem.setLayout(tItem.itemLayout)
|
||||
self._index.scanText(tHandle, text)
|
||||
self._index.reIndexHandle(tHandle)
|
||||
|
||||
return True
|
||||
|
||||
def removeItem(self, tHandle: str) -> bool:
|
||||
"""Remove an item from the project. This will delete both the
|
||||
project entry and a document file if it exists.
|
||||
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
|
||||
"""Create a new note. This function is used by the document
|
||||
editor to create note files for unknown tags.
|
||||
"""
|
||||
if self._tree.checkType(tHandle, nwItemType.FILE):
|
||||
delDoc = self._storage.getDocument(tHandle)
|
||||
if not delDoc.deleteDocument():
|
||||
SHARED.error(
|
||||
self.tr("Could not delete document file."),
|
||||
info=delDoc.getError()
|
||||
)
|
||||
return False
|
||||
|
||||
self._index.deleteHandle(tHandle)
|
||||
del self._tree[tHandle]
|
||||
|
||||
return True
|
||||
|
||||
def trashFolder(self) -> str:
|
||||
"""Add the special trash root folder to the project."""
|
||||
trashHandle = self._tree.trashRoot
|
||||
if trashHandle is None:
|
||||
label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
|
||||
return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH)
|
||||
return trashHandle
|
||||
if itemClass != nwItemClass.NO_CLASS:
|
||||
if not (rHandle := self._tree.findRoot(itemClass)):
|
||||
rHandle = self.newRoot(itemClass)
|
||||
if rHandle and (tHandle := SHARED.project.newFile(tag.title(), rHandle)):
|
||||
self.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
|
||||
self._tree.refreshItems([tHandle])
|
||||
return
|
||||
|
||||
##
|
||||
# Project Methods
|
||||
@@ -349,13 +368,14 @@ class NWProject:
|
||||
self._index.loadIndex()
|
||||
if xmlReader.state == XMLReadState.WAS_LEGACY:
|
||||
# Often, the index needs to be rebuilt when updating format
|
||||
self._index.rebuildIndex()
|
||||
self._index.rebuild()
|
||||
|
||||
self.updateWordCounts()
|
||||
self._session.startSession()
|
||||
self.setProjectChanged(False)
|
||||
self._valid = True
|
||||
self._state = NWProjectState.READY
|
||||
self._storage.lockSession() # Lock only after a successful open. See issue #1977.
|
||||
|
||||
SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name))
|
||||
|
||||
@@ -413,12 +433,11 @@ class NWProject:
|
||||
def closeProject(self, idleTime: float = 0.0) -> None:
|
||||
"""Close the project."""
|
||||
logger.info("Closing project")
|
||||
self._index.clearIndex() # Triggers clear signal, see #1718
|
||||
self._index.clear() # Triggers clear signal, see #1718
|
||||
self._options.saveSettings()
|
||||
self._tree.writeToCFile()
|
||||
self._session.appendSession(idleTime)
|
||||
self._storage.closeSession()
|
||||
self._lockedBy = None
|
||||
return
|
||||
|
||||
def backupProject(self, doNotify: bool) -> bool:
|
||||
@@ -488,17 +507,6 @@ class NWProject:
|
||||
self.setProjectChanged(True)
|
||||
return
|
||||
|
||||
def setTreeOrder(self, order: list[str]) -> None:
|
||||
"""A list representing the linear/flattened order of project
|
||||
items in the GUI project tree. The user can rearrange the order
|
||||
by drag-and-drop. Forwarded to the NWTree class.
|
||||
"""
|
||||
if len(self._tree) != len(order):
|
||||
logger.warning("Sizes of new and old tree order do not match")
|
||||
self._tree.setOrder(order)
|
||||
self.setProjectChanged(True)
|
||||
return
|
||||
|
||||
def setProjectChanged(self, status: bool) -> bool:
|
||||
"""Toggle the project changed flag, and propagate the
|
||||
information to the GUI statusbar.
|
||||
@@ -512,47 +520,6 @@ class NWProject:
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def iterProjectItems(self) -> Iterable[NWItem]:
|
||||
"""This function ensures that the item tree loaded is sent to
|
||||
the GUI tree view in such a way that the tree can be built. That
|
||||
is, the parent item must be sent before its child. In principle,
|
||||
a proper XML file will already ensure that, but in the event the
|
||||
order has been altered, or a file is orphaned, this function is
|
||||
capable of handling it.
|
||||
"""
|
||||
sentItems = set()
|
||||
iterItems = self._tree.handles()
|
||||
n = 0
|
||||
nMax = min(len(iterItems), 10000)
|
||||
while n < nMax:
|
||||
tHandle = iterItems[n]
|
||||
tItem = self._tree[tHandle]
|
||||
n += 1
|
||||
if tItem is None:
|
||||
# Technically a bug
|
||||
continue
|
||||
elif tItem.itemParent is None:
|
||||
# Item is a root, or already been identified as orphaned
|
||||
sentItems.add(tHandle)
|
||||
yield tItem
|
||||
elif tItem.itemParent in sentItems:
|
||||
# Item's parent has been sent, so all is fine
|
||||
sentItems.add(tHandle)
|
||||
yield tItem
|
||||
elif tItem.itemParent in iterItems:
|
||||
# Item's parent exists, but hasn't been sent yet, so add
|
||||
# it again to the end, but make sure this doesn't get
|
||||
# out hand, so we cap at 10000 items
|
||||
logger.warning("Item '%s' found before its parent", tHandle)
|
||||
iterItems.append(tHandle)
|
||||
nMax = min(len(iterItems), 10000)
|
||||
else:
|
||||
# Item is orphaned
|
||||
logger.error("Item '%s' has no parent in current tree", tHandle)
|
||||
tItem.setParent(None)
|
||||
yield tItem
|
||||
return
|
||||
|
||||
def updateWordCounts(self) -> None:
|
||||
"""Update the total word count values."""
|
||||
novel, notes = self._tree.sumWords()
|
||||
@@ -573,6 +540,18 @@ class NWProject:
|
||||
self._data.itemImport.increment(nwItem.itemImport)
|
||||
return
|
||||
|
||||
def updateStatus(self, kind: T_StatusKind, update: T_UpdateEntry) -> None:
|
||||
"""Update status or import entries."""
|
||||
if kind == "s":
|
||||
self._data.itemStatus.update(update)
|
||||
SHARED.emitStatusLabelsChanged(self, kind)
|
||||
self._tree.refreshAllItems()
|
||||
elif kind == "i":
|
||||
self._data.itemImport.update(update)
|
||||
SHARED.emitStatusLabelsChanged(self, kind)
|
||||
self._tree.refreshAllItems()
|
||||
return
|
||||
|
||||
def localLookup(self, word: str | int) -> str:
|
||||
"""Look up a word or number in the translation map for the
|
||||
project and return it. The variable is cast to a string before
|
||||
|
||||
@@ -125,21 +125,16 @@ class NWSpellEnchant:
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def addWord(self, word: str) -> bool:
|
||||
def addWord(self, word: str, save: bool = True) -> None:
|
||||
"""Add a word to the project dictionary."""
|
||||
word = word.strip()
|
||||
if not word:
|
||||
return False
|
||||
try:
|
||||
self._enchant.add_to_session(word)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
added = self._userDict.add(word)
|
||||
if added:
|
||||
self._userDict.save()
|
||||
|
||||
return added
|
||||
if word := word.strip():
|
||||
try:
|
||||
self._enchant.add_to_session(word)
|
||||
except Exception:
|
||||
return
|
||||
if save and self._userDict.add(word):
|
||||
self._userDict.save()
|
||||
return
|
||||
|
||||
def listDictionaries(self) -> list[tuple[str, str]]:
|
||||
"""List available dictionaries."""
|
||||
|
||||
@@ -65,6 +65,11 @@ class StatusEntry:
|
||||
|
||||
NO_ENTRY = StatusEntry("", QColor(0, 0, 0), nwStatusShape.SQUARE, QIcon(), 0)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
# Requires Python 3.10
|
||||
T_UpdateEntry = list[tuple[str | None, StatusEntry]]
|
||||
T_StatusKind = Literal["s", "i"]
|
||||
|
||||
|
||||
class NWStatus:
|
||||
|
||||
@@ -73,7 +78,7 @@ class NWStatus:
|
||||
|
||||
__slots__ = ("_store", "_default", "_prefix", "_height")
|
||||
|
||||
def __init__(self, prefix: Literal["s", "i"]) -> None:
|
||||
def __init__(self, prefix: T_StatusKind) -> None:
|
||||
self._store: dict[str, StatusEntry] = {}
|
||||
self._default = None
|
||||
self._prefix = prefix[:1]
|
||||
@@ -120,8 +125,8 @@ class NWStatus:
|
||||
|
||||
return key
|
||||
|
||||
def update(self, update: list[tuple[str | None, StatusEntry]]) -> None:
|
||||
"""Update the list of statuses, and from removed list."""
|
||||
def update(self, update: T_UpdateEntry) -> None:
|
||||
"""Update the list of statuses."""
|
||||
self._store.clear()
|
||||
for key, entry in update:
|
||||
self._store[self._checkKey(key)] = entry
|
||||
|
||||
@@ -235,7 +235,6 @@ class NWStorage:
|
||||
if child.is_dir() and child.name.startswith("data_"):
|
||||
legacy.legacyDataFolder(basePath, child)
|
||||
|
||||
self._writeLockFile()
|
||||
self._ready = True
|
||||
|
||||
return NWStorageOpen.READY
|
||||
@@ -249,6 +248,12 @@ class NWStorage:
|
||||
return True
|
||||
return True
|
||||
|
||||
def lockSession(self) -> None:
|
||||
"""Lock the session when the project is successfully opened."""
|
||||
if self._ready:
|
||||
self._writeLockFile()
|
||||
return
|
||||
|
||||
def closeSession(self) -> None:
|
||||
"""Run tasks related to closing the session."""
|
||||
self._clearLockFile()
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
"""
|
||||
novelWriter – HTML Text Converter
|
||||
=================================
|
||||
|
||||
File History:
|
||||
Created: 2019-05-07 [0.0.1] ToHtml
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
|
||||
from novelwriter.common import formatTimeStamp
|
||||
from novelwriter.constants import nwHeadFmt, nwHtmlUnicode, nwKeyWords, nwLabels
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.tokenizer import T_Formats, Tokenizer, stripEscape
|
||||
from novelwriter.types import FONT_STYLE, FONT_WEIGHTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Each opener tag, with the id of its corresponding closer and tag format
|
||||
HTML_OPENER: dict[int, tuple[int, str]] = {
|
||||
Tokenizer.FMT_B_B: (Tokenizer.FMT_B_E, "<strong>"),
|
||||
Tokenizer.FMT_I_B: (Tokenizer.FMT_I_E, "<em>"),
|
||||
Tokenizer.FMT_D_B: (Tokenizer.FMT_D_E, "<del>"),
|
||||
Tokenizer.FMT_U_B: (Tokenizer.FMT_U_E, "<span style='text-decoration: underline;'>"),
|
||||
Tokenizer.FMT_M_B: (Tokenizer.FMT_M_E, "<mark>"),
|
||||
Tokenizer.FMT_SUP_B: (Tokenizer.FMT_SUP_E, "<sup>"),
|
||||
Tokenizer.FMT_SUB_B: (Tokenizer.FMT_SUB_E, "<sub>"),
|
||||
Tokenizer.FMT_DL_B: (Tokenizer.FMT_DL_E, "<span class='dialog'>"),
|
||||
Tokenizer.FMT_ADL_B: (Tokenizer.FMT_ADL_E, "<span class='altdialog'>"),
|
||||
}
|
||||
|
||||
# Each closer tag, with the id of its corresponding opener and tag format
|
||||
HTML_CLOSER: dict[int, tuple[int, str]] = {
|
||||
Tokenizer.FMT_B_E: (Tokenizer.FMT_B_B, "</strong>"),
|
||||
Tokenizer.FMT_I_E: (Tokenizer.FMT_I_B, "</em>"),
|
||||
Tokenizer.FMT_D_E: (Tokenizer.FMT_D_B, "</del>"),
|
||||
Tokenizer.FMT_U_E: (Tokenizer.FMT_U_B, "</span>"),
|
||||
Tokenizer.FMT_M_E: (Tokenizer.FMT_M_B, "</mark>"),
|
||||
Tokenizer.FMT_SUP_E: (Tokenizer.FMT_SUP_B, "</sup>"),
|
||||
Tokenizer.FMT_SUB_E: (Tokenizer.FMT_SUB_B, "</sub>"),
|
||||
Tokenizer.FMT_DL_E: (Tokenizer.FMT_DL_B, "</span>"),
|
||||
Tokenizer.FMT_ADL_E: (Tokenizer.FMT_ADL_B, "</span>"),
|
||||
}
|
||||
|
||||
# Empty HTML tag record
|
||||
HTML_NONE = (0, "")
|
||||
|
||||
|
||||
class ToHtml(Tokenizer):
|
||||
"""Core: HTML Document Writer
|
||||
|
||||
Extend the Tokenizer class to writer HTML output. This class is
|
||||
also used by the Document Viewer, and Manuscript Build Preview.
|
||||
"""
|
||||
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
super().__init__(project)
|
||||
|
||||
self._cssStyles = True
|
||||
self._fullHTML: list[str] = []
|
||||
|
||||
# Internals
|
||||
self._trMap = {}
|
||||
self._usedNotes: dict[str, int] = {}
|
||||
self.setReplaceUnicode(False)
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def fullHTML(self) -> list[str]:
|
||||
return self._fullHTML
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setStyles(self, cssStyles: bool) -> None:
|
||||
"""Enable or disable CSS styling. Some elements may still have
|
||||
class tags.
|
||||
"""
|
||||
self._cssStyles = cssStyles
|
||||
return
|
||||
|
||||
def setReplaceUnicode(self, doReplace: bool) -> None:
|
||||
"""Set the translation map to either minimal or full unicode for
|
||||
html entities replacement.
|
||||
"""
|
||||
# Control characters must always be replaced
|
||||
# Angle brackets are replaced later as they are also used in
|
||||
# formatting codes
|
||||
self._trMap = str.maketrans({"&": "&"})
|
||||
if doReplace:
|
||||
# Extend to all relevant Unicode characters
|
||||
self._trMap.update(str.maketrans(nwHtmlUnicode.U_TO_H))
|
||||
return
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def getFullResultSize(self) -> int:
|
||||
"""Return the size of the full HTML result."""
|
||||
return sum(len(x) for x in self._fullHTML)
|
||||
|
||||
def doPreProcessing(self) -> None:
|
||||
"""Extend the auto-replace to also properly encode some unicode
|
||||
characters into their respective HTML entities.
|
||||
"""
|
||||
super().doPreProcessing()
|
||||
self._text = self._text.translate(self._trMap)
|
||||
return
|
||||
|
||||
def doConvert(self) -> None:
|
||||
"""Convert the list of text tokens into an HTML document."""
|
||||
self._result = ""
|
||||
|
||||
if self._isNovel:
|
||||
# For story files, we bump the titles one level up
|
||||
h1Cl = " class='title'"
|
||||
h1 = "h1"
|
||||
h2 = "h1"
|
||||
h3 = "h2"
|
||||
h4 = "h3"
|
||||
else:
|
||||
h1Cl = ""
|
||||
h1 = "h1"
|
||||
h2 = "h2"
|
||||
h3 = "h3"
|
||||
h4 = "h4"
|
||||
|
||||
lines = []
|
||||
tHandle = self._handle
|
||||
|
||||
for tType, nHead, tText, tFormat, tStyle in self._tokens:
|
||||
|
||||
# Replace < and > with HTML entities
|
||||
if tFormat:
|
||||
# If we have formatting, we must recompute the locations
|
||||
cText = []
|
||||
i = 0
|
||||
for c in tText:
|
||||
if c == "<":
|
||||
cText.append("<")
|
||||
tFormat = [(p + 3 if p > i else p, f, k) for p, f, k in tFormat]
|
||||
i += 4
|
||||
elif c == ">":
|
||||
cText.append(">")
|
||||
tFormat = [(p + 3 if p > i else p, f, k) for p, f, k in tFormat]
|
||||
i += 4
|
||||
else:
|
||||
cText.append(c)
|
||||
i += 1
|
||||
tText = "".join(cText)
|
||||
else:
|
||||
# If we don't have formatting, we can do a plain replace
|
||||
tText = tText.replace("<", "<").replace(">", ">")
|
||||
|
||||
# Styles
|
||||
aStyle = []
|
||||
if tStyle is not None and self._cssStyles:
|
||||
if tStyle & self.A_LEFT:
|
||||
aStyle.append("text-align: left;")
|
||||
elif tStyle & self.A_RIGHT:
|
||||
aStyle.append("text-align: right;")
|
||||
elif tStyle & self.A_CENTRE:
|
||||
aStyle.append("text-align: center;")
|
||||
elif tStyle & self.A_JUSTIFY:
|
||||
aStyle.append("text-align: justify;")
|
||||
|
||||
if tStyle & self.A_PBB:
|
||||
aStyle.append("page-break-before: always;")
|
||||
if tStyle & self.A_PBA:
|
||||
aStyle.append("page-break-after: always;")
|
||||
|
||||
if tStyle & self.A_Z_BTMMRG:
|
||||
aStyle.append("margin-bottom: 0;")
|
||||
if tStyle & self.A_Z_TOPMRG:
|
||||
aStyle.append("margin-top: 0;")
|
||||
|
||||
if tStyle & self.A_IND_L:
|
||||
aStyle.append(f"margin-left: {self._blockIndent:.2f}em;")
|
||||
if tStyle & self.A_IND_R:
|
||||
aStyle.append(f"margin-right: {self._blockIndent:.2f}em;")
|
||||
if tStyle & self.A_IND_T:
|
||||
aStyle.append(f"text-indent: {self._firstWidth:.2f}em;")
|
||||
|
||||
if aStyle:
|
||||
stVals = " ".join(aStyle)
|
||||
hStyle = f" style='{stVals}'"
|
||||
else:
|
||||
hStyle = ""
|
||||
|
||||
if self._linkHeadings and tHandle:
|
||||
aNm = f"<a name='{tHandle}:T{nHead:04d}'></a>"
|
||||
else:
|
||||
aNm = ""
|
||||
|
||||
# Process Text Type
|
||||
if tType == self.T_TEXT:
|
||||
lines.append(f"<p{hStyle}>{self._formatText(tText, tFormat)}</p>\n")
|
||||
|
||||
elif tType == self.T_TITLE:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "<br>")
|
||||
lines.append(f"<h1 class='title'{hStyle}>{aNm}{tHead}</h1>\n")
|
||||
|
||||
elif tType == self.T_HEAD1:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "<br>")
|
||||
lines.append(f"<{h1}{h1Cl}{hStyle}>{aNm}{tHead}</{h1}>\n")
|
||||
|
||||
elif tType == self.T_HEAD2:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "<br>")
|
||||
lines.append(f"<{h2}{hStyle}>{aNm}{tHead}</{h2}>\n")
|
||||
|
||||
elif tType == self.T_HEAD3:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "<br>")
|
||||
lines.append(f"<{h3}{hStyle}>{aNm}{tHead}</{h3}>\n")
|
||||
|
||||
elif tType == self.T_HEAD4:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "<br>")
|
||||
lines.append(f"<{h4}{hStyle}>{aNm}{tHead}</{h4}>\n")
|
||||
|
||||
elif tType == self.T_SEP:
|
||||
lines.append(f"<p class='sep'{hStyle}>{tText}</p>\n")
|
||||
|
||||
elif tType == self.T_SKIP:
|
||||
lines.append(f"<p class='skip'{hStyle}> </p>\n")
|
||||
|
||||
elif tType == self.T_SYNOPSIS and self._doSynopsis:
|
||||
lines.append(self._formatSynopsis(self._formatText(tText, tFormat), True))
|
||||
|
||||
elif tType == self.T_SHORT and self._doSynopsis:
|
||||
lines.append(self._formatSynopsis(self._formatText(tText, tFormat), False))
|
||||
|
||||
elif tType == self.T_COMMENT and self._doComments:
|
||||
lines.append(self._formatComments(self._formatText(tText, tFormat)))
|
||||
|
||||
elif tType == self.T_KEYWORD and self._doKeywords:
|
||||
tag, text = self._formatKeywords(tText)
|
||||
kClass = f" class='meta meta-{tag}'" if tag else ""
|
||||
tTemp = f"<p{kClass}{hStyle}>{text}</p>\n"
|
||||
lines.append(tTemp)
|
||||
|
||||
self._result = "".join(lines)
|
||||
self._fullHTML.append(self._result)
|
||||
|
||||
return
|
||||
|
||||
def appendFootnotes(self) -> None:
|
||||
"""Append the footnotes in the buffer."""
|
||||
if self._usedNotes:
|
||||
footnotes = self._localLookup("Footnotes")
|
||||
|
||||
lines = []
|
||||
lines.append(f"<h3>{footnotes}</h3>\n")
|
||||
lines.append("<ol>\n")
|
||||
for key, index in self._usedNotes.items():
|
||||
if content := self._footnotes.get(key):
|
||||
text = self._formatText(*content)
|
||||
lines.append(f"<li id='footnote_{index}'><p>{text}</p></li>\n")
|
||||
lines.append("</ol>\n")
|
||||
|
||||
result = "".join(lines)
|
||||
self._result += result
|
||||
self._fullHTML.append(result)
|
||||
|
||||
return
|
||||
|
||||
def saveHtml5(self, path: str | Path) -> None:
|
||||
"""Save the data to an HTML file."""
|
||||
with open(path, mode="w", encoding="utf-8") as fObj:
|
||||
fObj.write((
|
||||
"<!DOCTYPE html>\n"
|
||||
"<html>\n"
|
||||
"<head>\n"
|
||||
"<meta charset='utf-8'>\n"
|
||||
"<title>{title:s}</title>\n"
|
||||
"</head>\n"
|
||||
"<style>\n"
|
||||
"{style:s}\n"
|
||||
"</style>\n"
|
||||
"<body>\n"
|
||||
"<article>\n"
|
||||
"{body:s}\n"
|
||||
"</article>\n"
|
||||
"</body>\n"
|
||||
"</html>\n"
|
||||
).format(
|
||||
title=self._project.data.name,
|
||||
style="\n".join(self.getStyleSheet()),
|
||||
body=("".join(self._fullHTML)).replace("\t", "	").rstrip(),
|
||||
))
|
||||
logger.info("Wrote file: %s", path)
|
||||
return
|
||||
|
||||
def saveHtmlJson(self, path: str | Path) -> None:
|
||||
"""Save the data to a JSON file."""
|
||||
timeStamp = time()
|
||||
data = {
|
||||
"meta": {
|
||||
"projectName": self._project.data.name,
|
||||
"novelAuthor": self._project.data.author,
|
||||
"buildTime": int(timeStamp),
|
||||
"buildTimeStr": formatTimeStamp(timeStamp),
|
||||
},
|
||||
"text": {
|
||||
"css": self.getStyleSheet(),
|
||||
"html": [t.replace("\t", "	").rstrip().split("\n") for t in self.fullHTML],
|
||||
}
|
||||
}
|
||||
with open(path, mode="w", encoding="utf-8") as fObj:
|
||||
json.dump(data, fObj, indent=2)
|
||||
logger.info("Wrote file: %s", path)
|
||||
return
|
||||
|
||||
def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " ") -> None:
|
||||
"""Replace tabs with spaces in the html."""
|
||||
htmlText = []
|
||||
tabSpace = spaceChar*nSpaces
|
||||
for aLine in self._fullHTML:
|
||||
htmlText.append(aLine.replace("\t", tabSpace))
|
||||
|
||||
self._fullHTML = htmlText
|
||||
return
|
||||
|
||||
def getStyleSheet(self) -> list[str]:
|
||||
"""Generate a stylesheet for the current settings."""
|
||||
if not self._cssStyles:
|
||||
return []
|
||||
|
||||
mScale = self._lineHeight/1.15
|
||||
|
||||
styles = []
|
||||
font = self._textFont
|
||||
styles.append((
|
||||
"body {{"
|
||||
"font-family: '{0:s}'; font-size: {1:d}pt; "
|
||||
"font-weight: {2:d}; font-style: {3:s};"
|
||||
"}}"
|
||||
).format(
|
||||
font.family(), font.pointSize(),
|
||||
FONT_WEIGHTS.get(font.weight(), 400),
|
||||
FONT_STYLE.get(font.style(), "normal"),
|
||||
))
|
||||
styles.append((
|
||||
"p {{"
|
||||
"text-align: {0}; line-height: {1:d}%; "
|
||||
"margin-top: {2:.2f}em; margin-bottom: {3:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
"justify" if self._doJustify else "left",
|
||||
round(100 * self._lineHeight),
|
||||
mScale * self._marginText[0],
|
||||
mScale * self._marginText[1],
|
||||
))
|
||||
styles.append((
|
||||
"h1 {{"
|
||||
"color: rgb(66, 113, 174); "
|
||||
"page-break-after: avoid; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale * self._marginHead1[0], mScale * self._marginHead1[1]
|
||||
))
|
||||
styles.append((
|
||||
"h2 {{"
|
||||
"color: rgb(66, 113, 174); "
|
||||
"page-break-after: avoid; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale * self._marginHead2[0], mScale * self._marginHead2[1]
|
||||
))
|
||||
styles.append((
|
||||
"h3 {{"
|
||||
"color: rgb(50, 50, 50); "
|
||||
"page-break-after: avoid; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale * self._marginHead3[0], mScale * self._marginHead3[1]
|
||||
))
|
||||
styles.append((
|
||||
"h4 {{"
|
||||
"color: rgb(50, 50, 50); "
|
||||
"page-break-after: avoid; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale * self._marginHead4[0], mScale * self._marginHead4[1]
|
||||
))
|
||||
styles.append((
|
||||
".title {{"
|
||||
"font-size: 2.5em; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale * self._marginTitle[0], mScale * self._marginTitle[1]
|
||||
))
|
||||
styles.append((
|
||||
".sep, .skip {{"
|
||||
"text-align: center; "
|
||||
"margin-top: {0:.2f}em; "
|
||||
"margin-bottom: {1:.2f}em;"
|
||||
"}}"
|
||||
).format(
|
||||
mScale, mScale
|
||||
))
|
||||
|
||||
styles.append("a {color: rgb(66, 113, 174);}")
|
||||
styles.append("mark {background: rgb(255, 255, 166);}")
|
||||
styles.append(".keyword {color: rgb(245, 135, 31); font-weight: bold;}")
|
||||
styles.append(".break {text-align: left;}")
|
||||
styles.append(".synopsis {font-style: italic;}")
|
||||
styles.append(".comment {font-style: italic; color: rgb(100, 100, 100);}")
|
||||
styles.append(".dialog {color: rgb(66, 113, 174);}")
|
||||
styles.append(".altdialog {color: rgb(129, 55, 9);}")
|
||||
|
||||
return styles
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _formatText(self, text: str, tFmt: T_Formats) -> str:
|
||||
"""Apply formatting tags to text."""
|
||||
temp = text
|
||||
|
||||
# Build a list of all html tags that need to be inserted in the text.
|
||||
# This is done in the forward direction, and a tag is only opened if it
|
||||
# isn't already open, and only closed if it has previously been opened.
|
||||
tags: list[tuple[int, str]] = []
|
||||
state = dict.fromkeys(HTML_OPENER, False)
|
||||
for pos, fmt, data in tFmt:
|
||||
if m := HTML_OPENER.get(fmt):
|
||||
if not state.get(fmt, True):
|
||||
tags.append((pos, m[1]))
|
||||
state[fmt] = True
|
||||
elif m := HTML_CLOSER.get(fmt):
|
||||
if state.get(m[0], False):
|
||||
tags.append((pos, m[1]))
|
||||
state[m[0]] = False
|
||||
elif fmt == self.FMT_FNOTE:
|
||||
if data in self._footnotes:
|
||||
index = len(self._usedNotes) + 1
|
||||
self._usedNotes[data] = index
|
||||
tags.append((pos, f"<sup><a href='#footnote_{index}'>{index}</a></sup>"))
|
||||
else:
|
||||
tags.append((pos, "<sup>ERR</sup>"))
|
||||
|
||||
# Check all format types and close any tag that is still open. This
|
||||
# ensures that unclosed tags don't spill over to the next paragraph.
|
||||
end = len(text)
|
||||
for opener, active in state.items():
|
||||
if active:
|
||||
closer = HTML_OPENER.get(opener, HTML_NONE)[0]
|
||||
tags.append((end, HTML_CLOSER.get(closer, HTML_NONE)[1]))
|
||||
|
||||
# Insert all tags at their correct position, starting from the back.
|
||||
# The reverse order ensures that the positions are not shifted while we
|
||||
# insert tags.
|
||||
for pos, tag in reversed(tags):
|
||||
temp = f"{temp[:pos]}{tag}{temp[pos:]}"
|
||||
|
||||
# Replace all line breaks with proper HTML break tags
|
||||
temp = temp.replace("\n", "<br>")
|
||||
|
||||
return stripEscape(temp)
|
||||
|
||||
def _formatSynopsis(self, text: str, synopsis: bool) -> str:
|
||||
"""Apply HTML formatting to synopsis."""
|
||||
if synopsis:
|
||||
sSynop = self._localLookup("Synopsis")
|
||||
else:
|
||||
sSynop = self._localLookup("Short Description")
|
||||
return f"<p class='synopsis'><strong>{sSynop}:</strong> {text}</p>\n"
|
||||
|
||||
def _formatComments(self, text: str) -> str:
|
||||
"""Apply HTML formatting to comments."""
|
||||
sComm = self._localLookup("Comment")
|
||||
return f"<p class='comment'><strong>{sComm}:</strong> {text}</p>\n"
|
||||
|
||||
def _formatKeywords(self, text: str) -> tuple[str, str]:
|
||||
"""Apply HTML formatting to keywords."""
|
||||
valid, bits, _ = self._project.index.scanThis("@"+text)
|
||||
if not valid or not bits or bits[0] not in nwLabels.KEY_NAME:
|
||||
return "", ""
|
||||
|
||||
result = f"<span class='keyword'>{self._localLookup(nwLabels.KEY_NAME[bits[0]])}:</span> "
|
||||
if len(bits) > 1:
|
||||
if bits[0] == nwKeyWords.TAG_KEY:
|
||||
one, two = self._project.index.parseValue(bits[1])
|
||||
result += f"<a class='tag' name='tag_{one}'>{one}</a>"
|
||||
if two:
|
||||
result += f" | <span class='optional'>{two}</a>"
|
||||
else:
|
||||
result += ", ".join(
|
||||
f"<a class='tag' href='#tag_{t}'>{t}</a>" for t in bits[1:]
|
||||
)
|
||||
|
||||
return bits[0][1:], result
|
||||
@@ -1,252 +0,0 @@
|
||||
"""
|
||||
novelWriter – Markdown Text Converter
|
||||
=====================================
|
||||
|
||||
File History:
|
||||
Created: 2021-02-06 [1.2b1] ToMarkdown
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from novelwriter.constants import nwHeadFmt, nwLabels, nwUnicode
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.tokenizer import T_Formats, Tokenizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Standard Markdown
|
||||
STD_MD = {
|
||||
Tokenizer.FMT_B_B: "**",
|
||||
Tokenizer.FMT_B_E: "**",
|
||||
Tokenizer.FMT_I_B: "_",
|
||||
Tokenizer.FMT_I_E: "_",
|
||||
Tokenizer.FMT_D_B: "",
|
||||
Tokenizer.FMT_D_E: "",
|
||||
Tokenizer.FMT_U_B: "",
|
||||
Tokenizer.FMT_U_E: "",
|
||||
Tokenizer.FMT_M_B: "",
|
||||
Tokenizer.FMT_M_E: "",
|
||||
Tokenizer.FMT_SUP_B: "",
|
||||
Tokenizer.FMT_SUP_E: "",
|
||||
Tokenizer.FMT_SUB_B: "",
|
||||
Tokenizer.FMT_SUB_E: "",
|
||||
Tokenizer.FMT_STRIP: "",
|
||||
}
|
||||
|
||||
# Extended Markdown
|
||||
EXT_MD = {
|
||||
Tokenizer.FMT_B_B: "**",
|
||||
Tokenizer.FMT_B_E: "**",
|
||||
Tokenizer.FMT_I_B: "_",
|
||||
Tokenizer.FMT_I_E: "_",
|
||||
Tokenizer.FMT_D_B: "~~",
|
||||
Tokenizer.FMT_D_E: "~~",
|
||||
Tokenizer.FMT_U_B: "",
|
||||
Tokenizer.FMT_U_E: "",
|
||||
Tokenizer.FMT_M_B: "==",
|
||||
Tokenizer.FMT_M_E: "==",
|
||||
Tokenizer.FMT_SUP_B: "^",
|
||||
Tokenizer.FMT_SUP_E: "^",
|
||||
Tokenizer.FMT_SUB_B: "~",
|
||||
Tokenizer.FMT_SUB_E: "~",
|
||||
Tokenizer.FMT_STRIP: "",
|
||||
}
|
||||
|
||||
|
||||
class ToMarkdown(Tokenizer):
|
||||
"""Core: Markdown Document Writer
|
||||
|
||||
Extend the Tokenizer class to writer Markdown output. It supports
|
||||
both Standard Markdown and Extended Markdown. The class also
|
||||
supports concatenating novelWriter markup files.
|
||||
"""
|
||||
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
super().__init__(project)
|
||||
self._fullMD: list[str] = []
|
||||
self._usedNotes: dict[str, int] = {}
|
||||
self._extended = True
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def fullMD(self) -> list[str]:
|
||||
"""Return the markdown as a list."""
|
||||
return self._fullMD
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setExtendedMarkdown(self, state: bool) -> None:
|
||||
"""Set the converter to use Extended Markdown formatting."""
|
||||
self._extended = state
|
||||
return
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def getFullResultSize(self) -> int:
|
||||
"""Return the size of the full Markdown result."""
|
||||
return sum(len(x) for x in self._fullMD)
|
||||
|
||||
def doConvert(self) -> None:
|
||||
"""Convert the list of text tokens into a Markdown document."""
|
||||
self._result = ""
|
||||
|
||||
if self._extended:
|
||||
mTags = EXT_MD
|
||||
cSkip = nwUnicode.U_MMSP
|
||||
else:
|
||||
mTags = STD_MD
|
||||
cSkip = ""
|
||||
|
||||
lines = []
|
||||
for tType, _, tText, tFormat, tStyle in self._tokens:
|
||||
|
||||
if tType == self.T_TEXT:
|
||||
tTemp = self._formatText(tText, tFormat, mTags).replace("\n", " \n")
|
||||
lines.append(f"{tTemp}\n\n")
|
||||
|
||||
elif tType == self.T_TITLE:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
||||
lines.append(f"# {tHead}\n\n")
|
||||
|
||||
elif tType == self.T_HEAD1:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
||||
lines.append(f"# {tHead}\n\n")
|
||||
|
||||
elif tType == self.T_HEAD2:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
||||
lines.append(f"## {tHead}\n\n")
|
||||
|
||||
elif tType == self.T_HEAD3:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
||||
lines.append(f"### {tHead}\n\n")
|
||||
|
||||
elif tType == self.T_HEAD4:
|
||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
||||
lines.append(f"#### {tHead}\n\n")
|
||||
|
||||
elif tType == self.T_SEP:
|
||||
lines.append(f"{tText}\n\n")
|
||||
|
||||
elif tType == self.T_SKIP:
|
||||
lines.append(f"{cSkip}\n\n")
|
||||
|
||||
elif tType == self.T_SYNOPSIS and self._doSynopsis:
|
||||
label = self._localLookup("Synopsis")
|
||||
lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n")
|
||||
|
||||
elif tType == self.T_SHORT and self._doSynopsis:
|
||||
label = self._localLookup("Short Description")
|
||||
lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n")
|
||||
|
||||
elif tType == self.T_COMMENT and self._doComments:
|
||||
label = self._localLookup("Comment")
|
||||
lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n")
|
||||
|
||||
elif tType == self.T_KEYWORD and self._doKeywords:
|
||||
lines.append(self._formatKeywords(tText, tStyle))
|
||||
|
||||
self._result = "".join(lines)
|
||||
self._fullMD.append(self._result)
|
||||
|
||||
return
|
||||
|
||||
def appendFootnotes(self) -> None:
|
||||
"""Append the footnotes in the buffer."""
|
||||
if self._usedNotes:
|
||||
tags = EXT_MD if self._extended else STD_MD
|
||||
footnotes = self._localLookup("Footnotes")
|
||||
|
||||
lines = []
|
||||
lines.append(f"### {footnotes}\n\n")
|
||||
for key, index in self._usedNotes.items():
|
||||
if content := self._footnotes.get(key):
|
||||
marker = f"{index}. "
|
||||
text = self._formatText(*content, tags)
|
||||
lines.append(f"{marker}{text}\n")
|
||||
lines.append("\n")
|
||||
|
||||
result = "".join(lines)
|
||||
self._result += result
|
||||
self._fullMD.append(result)
|
||||
|
||||
return
|
||||
|
||||
def saveMarkdown(self, path: str | Path) -> None:
|
||||
"""Save the data to a plain text file."""
|
||||
with open(path, mode="w", encoding="utf-8") as outFile:
|
||||
outFile.write("".join(self._fullMD))
|
||||
logger.info("Wrote file: %s", path)
|
||||
return
|
||||
|
||||
def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " ") -> None:
|
||||
"""Replace tabs with spaces."""
|
||||
spaces = spaceChar*nSpaces
|
||||
self._fullMD = [p.replace("\t", spaces) for p in self._fullMD]
|
||||
if self._keepMD:
|
||||
self._markdown = [p.replace("\t", spaces) for p in self._markdown]
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _formatText(self, text: str, tFmt: T_Formats, tags: dict[int, str]) -> str:
|
||||
"""Apply formatting tags to text."""
|
||||
temp = text
|
||||
for pos, fmt, data in reversed(tFmt):
|
||||
md = ""
|
||||
if fmt == self.FMT_FNOTE:
|
||||
if data in self._footnotes:
|
||||
index = len(self._usedNotes) + 1
|
||||
self._usedNotes[data] = index
|
||||
md = f"[{index}]"
|
||||
else:
|
||||
md = "[ERR]"
|
||||
else:
|
||||
md = tags.get(fmt, "")
|
||||
temp = f"{temp[:pos]}{md}{temp[pos:]}"
|
||||
return temp
|
||||
|
||||
def _formatKeywords(self, text: str, style: int) -> str:
|
||||
"""Apply Markdown formatting to keywords."""
|
||||
valid, bits, _ = self._project.index.scanThis("@"+text)
|
||||
if not valid or not bits:
|
||||
return ""
|
||||
|
||||
result = ""
|
||||
if bits[0] in nwLabels.KEY_NAME:
|
||||
result += f"**{self._localLookup(nwLabels.KEY_NAME[bits[0]])}:** "
|
||||
if len(bits) > 1:
|
||||
result += ", ".join(bits[1:])
|
||||
|
||||
result += " \n" if style & self.A_Z_BTMMRG else "\n\n"
|
||||
|
||||
return result
|
||||
@@ -1,419 +0,0 @@
|
||||
"""
|
||||
novelWriter – QTextDocument Converter
|
||||
=====================================
|
||||
|
||||
File History:
|
||||
Created: 2024-05-21 [2.5b1] ToQTextDocument
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from PyQt5.QtGui import (
|
||||
QColor, QFont, QFontMetricsF, QTextBlockFormat, QTextCharFormat,
|
||||
QTextCursor, QTextDocument
|
||||
)
|
||||
|
||||
from novelwriter.constants import nwHeaders, nwHeadFmt, nwKeyWords, nwLabels, nwUnicode
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.tokenizer import T_Formats, Tokenizer
|
||||
from novelwriter.types import (
|
||||
QtAlignAbsolute, QtAlignCenter, QtAlignJustify, QtAlignLeft, QtAlignRight,
|
||||
QtBlack, QtPageBreakAfter, QtPageBreakBefore, QtTransparent,
|
||||
QtVAlignNormal, QtVAlignSub, QtVAlignSuper
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T_TextStyle = tuple[QTextBlockFormat, QTextCharFormat]
|
||||
|
||||
|
||||
class TextDocumentTheme:
|
||||
text: QColor = QtBlack
|
||||
highlight: QColor = QtTransparent
|
||||
head: QColor = QtBlack
|
||||
comment: QColor = QtBlack
|
||||
note: QColor = QtBlack
|
||||
code: QColor = QtBlack
|
||||
modifier: QColor = QtBlack
|
||||
keyword: QColor = QtBlack
|
||||
tag: QColor = QtBlack
|
||||
optional: QColor = QtBlack
|
||||
dialog: QColor = QtBlack
|
||||
altdialog: QColor = QtBlack
|
||||
|
||||
|
||||
def newBlock(cursor: QTextCursor, bFmt: QTextBlockFormat) -> None:
|
||||
if cursor.position() > 0:
|
||||
cursor.insertBlock(bFmt)
|
||||
else:
|
||||
cursor.setBlockFormat(bFmt)
|
||||
|
||||
|
||||
class ToQTextDocument(Tokenizer):
|
||||
"""Core: QTextDocument Writer
|
||||
|
||||
Extend the Tokenizer class to generate a QTextDocument output. This
|
||||
is intended for usage in the document viewer and build tool preview.
|
||||
"""
|
||||
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
super().__init__(project)
|
||||
self._document = QTextDocument()
|
||||
self._document.setUndoRedoEnabled(False)
|
||||
self._document.setDocumentMargin(0)
|
||||
|
||||
self._theme = TextDocumentTheme()
|
||||
self._styles: dict[int, T_TextStyle] = {}
|
||||
self._usedNotes: dict[str, int] = {}
|
||||
|
||||
self._init = False
|
||||
self._bold = QFont.Weight.Bold
|
||||
self._normal = QFont.Weight.Normal
|
||||
|
||||
return
|
||||
|
||||
def initDocument(self, font: QFont, theme: TextDocumentTheme) -> None:
|
||||
"""Initialise all computed values of the document."""
|
||||
self._textFont = font
|
||||
self._theme = theme
|
||||
|
||||
self._document.setUndoRedoEnabled(False)
|
||||
self._document.blockSignals(True)
|
||||
self._document.clear()
|
||||
self._document.setDefaultFont(self._textFont)
|
||||
|
||||
qMetric = QFontMetricsF(self._textFont)
|
||||
mPx = qMetric.ascent() # 1 em in pixels
|
||||
fPt = self._textFont.pointSizeF()
|
||||
|
||||
# Scaled Sizes
|
||||
# ============
|
||||
|
||||
self._mHead = {
|
||||
self.T_TITLE: (mPx * self._marginTitle[0], mPx * self._marginTitle[1]),
|
||||
self.T_HEAD1: (mPx * self._marginHead1[0], mPx * self._marginHead1[1]),
|
||||
self.T_HEAD2: (mPx * self._marginHead2[0], mPx * self._marginHead2[1]),
|
||||
self.T_HEAD3: (mPx * self._marginHead3[0], mPx * self._marginHead3[1]),
|
||||
self.T_HEAD4: (mPx * self._marginHead4[0], mPx * self._marginHead4[1]),
|
||||
}
|
||||
|
||||
self._sHead = {
|
||||
self.T_TITLE: nwHeaders.H_SIZES.get(0, 1.0) * fPt,
|
||||
self.T_HEAD1: nwHeaders.H_SIZES.get(1, 1.0) * fPt,
|
||||
self.T_HEAD2: nwHeaders.H_SIZES.get(2, 1.0) * fPt,
|
||||
self.T_HEAD3: nwHeaders.H_SIZES.get(3, 1.0) * fPt,
|
||||
self.T_HEAD4: nwHeaders.H_SIZES.get(4, 1.0) * fPt,
|
||||
}
|
||||
|
||||
self._mText = (mPx * self._marginText[0], mPx * self._marginText[1])
|
||||
self._mMeta = (mPx * self._marginMeta[0], mPx * self._marginMeta[1])
|
||||
self._mSep = (mPx * self._marginSep[0], mPx * self._marginSep[1])
|
||||
|
||||
self._mIndent = mPx * 2.0
|
||||
self._tIndent = mPx * self._firstWidth
|
||||
|
||||
# Block Format
|
||||
# ============
|
||||
|
||||
self._blockFmt = QTextBlockFormat()
|
||||
self._blockFmt.setTopMargin(self._mText[0])
|
||||
self._blockFmt.setBottomMargin(self._mText[1])
|
||||
self._blockFmt.setAlignment(QtAlignJustify if self._doJustify else QtAlignAbsolute)
|
||||
self._blockFmt.setLineHeight(
|
||||
100*self._lineHeight, QTextBlockFormat.LineHeightTypes.ProportionalHeight
|
||||
)
|
||||
|
||||
# Character Formats
|
||||
# =================
|
||||
|
||||
self._cText = QTextCharFormat()
|
||||
self._cText.setBackground(QtTransparent)
|
||||
self._cText.setForeground(self._theme.text)
|
||||
|
||||
self._cHead = QTextCharFormat(self._cText)
|
||||
self._cHead.setForeground(self._theme.head)
|
||||
|
||||
self._cComment = QTextCharFormat(self._cText)
|
||||
self._cComment.setForeground(self._theme.comment)
|
||||
|
||||
self._cCommentMod = QTextCharFormat(self._cText)
|
||||
self._cCommentMod.setForeground(self._theme.comment)
|
||||
self._cCommentMod.setFontWeight(self._bold)
|
||||
|
||||
self._cNote = QTextCharFormat(self._cText)
|
||||
self._cNote.setForeground(self._theme.note)
|
||||
|
||||
self._cCode = QTextCharFormat(self._cText)
|
||||
self._cCode.setForeground(self._theme.code)
|
||||
|
||||
self._cModifier = QTextCharFormat(self._cText)
|
||||
self._cModifier.setForeground(self._theme.modifier)
|
||||
self._cModifier.setFontWeight(self._bold)
|
||||
|
||||
self._cKeyword = QTextCharFormat(self._cText)
|
||||
self._cKeyword.setForeground(self._theme.keyword)
|
||||
|
||||
self._cTag = QTextCharFormat(self._cText)
|
||||
self._cTag.setForeground(self._theme.tag)
|
||||
|
||||
self._cOptional = QTextCharFormat(self._cText)
|
||||
self._cOptional.setForeground(self._theme.optional)
|
||||
|
||||
self._init = True
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def document(self) -> QTextDocument:
|
||||
"""Return the document."""
|
||||
return self._document
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def doConvert(self) -> None:
|
||||
"""Write text tokens into the document."""
|
||||
if not self._init:
|
||||
return
|
||||
|
||||
self._document.blockSignals(True)
|
||||
cursor = QTextCursor(self._document)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
|
||||
for tType, nHead, tText, tFormat, tStyle in self._tokens:
|
||||
|
||||
# Styles
|
||||
bFmt = QTextBlockFormat(self._blockFmt)
|
||||
if tStyle is not None:
|
||||
if tStyle & self.A_LEFT:
|
||||
bFmt.setAlignment(QtAlignLeft)
|
||||
elif tStyle & self.A_RIGHT:
|
||||
bFmt.setAlignment(QtAlignRight)
|
||||
elif tStyle & self.A_CENTRE:
|
||||
bFmt.setAlignment(QtAlignCenter)
|
||||
elif tStyle & self.A_JUSTIFY:
|
||||
bFmt.setAlignment(QtAlignJustify)
|
||||
|
||||
if tStyle & self.A_PBB:
|
||||
bFmt.setPageBreakPolicy(QtPageBreakBefore)
|
||||
if tStyle & self.A_PBA:
|
||||
bFmt.setPageBreakPolicy(QtPageBreakAfter)
|
||||
|
||||
if tStyle & self.A_Z_BTMMRG:
|
||||
bFmt.setBottomMargin(0.0)
|
||||
if tStyle & self.A_Z_TOPMRG:
|
||||
bFmt.setTopMargin(0.0)
|
||||
|
||||
if tStyle & self.A_IND_L:
|
||||
bFmt.setLeftMargin(self._mIndent)
|
||||
if tStyle & self.A_IND_R:
|
||||
bFmt.setRightMargin(self._mIndent)
|
||||
if tStyle & self.A_IND_T:
|
||||
bFmt.setTextIndent(self._tIndent)
|
||||
|
||||
if tType == self.T_TEXT:
|
||||
newBlock(cursor, bFmt)
|
||||
self._insertFragments(tText, tFormat, cursor, self._cText)
|
||||
|
||||
elif tType in self.L_HEADINGS:
|
||||
bFmt, cFmt = self._genHeadStyle(tType, nHead, bFmt)
|
||||
newBlock(cursor, bFmt)
|
||||
cursor.insertText(tText.replace(nwHeadFmt.BR, "\n"), cFmt)
|
||||
|
||||
elif tType == self.T_SEP:
|
||||
sFmt = QTextBlockFormat(bFmt)
|
||||
sFmt.setTopMargin(self._mSep[0])
|
||||
sFmt.setBottomMargin(self._mSep[1])
|
||||
newBlock(cursor, sFmt)
|
||||
cursor.insertText(tText, self._cText)
|
||||
|
||||
elif tType == self.T_SKIP:
|
||||
newBlock(cursor, bFmt)
|
||||
cursor.insertText(nwUnicode.U_NBSP, self._cText)
|
||||
|
||||
elif tType in self.L_SUMMARY and self._doSynopsis:
|
||||
newBlock(cursor, bFmt)
|
||||
modifier = self._localLookup(
|
||||
"Short Description" if tType == self.T_SHORT else "Synopsis"
|
||||
)
|
||||
cursor.insertText(f"{modifier}: ", self._cModifier)
|
||||
self._insertFragments(tText, tFormat, cursor, self._cNote)
|
||||
|
||||
elif tType == self.T_COMMENT and self._doComments:
|
||||
newBlock(cursor, bFmt)
|
||||
modifier = self._localLookup("Comment")
|
||||
cursor.insertText(f"{modifier}: ", self._cCommentMod)
|
||||
self._insertFragments(tText, tFormat, cursor, self._cComment)
|
||||
|
||||
elif tType == self.T_KEYWORD and self._doKeywords:
|
||||
newBlock(cursor, bFmt)
|
||||
self._insertKeywords(tText, cursor)
|
||||
|
||||
self._document.blockSignals(False)
|
||||
|
||||
return
|
||||
|
||||
def appendFootnotes(self) -> None:
|
||||
"""Append the footnotes in the buffer."""
|
||||
if self._usedNotes:
|
||||
self._document.blockSignals(True)
|
||||
|
||||
cursor = QTextCursor(self._document)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
|
||||
bFmt, cFmt = self._genHeadStyle(self.T_HEAD4, -1, self._blockFmt)
|
||||
newBlock(cursor, bFmt)
|
||||
cursor.insertText(self._localLookup("Footnotes"), cFmt)
|
||||
|
||||
for key, index in self._usedNotes.items():
|
||||
if content := self._footnotes.get(key):
|
||||
cFmt = QTextCharFormat(self._cCode)
|
||||
cFmt.setAnchor(True)
|
||||
cFmt.setAnchorNames([f"footnote_{index}"])
|
||||
newBlock(cursor, self._blockFmt)
|
||||
cursor.insertText(f"{index}. ", cFmt)
|
||||
self._insertFragments(*content, cursor, self._cText)
|
||||
|
||||
self._document.blockSignals(False)
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _insertFragments(
|
||||
self, text: str, tFmt: T_Formats, cursor: QTextCursor, dFmt: QTextCharFormat
|
||||
) -> None:
|
||||
"""Apply formatting tags to text."""
|
||||
cFmt = QTextCharFormat(dFmt)
|
||||
start = 0
|
||||
temp = text.replace("\n", nwUnicode.U_LSEP)
|
||||
for pos, fmt, data in tFmt:
|
||||
|
||||
# Insert buffer with previous format
|
||||
cursor.insertText(temp[start:pos], cFmt)
|
||||
|
||||
# Construct next format
|
||||
if fmt == self.FMT_B_B:
|
||||
cFmt.setFontWeight(self._bold)
|
||||
elif fmt == self.FMT_B_E:
|
||||
cFmt.setFontWeight(self._normal)
|
||||
elif fmt == self.FMT_I_B:
|
||||
cFmt.setFontItalic(True)
|
||||
elif fmt == self.FMT_I_E:
|
||||
cFmt.setFontItalic(False)
|
||||
elif fmt == self.FMT_D_B:
|
||||
cFmt.setFontStrikeOut(True)
|
||||
elif fmt == self.FMT_D_E:
|
||||
cFmt.setFontStrikeOut(False)
|
||||
elif fmt == self.FMT_U_B:
|
||||
cFmt.setFontUnderline(True)
|
||||
elif fmt == self.FMT_U_E:
|
||||
cFmt.setFontUnderline(False)
|
||||
elif fmt == self.FMT_M_B:
|
||||
cFmt.setBackground(self._theme.highlight)
|
||||
elif fmt == self.FMT_M_E:
|
||||
cFmt.setBackground(QtTransparent)
|
||||
elif fmt == self.FMT_SUP_B:
|
||||
cFmt.setVerticalAlignment(QtVAlignSuper)
|
||||
elif fmt == self.FMT_SUP_E:
|
||||
cFmt.setVerticalAlignment(QtVAlignNormal)
|
||||
elif fmt == self.FMT_SUB_B:
|
||||
cFmt.setVerticalAlignment(QtVAlignSub)
|
||||
elif fmt == self.FMT_SUB_E:
|
||||
cFmt.setVerticalAlignment(QtVAlignNormal)
|
||||
elif fmt == self.FMT_DL_B:
|
||||
cFmt.setForeground(self._theme.dialog)
|
||||
elif fmt == self.FMT_DL_E:
|
||||
cFmt.setForeground(self._theme.text)
|
||||
elif fmt == self.FMT_ADL_B:
|
||||
cFmt.setForeground(self._theme.altdialog)
|
||||
elif fmt == self.FMT_ADL_E:
|
||||
cFmt.setForeground(self._theme.text)
|
||||
elif fmt == self.FMT_FNOTE:
|
||||
xFmt = QTextCharFormat(self._cCode)
|
||||
xFmt.setVerticalAlignment(QtVAlignSuper)
|
||||
if data in self._footnotes:
|
||||
index = len(self._usedNotes) + 1
|
||||
self._usedNotes[data] = index
|
||||
xFmt.setAnchor(True)
|
||||
xFmt.setAnchorHref(f"#footnote_{index}")
|
||||
xFmt.setFontUnderline(True)
|
||||
cursor.insertText(f"[{index}]", xFmt)
|
||||
else:
|
||||
cursor.insertText("[ERR]", cFmt)
|
||||
|
||||
# Move pos for next pass
|
||||
start = pos
|
||||
|
||||
# Insert whatever is left in the buffer
|
||||
cursor.insertText(temp[start:], cFmt)
|
||||
|
||||
return
|
||||
|
||||
def _insertKeywords(self, text: str, cursor: QTextCursor) -> None:
|
||||
"""Apply Markdown formatting to keywords."""
|
||||
valid, bits, _ = self._project.index.scanThis("@"+text)
|
||||
if valid and bits:
|
||||
key = f"{self._localLookup(nwLabels.KEY_NAME[bits[0]])}: "
|
||||
cursor.insertText(key, self._cKeyword)
|
||||
if (num := len(bits)) > 1:
|
||||
if bits[0] == nwKeyWords.TAG_KEY:
|
||||
one, two = self._project.index.parseValue(bits[1])
|
||||
cFmt = QTextCharFormat(self._cTag)
|
||||
cFmt.setAnchor(True)
|
||||
cFmt.setAnchorNames([f"tag_{one}".lower()])
|
||||
cursor.insertText(one, cFmt)
|
||||
if two:
|
||||
cursor.insertText(" | ", self._cText)
|
||||
cursor.insertText(two, self._cOptional)
|
||||
else:
|
||||
for n, bit in enumerate(bits[1:], 2):
|
||||
cFmt = QTextCharFormat(self._cTag)
|
||||
cFmt.setFontUnderline(True)
|
||||
cFmt.setAnchor(True)
|
||||
cFmt.setAnchorHref(f"#tag_{bit}".lower())
|
||||
cursor.insertText(bit, cFmt)
|
||||
if n < num:
|
||||
cursor.insertText(", ", self._cText)
|
||||
return
|
||||
|
||||
def _genHeadStyle(self, hType: int, nHead: int, rFmt: QTextBlockFormat) -> T_TextStyle:
|
||||
"""Generate a heading style set."""
|
||||
mTop, mBottom = self._mHead.get(hType, (0.0, 0.0))
|
||||
|
||||
bFmt = QTextBlockFormat(rFmt)
|
||||
bFmt.setTopMargin(mTop)
|
||||
bFmt.setBottomMargin(mBottom)
|
||||
|
||||
cFmt = QTextCharFormat(self._cText if hType == self.T_TITLE else self._cHead)
|
||||
cFmt.setFontWeight(self._bold)
|
||||
cFmt.setFontPointSize(self._sHead.get(hType, 1.0))
|
||||
if nHead >= 0:
|
||||
cFmt.setAnchorNames([f"{self._handle}:T{nHead:04d}"])
|
||||
cFmt.setAnchor(True)
|
||||
|
||||
return bFmt, cFmt
|
||||
@@ -3,7 +3,8 @@ novelWriter – Project Tree Class
|
||||
================================
|
||||
|
||||
File History:
|
||||
Created: 2020-05-07 [0.4.5] NWTree
|
||||
Created: 2020-05-07 [0.4.5] NWTree
|
||||
Rewritten: 2024-11-16 [2.6b2] NWTree
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
@@ -30,10 +31,13 @@ from collections.abc import Iterable, Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
|
||||
from novelwriter.common import isHandle
|
||||
from novelwriter.constants import nwFiles
|
||||
from PyQt5.QtCore import QModelIndex
|
||||
|
||||
from novelwriter import SHARED
|
||||
from novelwriter.constants import nwFiles, nwLabels, trConst
|
||||
from novelwriter.core.item import NWItem
|
||||
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
|
||||
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
|
||||
from novelwriter.enum import nwChange, nwItemClass, nwItemLayout, nwItemType
|
||||
from novelwriter.error import logException
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -41,7 +45,7 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_DEPTH = 1000 # Cap of tree traversing for loops (recursion limit)
|
||||
MAX_DEPTH = 999 # Cap of tree traversing for loops (recursion limit)
|
||||
|
||||
|
||||
class NWTree:
|
||||
@@ -51,30 +55,51 @@ class NWTree:
|
||||
This class holds all the project items of the project as instances
|
||||
of NWItem.
|
||||
|
||||
For historical reasons, the order of the items is saved in a
|
||||
separate list from the items themselves, which are stored in a
|
||||
dictionary. This is somewhat redundant with the newer versions of
|
||||
Python, but is still practical as it's easier to update the item
|
||||
order as a list.
|
||||
|
||||
Each item has a handle, which is a random hex string of length 13.
|
||||
The handle is the name of the item everywhere in novelWriter, and is
|
||||
also used for file names.
|
||||
"""
|
||||
|
||||
__slots__ = ("_project", "_tree", "_order", "_roots", "_trash", "_changed")
|
||||
__slots__ = ("_project", "_model", "_items", "_nodes", "_trash")
|
||||
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
|
||||
self._project = project
|
||||
self._model = ProjectModel(self)
|
||||
self._items: dict[str, NWItem] = {}
|
||||
self._nodes: dict[str, ProjectNode] = {}
|
||||
self._trash = None
|
||||
logger.debug("Ready: NWTree")
|
||||
return
|
||||
|
||||
self._tree: dict[str, NWItem] = {} # Holds all the items of the project
|
||||
self._order: list[str] = [] # The order of the tree items in the tree view
|
||||
self._roots: dict[str, NWItem] = {} # The root items of the tree
|
||||
def __del__(self) -> None: # pragma: no cover
|
||||
logger.debug("Delete: NWTree")
|
||||
return
|
||||
|
||||
self._trash = None # The handle of the trash root folder
|
||||
self._changed = False # True if tree structure has changed
|
||||
def __len__(self) -> int:
|
||||
"""The number of items in the project."""
|
||||
return len(self._items)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""True if there are any items in the project."""
|
||||
return bool(self._items)
|
||||
|
||||
def __getitem__(self, tHandle: str | None) -> NWItem | None:
|
||||
"""Return a project item based on its handle. Returns None if
|
||||
the handle doesn't exist in the project.
|
||||
"""
|
||||
if tHandle and tHandle in self._items:
|
||||
return self._items[tHandle]
|
||||
logger.error("No tree item with handle '%s'", str(tHandle))
|
||||
return None
|
||||
|
||||
def __contains__(self, tHandle: str) -> bool:
|
||||
"""Checks if a handle exists in the tree."""
|
||||
return tHandle in self._items
|
||||
|
||||
def __iter__(self) -> Iterator[NWItem]:
|
||||
"""Iterate through project items."""
|
||||
for node in self._model.root.allChildren():
|
||||
yield node.item
|
||||
return
|
||||
|
||||
##
|
||||
@@ -82,9 +107,19 @@ class NWTree:
|
||||
##
|
||||
|
||||
@property
|
||||
def trashRoot(self) -> str | None:
|
||||
"""Return the handle of the trash folder, or None."""
|
||||
return self._trash
|
||||
def trash(self) -> ProjectNode | None:
|
||||
"""Return trash node, if it exists."""
|
||||
if self._trash:
|
||||
return self._trash
|
||||
return self._getTrashNode()
|
||||
|
||||
@property
|
||||
def model(self) -> ProjectModel:
|
||||
return self._model
|
||||
|
||||
@property
|
||||
def nodes(self) -> dict[str, ProjectNode]:
|
||||
return self._nodes
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
@@ -92,83 +127,91 @@ class NWTree:
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the item tree entirely."""
|
||||
self._tree = {}
|
||||
self._order = []
|
||||
self._roots = {}
|
||||
self._trash = None
|
||||
self._changed = False
|
||||
oldModel = self._model
|
||||
oldModel.clear()
|
||||
self._model = ProjectModel(self)
|
||||
self._items.clear()
|
||||
self._nodes.clear()
|
||||
self._trash = None
|
||||
oldModel.deleteLater()
|
||||
del oldModel
|
||||
return
|
||||
|
||||
def handles(self) -> list[str]:
|
||||
"""Returns a copy of the list of all the active handles."""
|
||||
return self._order.copy()
|
||||
def add(self, item: NWItem, pos: int = -1) -> bool:
|
||||
"""Add a project item into the project tree."""
|
||||
if pHandle := item.itemParent:
|
||||
if parent := self._nodes.get(pHandle):
|
||||
node = ProjectNode(item)
|
||||
index = self._model.indexFromNode(parent)
|
||||
self._model.insertChild(node, index, pos)
|
||||
self._nodes[item.itemHandle] = node
|
||||
self._items[item.itemHandle] = item
|
||||
self._itemChange(item, nwChange.CREATE)
|
||||
else:
|
||||
logger.error("Could not locate parent of '%s'", item.itemHandle)
|
||||
return False
|
||||
elif item.isRootType():
|
||||
node = ProjectNode(item)
|
||||
self._model.insertChild(node, QModelIndex(), pos)
|
||||
self._nodes[item.itemHandle] = node
|
||||
self._items[item.itemHandle] = item
|
||||
self._itemChange(item, nwChange.CREATE)
|
||||
else:
|
||||
logger.error("Invalid project item '%s'", item.itemHandle)
|
||||
return False
|
||||
return True
|
||||
|
||||
def remove(self, tHandle: str) -> bool:
|
||||
"""Remove an item from the project tree."""
|
||||
if (node := self._nodes.get(tHandle)) and tHandle in self._items:
|
||||
index = self._model.indexFromNode(node)
|
||||
if index.isValid() and self._model.removeChild(index.parent(), index.row()):
|
||||
self._itemChange(node.item, nwChange.DELETE)
|
||||
del self._nodes[tHandle]
|
||||
del self._items[tHandle]
|
||||
return True
|
||||
return False
|
||||
|
||||
@overload # pragma: no cover
|
||||
def create(self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
|
||||
itemClass: nwItemClass) -> str:
|
||||
def create(
|
||||
self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
|
||||
itemClass: nwItemClass, pos: int = -1
|
||||
) -> str:
|
||||
pass
|
||||
|
||||
@overload # pragma: no cover
|
||||
def create(self, label: str, parent: str | None, itemType: nwItemType,
|
||||
itemClass: nwItemClass = nwItemClass.NO_CLASS) -> str | None:
|
||||
def create(
|
||||
self, label: str, parent: str | None, itemType: nwItemType,
|
||||
itemClass: nwItemClass = nwItemClass.NO_CLASS, pos: int = -1
|
||||
) -> str | None:
|
||||
pass
|
||||
|
||||
def create(self, label, parent, itemType, itemClass=nwItemClass.NO_CLASS):
|
||||
def create(
|
||||
self, label: str, parent: str | None, itemType: nwItemType,
|
||||
itemClass: nwItemClass = nwItemClass.NO_CLASS, pos: int = -1,
|
||||
) -> str | None:
|
||||
"""Create a new item in the project tree, and return its handle.
|
||||
If the item cannot be added to the project because of an invalid
|
||||
parent, None is returned. For root elements, this cannot occur.
|
||||
"""
|
||||
parent = None if itemType == nwItemType.ROOT else parent
|
||||
if parent is None or parent in self._order:
|
||||
if parent is None or parent in self._nodes:
|
||||
tHandle = self._makeHandle()
|
||||
newItem = NWItem(self._project, tHandle)
|
||||
newItem.setName(label)
|
||||
newItem.setParent(parent)
|
||||
newItem.setType(itemType)
|
||||
newItem.setClass(itemClass)
|
||||
self.append(newItem)
|
||||
self.updateItemData(tHandle)
|
||||
return tHandle
|
||||
nwItem = NWItem(self._project, tHandle)
|
||||
nwItem.setName(label)
|
||||
nwItem.setParent(parent)
|
||||
nwItem.setType(itemType)
|
||||
nwItem.setClass(itemClass)
|
||||
if self.add(nwItem, pos):
|
||||
return tHandle
|
||||
return None
|
||||
|
||||
def append(self, nwItem: NWItem) -> bool:
|
||||
"""Add a new item to the end of the tree."""
|
||||
tHandle = nwItem.itemHandle
|
||||
pHandle = nwItem.itemParent
|
||||
|
||||
if not isHandle(tHandle):
|
||||
logger.warning("Invalid item handle '%s' detected, skipping", tHandle)
|
||||
return False
|
||||
|
||||
if tHandle in self._tree:
|
||||
logger.warning("Duplicate handle '%s' detected, skipping", tHandle)
|
||||
return False
|
||||
|
||||
logger.debug("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle))
|
||||
|
||||
if nwItem.isRootType():
|
||||
logger.debug("Item '%s' is a root item", str(tHandle))
|
||||
self._roots[tHandle] = nwItem
|
||||
if nwItem.itemClass == nwItemClass.TRASH:
|
||||
if self._trash is None:
|
||||
logger.debug("Item '%s' is the trash folder", str(tHandle))
|
||||
self._trash = tHandle
|
||||
else:
|
||||
logger.error("Only one trash folder allowed")
|
||||
return False
|
||||
|
||||
self._tree[tHandle] = nwItem
|
||||
self._order.append(tHandle)
|
||||
self._setTreeChanged(True)
|
||||
|
||||
return True
|
||||
|
||||
def duplicate(self, sHandle: str) -> NWItem | None:
|
||||
def duplicate(self, sHandle: str, pHandle: str | None, putAfter: bool) -> NWItem | None:
|
||||
"""Duplicate an item and set a new handle."""
|
||||
sItem = self.__getitem__(sHandle)
|
||||
if isinstance(sItem, NWItem):
|
||||
nItem = NWItem.duplicate(sItem, self._makeHandle())
|
||||
if self.append(nItem):
|
||||
if sNode := self._nodes.get(sHandle):
|
||||
nItem = NWItem.duplicate(sNode.item, self._makeHandle())
|
||||
nItem.setParent(pHandle)
|
||||
if self.add(nItem, (sNode.row() + 1) if putAfter else -1):
|
||||
logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle)
|
||||
return nItem
|
||||
return None
|
||||
@@ -177,23 +220,62 @@ class NWTree:
|
||||
"""Pack the content of the tree into a list of dictionaries of
|
||||
items. In the order defined by the _treeOrder list.
|
||||
"""
|
||||
tree = []
|
||||
for tHandle in self._order:
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem:
|
||||
tree.append(tItem.pack())
|
||||
return tree
|
||||
nodes = self._model.root.allChildren()
|
||||
if len(nodes) != len(self._nodes):
|
||||
logger.warning(
|
||||
"Model tree is inconsitent with nodes map, %d != %d",
|
||||
len(nodes), len(self._nodes)
|
||||
)
|
||||
return [node.item.pack() for node in nodes]
|
||||
|
||||
def unpack(self, data: list[dict]) -> None:
|
||||
"""Iterate through all items of a list and add them to the
|
||||
project tree.
|
||||
"""
|
||||
self.clear()
|
||||
items: dict[str, NWItem] = self._items.copy()
|
||||
for item in data:
|
||||
nwItem = NWItem(self._project, "") # Handle is set by unpack()
|
||||
nwItem = NWItem(self._project, "")
|
||||
if nwItem.unpack(item):
|
||||
self.append(nwItem)
|
||||
nwItem.saveInitialCount()
|
||||
items[nwItem.itemHandle] = nwItem
|
||||
|
||||
later = items
|
||||
self._model.beginInsertRows(self._model.index(0, 0), 0, 0)
|
||||
for _ in range(MAX_DEPTH):
|
||||
later = self._addItems(later)
|
||||
if len(later) == 0:
|
||||
break
|
||||
else:
|
||||
logger.error("Not all items could be added to project tree")
|
||||
|
||||
self._trash = self._getTrashNode()
|
||||
self._model.endInsertRows()
|
||||
self._model.layoutChanged.emit()
|
||||
|
||||
return
|
||||
|
||||
def refreshItems(self, items: list[str]) -> None:
|
||||
"""Refresh these items on the GUI. If they are an ordered range,
|
||||
also set the isRange flag to True.
|
||||
"""
|
||||
for tHandle in items:
|
||||
if node := self._nodes.get(tHandle):
|
||||
node.refresh()
|
||||
node.updateCount()
|
||||
indexS = self._model.indexFromNode(node, 0)
|
||||
indexE = self._model.indexFromNode(node, 3)
|
||||
self._model.dataChanged.emit(indexS, indexE)
|
||||
self._itemChange(node.item, nwChange.UPDATE)
|
||||
return
|
||||
|
||||
def refreshAllItems(self) -> None:
|
||||
"""Refresh all items in the tree."""
|
||||
for node in reversed(self._model.root.allChildren()):
|
||||
node.refresh()
|
||||
node.updateCount(propagate=False)
|
||||
self._model.root.refresh()
|
||||
self._model.root.updateCount(propagate=False)
|
||||
self._model.layoutChanged.emit()
|
||||
return
|
||||
|
||||
def checkConsistency(self, prefix: str) -> tuple[int, int]:
|
||||
@@ -205,29 +287,21 @@ class NWTree:
|
||||
mark recovered files.
|
||||
"""
|
||||
storage = self._project.storage
|
||||
files = set(storage.scanContent())
|
||||
for tHandle in self._order:
|
||||
if self.updateItemData(tHandle):
|
||||
logger.debug("Checking item '%s' ... OK", tHandle)
|
||||
files.discard(tHandle) # Remove it from the record
|
||||
else:
|
||||
logger.error("Checking item '%s' ... ERROR", tHandle)
|
||||
self.__delitem__(tHandle) # The file will be re-added as orphaned
|
||||
|
||||
orphans = len(files)
|
||||
remains = set(storage.scanContent()).difference(set(self._nodes.keys()))
|
||||
orphans = len(remains)
|
||||
if orphans == 0:
|
||||
logger.info("Checked project files: OK")
|
||||
return 0, 0
|
||||
|
||||
logger.warning("Found %d file(s) not tracked in project", orphans)
|
||||
recovered = 0
|
||||
for cHandle in files:
|
||||
for cHandle in remains:
|
||||
aDoc = storage.getDocument(cHandle)
|
||||
aDoc.readDocument(isOrphan=True)
|
||||
oName, oParent, oClass, oLayout = aDoc.getMeta()
|
||||
|
||||
oName = oName or cHandle
|
||||
oParent = oParent if oParent in self._order else None
|
||||
oParent = oParent if oParent in self._nodes else None
|
||||
oClass = oClass or nwItemClass.NOVEL
|
||||
oLayout = oLayout or nwItemLayout.NOTE
|
||||
|
||||
@@ -248,8 +322,7 @@ class NWTree:
|
||||
newItem.setType(nwItemType.FILE)
|
||||
newItem.setClass(oClass)
|
||||
newItem.setLayout(oLayout)
|
||||
if self.append(newItem):
|
||||
self.updateItemData(cHandle)
|
||||
if self.add(newItem):
|
||||
recovered += 1
|
||||
|
||||
return orphans, recovered
|
||||
@@ -263,38 +336,33 @@ class NWTree:
|
||||
if not (isinstance(contentPath, Path) and isinstance(runtimePath, Path)):
|
||||
return False
|
||||
|
||||
tocList = []
|
||||
tocLen = 0
|
||||
for tHandle in self._order:
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is None:
|
||||
continue
|
||||
|
||||
tFile = tHandle+".nwd"
|
||||
if (contentPath / tFile).is_file():
|
||||
entries = []
|
||||
maxLen = 0
|
||||
for node in self._model.root.allChildren():
|
||||
item = node.item
|
||||
file = f"{item.itemHandle}.nwd"
|
||||
if (contentPath / file).is_file():
|
||||
tocLine = "{0:<25s} {1:<9s} {2:<8s} {3:s}".format(
|
||||
str(Path("content") / tFile),
|
||||
tItem.itemClass.name,
|
||||
tItem.itemLayout.name,
|
||||
tItem.itemName,
|
||||
f"content/{file}",
|
||||
item.itemClass.name,
|
||||
item.itemLayout.name,
|
||||
item.itemName,
|
||||
)
|
||||
tocList.append(tocLine)
|
||||
tocLen = max(tocLen, len(tocLine))
|
||||
entries.append(tocLine)
|
||||
maxLen = max(maxLen, len(tocLine))
|
||||
|
||||
try:
|
||||
# Dump the text
|
||||
tocText = runtimePath / nwFiles.TOC_TXT
|
||||
with open(tocText, mode="w", encoding="utf-8") as outFile:
|
||||
outFile.write("\n")
|
||||
outFile.write("Table of Contents\n")
|
||||
outFile.write("=================\n")
|
||||
outFile.write("\n")
|
||||
outFile.write("{0:<25s} {1:<9s} {2:<8s} {3:s}\n".format(
|
||||
with open(runtimePath / nwFiles.TOC_TXT, mode="w", encoding="utf-8") as toc:
|
||||
toc.write("\n")
|
||||
toc.write("Table of Contents\n")
|
||||
toc.write("=================\n")
|
||||
toc.write("\n")
|
||||
toc.write("{0:<25s} {1:<9s} {2:<8s} {3:s}\n".format(
|
||||
"File Name", "Class", "Layout", "Document Label"
|
||||
))
|
||||
outFile.write("-"*max(tocLen, 62) + "\n")
|
||||
outFile.write("\n".join(tocList))
|
||||
outFile.write("\n")
|
||||
toc.write("-"*max(maxLen, 62) + "\n")
|
||||
toc.write("\n".join(entries))
|
||||
toc.write("\n")
|
||||
|
||||
except Exception:
|
||||
logger.error("Could not write ToC file")
|
||||
@@ -307,74 +375,46 @@ class NWTree:
|
||||
"""Loop over all entries and add up the word counts."""
|
||||
noteWords = 0
|
||||
novelWords = 0
|
||||
for tHandle in self._order:
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is None:
|
||||
continue
|
||||
if tItem.itemLayout == nwItemLayout.NO_LAYOUT:
|
||||
pass
|
||||
elif tItem.itemLayout == nwItemLayout.NOTE:
|
||||
noteWords += tItem.wordCount
|
||||
else:
|
||||
novelWords += tItem.wordCount
|
||||
for item in self._items.values():
|
||||
if item.itemLayout == nwItemLayout.NOTE:
|
||||
noteWords += item.wordCount
|
||||
elif item.itemLayout == nwItemLayout.DOCUMENT:
|
||||
novelWords += item.wordCount
|
||||
return novelWords, noteWords
|
||||
|
||||
##
|
||||
# Tree Item Methods
|
||||
##
|
||||
|
||||
def updateItemData(self, tHandle: str) -> bool:
|
||||
"""Update the root item handle of a given item. Returns True if
|
||||
a root was found and data updated, otherwise False.
|
||||
"""
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is None:
|
||||
return False
|
||||
|
||||
iItem = tItem
|
||||
for _ in range(MAX_DEPTH):
|
||||
if iItem.itemParent is None:
|
||||
tItem.setRoot(iItem.itemHandle)
|
||||
tItem.setClassDefaults(iItem.itemClass)
|
||||
return True
|
||||
else:
|
||||
iItem = self.__getitem__(iItem.itemParent)
|
||||
if iItem is None:
|
||||
return False
|
||||
else:
|
||||
raise RecursionError("Critical internal error")
|
||||
|
||||
def checkType(self, tHandle: str, itemType: nwItemType) -> bool:
|
||||
"""Check if item exists and is of the specified item type."""
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if not tItem:
|
||||
return False
|
||||
return tItem.itemType == itemType
|
||||
if tItem := self.__getitem__(tHandle):
|
||||
return tItem.itemType == itemType
|
||||
return False
|
||||
|
||||
def getItemPath(self, tHandle: str, asName: bool = False) -> list[str]:
|
||||
def itemPath(self, tHandle: str, asName: bool = False) -> list[str]:
|
||||
"""Iterate upwards in the tree until we find the item with
|
||||
parent None, the root item, and return the list of handles, or
|
||||
alternatively item names. We do this with a for loop with a
|
||||
maximum depth to make infinite loops impossible.
|
||||
"""
|
||||
tTree = []
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is not None:
|
||||
tTree.append(tItem.itemName if asName else tHandle)
|
||||
path = []
|
||||
if node := self._nodes.get(tHandle):
|
||||
for _ in range(MAX_DEPTH):
|
||||
if tItem.itemParent is None:
|
||||
return tTree
|
||||
if parent := node.parent():
|
||||
path.append(node.item.itemName if asName else tHandle)
|
||||
node = parent
|
||||
else:
|
||||
tHandle = tItem.itemParent
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is None:
|
||||
return tTree
|
||||
else:
|
||||
tTree.append(tItem.itemName if asName else tHandle)
|
||||
return path
|
||||
else:
|
||||
raise RecursionError("Critical internal error")
|
||||
logger.error("Max project tree depth reached")
|
||||
return path
|
||||
|
||||
return tTree
|
||||
def subTree(self, tHandle: str) -> list[str]:
|
||||
"""Get the subtree from a given handle."""
|
||||
if node := self._nodes.get(tHandle):
|
||||
return [child.item.itemHandle for child in node.allChildren()]
|
||||
return []
|
||||
|
||||
##
|
||||
# Tree Root Methods
|
||||
@@ -383,139 +423,80 @@ class NWTree:
|
||||
def rootClasses(self) -> set[nwItemClass]:
|
||||
"""Return a set of all root classes in use by the project."""
|
||||
rootClasses = set()
|
||||
for nwItem in self._roots.values():
|
||||
rootClasses.add(nwItem.itemClass)
|
||||
for node in self._model.root.children:
|
||||
rootClasses.add(node.item.itemClass)
|
||||
return rootClasses
|
||||
|
||||
def iterRoots(self, itemClass: nwItemClass | None) -> Iterable[tuple[str, NWItem]]:
|
||||
"""Iterate over all root items of a given class in order."""
|
||||
for tHandle in self._order:
|
||||
nwItem = self.__getitem__(tHandle)
|
||||
if isinstance(nwItem, NWItem) and nwItem.isRootType():
|
||||
if itemClass is None or nwItem.itemClass == itemClass:
|
||||
yield tHandle, nwItem
|
||||
for node in self._model.root.children:
|
||||
if node.item.isRootType():
|
||||
if itemClass is None or node.item.itemClass == itemClass:
|
||||
yield node.item.itemHandle, node.item
|
||||
return
|
||||
|
||||
def isTrash(self, tHandle: str) -> bool:
|
||||
"""Check if an item is in or is the trash folder."""
|
||||
tItem = self.__getitem__(tHandle)
|
||||
if tItem is None:
|
||||
return True
|
||||
if tItem.itemClass == nwItemClass.TRASH:
|
||||
return True
|
||||
if self._trash is not None:
|
||||
if tHandle == self._trash:
|
||||
return True
|
||||
elif tItem.itemParent == self._trash:
|
||||
return True
|
||||
elif tItem.itemRoot == self._trash:
|
||||
return True
|
||||
return False
|
||||
|
||||
def findRoot(self, itemClass: nwItemClass | None) -> str | None:
|
||||
"""Find the first root item for a given class."""
|
||||
for aRoot in self._roots:
|
||||
tItem = self.__getitem__(aRoot)
|
||||
if tItem is None:
|
||||
continue
|
||||
if itemClass == tItem.itemClass:
|
||||
return tItem.itemHandle
|
||||
for node in self._model.root.children:
|
||||
if node.item.itemClass == itemClass:
|
||||
return node.item.itemHandle
|
||||
return None
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setOrder(self, newOrder: list[str]) -> None:
|
||||
"""Reorders the tree based on a list of items."""
|
||||
tmpOrder = [tHandle for tHandle in newOrder if tHandle in self._tree]
|
||||
if not (len(tmpOrder) == len(newOrder) == len(self._order)):
|
||||
# Something is wrong, so let's debug it
|
||||
for tHandle in newOrder:
|
||||
if tHandle not in self._tree:
|
||||
logger.error("Handle '%s' in new tree order is not in old order", tHandle)
|
||||
for tHandle in self._order:
|
||||
if tHandle not in tmpOrder:
|
||||
logger.warning("Handle '%s' in old tree order is not in new order", tHandle)
|
||||
|
||||
# Save the temp list
|
||||
self._order = tmpOrder
|
||||
self._setTreeChanged(True)
|
||||
logger.debug("Project tree order updated")
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Special Methods
|
||||
##
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""The number of items in the project."""
|
||||
return len(self._order)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""True if there are any items in the project."""
|
||||
return bool(self._order)
|
||||
|
||||
def __getitem__(self, tHandle: str | None) -> NWItem | None:
|
||||
"""Return a project item based on its handle. Returns None if
|
||||
the handle doesn't exist in the project.
|
||||
"""
|
||||
if tHandle and tHandle in self._tree:
|
||||
return self._tree[tHandle]
|
||||
logger.error("No tree item with handle '%s'", str(tHandle))
|
||||
return None
|
||||
|
||||
def __delitem__(self, tHandle: str) -> None:
|
||||
"""Remove an item from the internal lists and dictionaries."""
|
||||
if tHandle in self._order and tHandle in self._tree:
|
||||
self._order.remove(tHandle)
|
||||
del self._tree[tHandle]
|
||||
else:
|
||||
logger.warning("Failed to delete item '%s': item not found", tHandle)
|
||||
return
|
||||
|
||||
if tHandle in self._roots:
|
||||
del self._roots[tHandle]
|
||||
if tHandle == self._trash:
|
||||
self._trash = None
|
||||
|
||||
self._setTreeChanged(True)
|
||||
|
||||
return
|
||||
|
||||
def __contains__(self, tHandle: str) -> bool:
|
||||
"""Checks if a handle exists in the tree."""
|
||||
return tHandle in self._order
|
||||
|
||||
def __iter__(self) -> Iterator[NWItem]:
|
||||
"""Iterate through project items."""
|
||||
for tHandle in self._order:
|
||||
tItem = self._tree.get(tHandle)
|
||||
if isinstance(tItem, NWItem):
|
||||
yield tItem
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _setTreeChanged(self, state: bool) -> None:
|
||||
"""Set the changed flag to state, and if being set to True,
|
||||
propagate that state change to the parent NWProject class.
|
||||
"""
|
||||
self._changed = state
|
||||
if state:
|
||||
self._project.setProjectChanged(True)
|
||||
def _itemChange(self, item: NWItem, change: nwChange) -> None:
|
||||
"""Signal item change and notify project."""
|
||||
tHandle = item.itemHandle
|
||||
logger.debug("Item change: %s -> %s", tHandle, change.name)
|
||||
self._project.setProjectChanged(True)
|
||||
SHARED.emitProjectItemChanged(self._project, tHandle, change)
|
||||
if item.isRootType():
|
||||
SHARED.emitRootFolderChanged(self._project, tHandle, change)
|
||||
return
|
||||
|
||||
def _getTrashNode(self) -> ProjectNode | None:
|
||||
"""Get the trash node. If it doesn't exist, create it."""
|
||||
for node in self._model.root.children:
|
||||
if node.item.itemClass == nwItemClass.TRASH:
|
||||
return node
|
||||
label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
|
||||
if handle := self.create(label, None, nwItemType.ROOT, nwItemClass.TRASH):
|
||||
return self._nodes.get(handle)
|
||||
return None
|
||||
|
||||
def _addItems(self, items: dict[str, NWItem]) -> dict[str, NWItem]:
|
||||
"""Add a dictionary of items to the project tree. Returns a new
|
||||
dictionary of items that could not be added yet, but can be.
|
||||
"""
|
||||
remains: dict[str, NWItem] = {}
|
||||
for handle, item in items.items():
|
||||
if pHandle := item.itemParent:
|
||||
if parent := self._nodes.get(pHandle):
|
||||
node = ProjectNode(item)
|
||||
parent.addChild(node)
|
||||
parent.updateCount()
|
||||
self._items[handle] = item
|
||||
self._nodes[handle] = node
|
||||
elif pHandle in items:
|
||||
remains[handle] = item
|
||||
logger.warning("Item '%s' found before its parent", handle)
|
||||
elif item.isRootType():
|
||||
node = ProjectNode(item)
|
||||
self._model.root.addChild(node)
|
||||
self._model.root.updateCount()
|
||||
self._items[handle] = item
|
||||
self._nodes[handle] = node
|
||||
return remains
|
||||
|
||||
def _makeHandle(self) -> str:
|
||||
"""Generate a unique item handle. In the event that the key
|
||||
already exists, generate a new one.
|
||||
"""
|
||||
logger.debug("Generating new handle")
|
||||
handle = f"{random.getrandbits(52):013x}"
|
||||
if handle in self._tree:
|
||||
if handle in self._items:
|
||||
logger.warning("Duplicate handle encountered! Retrying ...")
|
||||
handle = self._makeHandle()
|
||||
|
||||
|
||||
@@ -162,23 +162,13 @@ class GuiDocMerge(NDialog):
|
||||
self._data = {}
|
||||
self._data["sHandle"] = sHandle
|
||||
self._data["origItems"] = itemList
|
||||
|
||||
self.listBox.clear()
|
||||
for tHandle in itemList:
|
||||
nwItem = SHARED.project.tree[tHandle]
|
||||
if nwItem is None or not nwItem.isFileType():
|
||||
continue
|
||||
|
||||
itemIcon = SHARED.theme.getItemIcon(
|
||||
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, nwItem.mainHeading
|
||||
)
|
||||
|
||||
newItem = QListWidgetItem()
|
||||
newItem.setIcon(itemIcon)
|
||||
newItem.setText(nwItem.itemName)
|
||||
newItem.setData(self.D_HANDLE, tHandle)
|
||||
newItem.setCheckState(Qt.CheckState.Checked)
|
||||
|
||||
self.listBox.addItem(newItem)
|
||||
|
||||
if (nwItem := SHARED.project.tree[tHandle]) and nwItem.isFileType():
|
||||
item = QListWidgetItem()
|
||||
item.setIcon(nwItem.getMainIcon())
|
||||
item.setText(nwItem.itemName)
|
||||
item.setData(self.D_HANDLE, tHandle)
|
||||
item.setCheckState(Qt.CheckState.Checked)
|
||||
self.listBox.addItem(item)
|
||||
return
|
||||
|
||||
@@ -34,7 +34,7 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import describeFont
|
||||
from novelwriter.common import compact, describeFont, uniqueCompact
|
||||
from novelwriter.constants import nwUnicode
|
||||
from novelwriter.dialogs.quotes import GuiQuoteSelect
|
||||
from novelwriter.extensions.configlayout import NColourLabel, NScrollableForm
|
||||
@@ -133,7 +133,7 @@ class GuiPreferences(NDialog):
|
||||
"""Build the settings form."""
|
||||
section = 0
|
||||
iSz = SHARED.theme.baseIconSize
|
||||
boxFixed = 5*SHARED.theme.textNWidth
|
||||
boxFixed = 6*SHARED.theme.textNWidth
|
||||
minWidth = CONFIG.pxInt(200)
|
||||
fontWidth = CONFIG.pxInt(162)
|
||||
|
||||
@@ -543,33 +543,6 @@ class GuiPreferences(NDialog):
|
||||
self.tr("Applies to the selected quote styles.")
|
||||
)
|
||||
|
||||
self.allowOpenDial = NSwitch(self)
|
||||
self.allowOpenDial.setChecked(CONFIG.allowOpenDial)
|
||||
self.mainForm.addRow(
|
||||
self.tr("Allow open-ended dialogue"), self.allowOpenDial,
|
||||
self.tr("Highlight dialogue line with no closing quote.")
|
||||
)
|
||||
|
||||
self.narratorBreak = QLineEdit(self)
|
||||
self.narratorBreak.setMaxLength(1)
|
||||
self.narratorBreak.setFixedWidth(boxFixed)
|
||||
self.narratorBreak.setAlignment(QtAlignCenter)
|
||||
self.narratorBreak.setText(CONFIG.narratorBreak)
|
||||
self.mainForm.addRow(
|
||||
self.tr("Dialogue narrator break symbol"), self.narratorBreak,
|
||||
self.tr("Symbol to indicate injected narrator break.")
|
||||
)
|
||||
|
||||
self.dialogLine = QLineEdit(self)
|
||||
self.dialogLine.setMaxLength(1)
|
||||
self.dialogLine.setFixedWidth(boxFixed)
|
||||
self.dialogLine.setAlignment(QtAlignCenter)
|
||||
self.dialogLine.setText(CONFIG.dialogLine)
|
||||
self.mainForm.addRow(
|
||||
self.tr("Dialogue line symbol"), self.dialogLine,
|
||||
self.tr("Lines starting with this symbol are dialogue.")
|
||||
)
|
||||
|
||||
self.altDialogOpen = QLineEdit(self)
|
||||
self.altDialogOpen.setMaxLength(4)
|
||||
self.altDialogOpen.setFixedWidth(boxFixed)
|
||||
@@ -587,6 +560,43 @@ class GuiPreferences(NDialog):
|
||||
self.tr("Custom highlighting of dialogue text.")
|
||||
)
|
||||
|
||||
self.allowOpenDial = NSwitch(self)
|
||||
self.allowOpenDial.setChecked(CONFIG.allowOpenDial)
|
||||
self.mainForm.addRow(
|
||||
self.tr("Allow open-ended dialogue"), self.allowOpenDial,
|
||||
self.tr("Highlight dialogue line with no closing quote.")
|
||||
)
|
||||
|
||||
self.dialogLine = QLineEdit(self)
|
||||
self.dialogLine.setMaxLength(4)
|
||||
self.dialogLine.setFixedWidth(boxFixed)
|
||||
self.dialogLine.setAlignment(QtAlignCenter)
|
||||
self.dialogLine.setText(CONFIG.dialogLine)
|
||||
self.mainForm.addRow(
|
||||
self.tr("Dialogue line symbols"), self.dialogLine,
|
||||
self.tr("Lines starting with any of these symbols are dialogue.")
|
||||
)
|
||||
|
||||
self.narratorBreak = QLineEdit(self)
|
||||
self.narratorBreak.setMaxLength(1)
|
||||
self.narratorBreak.setFixedWidth(boxFixed)
|
||||
self.narratorBreak.setAlignment(QtAlignCenter)
|
||||
self.narratorBreak.setText(CONFIG.narratorBreak)
|
||||
self.mainForm.addRow(
|
||||
self.tr("Narrator break symbol"), self.narratorBreak,
|
||||
self.tr("Symbol to indicate a narrator break in dialogue")
|
||||
)
|
||||
|
||||
self.narratorDialog = QLineEdit(self)
|
||||
self.narratorDialog.setMaxLength(1)
|
||||
self.narratorDialog.setFixedWidth(boxFixed)
|
||||
self.narratorDialog.setAlignment(QtAlignCenter)
|
||||
self.narratorDialog.setText(CONFIG.narratorDialog)
|
||||
self.mainForm.addRow(
|
||||
self.tr("Alternating dialogue/narration symbol"), self.narratorDialog,
|
||||
self.tr("Alternates dialogue highlighting within any paragraph.")
|
||||
)
|
||||
|
||||
self.highlightEmph = NSwitch(self)
|
||||
self.highlightEmph.setChecked(CONFIG.highlightEmph)
|
||||
self.mainForm.addRow(
|
||||
@@ -952,17 +962,19 @@ class GuiPreferences(NDialog):
|
||||
# Text Highlighting
|
||||
dialogueStyle = self.dialogStyle.currentData()
|
||||
allowOpenDial = self.allowOpenDial.isChecked()
|
||||
narratorBreak = self.narratorBreak.text()
|
||||
dialogueLine = self.dialogLine.text()
|
||||
altDialogOpen = self.altDialogOpen.text()
|
||||
altDialogClose = self.altDialogClose.text()
|
||||
dialogueLine = uniqueCompact(self.dialogLine.text())
|
||||
narratorBreak = self.narratorBreak.text().strip()
|
||||
narratorDialog = self.narratorDialog.text().strip()
|
||||
altDialogOpen = compact(self.altDialogOpen.text())
|
||||
altDialogClose = compact(self.altDialogClose.text())
|
||||
highlightEmph = self.highlightEmph.isChecked()
|
||||
showMultiSpaces = self.showMultiSpaces.isChecked()
|
||||
|
||||
updateSyntax |= CONFIG.dialogStyle != dialogueStyle
|
||||
updateSyntax |= CONFIG.allowOpenDial != allowOpenDial
|
||||
updateSyntax |= CONFIG.narratorBreak != narratorBreak
|
||||
updateSyntax |= CONFIG.dialogLine != dialogueLine
|
||||
updateSyntax |= CONFIG.narratorBreak != narratorBreak
|
||||
updateSyntax |= CONFIG.narratorDialog != narratorDialog
|
||||
updateSyntax |= CONFIG.altDialogOpen != altDialogOpen
|
||||
updateSyntax |= CONFIG.altDialogClose != altDialogClose
|
||||
updateSyntax |= CONFIG.highlightEmph != highlightEmph
|
||||
@@ -970,8 +982,9 @@ class GuiPreferences(NDialog):
|
||||
|
||||
CONFIG.dialogStyle = dialogueStyle
|
||||
CONFIG.allowOpenDial = allowOpenDial
|
||||
CONFIG.narratorBreak = narratorBreak
|
||||
CONFIG.dialogLine = dialogueLine
|
||||
CONFIG.narratorBreak = narratorBreak
|
||||
CONFIG.narratorDialog = narratorDialog
|
||||
CONFIG.altDialogOpen = altDialogOpen
|
||||
CONFIG.altDialogClose = altDialogClose
|
||||
CONFIG.highlightEmph = highlightEmph
|
||||
@@ -983,8 +996,8 @@ class GuiPreferences(NDialog):
|
||||
CONFIG.doReplaceDQuote = self.doReplaceDQuote.isChecked()
|
||||
CONFIG.doReplaceDash = self.doReplaceDash.isChecked()
|
||||
CONFIG.doReplaceDots = self.doReplaceDots.isChecked()
|
||||
CONFIG.fmtPadBefore = self.fmtPadBefore.text().strip()
|
||||
CONFIG.fmtPadAfter = self.fmtPadAfter.text().strip()
|
||||
CONFIG.fmtPadBefore = uniqueCompact(self.fmtPadBefore.text())
|
||||
CONFIG.fmtPadAfter = uniqueCompact(self.fmtPadAfter.text())
|
||||
CONFIG.fmtPadThin = self.fmtPadThin.isChecked()
|
||||
|
||||
# Quotation Style
|
||||
|
||||
@@ -35,7 +35,7 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import simplified
|
||||
from novelwriter.common import qtLambda, simplified
|
||||
from novelwriter.constants import nwLabels, trConst
|
||||
from novelwriter.core.status import NWStatus, StatusEntry
|
||||
from novelwriter.enum import nwStatusShape
|
||||
@@ -58,7 +58,7 @@ class GuiProjectSettings(NDialog):
|
||||
PAGE_IMPORT = 2
|
||||
PAGE_REPLACE = 3
|
||||
|
||||
newProjectSettingsReady = pyqtSignal(bool)
|
||||
newProjectSettingsReady = pyqtSignal()
|
||||
|
||||
def __init__(self, parent: QWidget, gotoPage: int = PAGE_SETTINGS) -> None:
|
||||
super().__init__(parent=parent)
|
||||
@@ -175,7 +175,7 @@ class GuiProjectSettings(NDialog):
|
||||
projAuthor = self.settingsPage.projAuthor.text()
|
||||
projLang = self.settingsPage.projLang.currentData()
|
||||
spellLang = self.settingsPage.spellLang.currentData()
|
||||
doBackup = not self.settingsPage.doBackup.isChecked()
|
||||
doBackup = not self.settingsPage.noBackup.isChecked()
|
||||
|
||||
project.data.setName(projName)
|
||||
project.data.setAuthor(projAuthor)
|
||||
@@ -183,23 +183,19 @@ class GuiProjectSettings(NDialog):
|
||||
project.data.setSpellLang(spellLang)
|
||||
project.setProjectLang(projLang)
|
||||
|
||||
rebuildTrees = False
|
||||
|
||||
if self.statusPage.changed:
|
||||
logger.debug("Updating status labels")
|
||||
project.data.itemStatus.update(self.statusPage.getNewList())
|
||||
rebuildTrees = True
|
||||
project.updateStatus("s", self.statusPage.getNewList())
|
||||
|
||||
if self.importPage.changed:
|
||||
logger.debug("Updating importance labels")
|
||||
project.data.itemImport.update(self.importPage.getNewList())
|
||||
rebuildTrees = True
|
||||
project.updateStatus("i", self.importPage.getNewList())
|
||||
|
||||
if self.replacePage.changed:
|
||||
logger.debug("Updating auto-replace settings")
|
||||
project.data.setAutoReplace(self.replacePage.getNewList())
|
||||
|
||||
self.newProjectSettingsReady.emit(rebuildTrees)
|
||||
self.newProjectSettingsReady.emit()
|
||||
QApplication.processEvents()
|
||||
self.close()
|
||||
|
||||
@@ -289,10 +285,10 @@ class _SettingsPage(NScrollableForm):
|
||||
self.spellLang.setCurrentIndex(idx)
|
||||
|
||||
# Backup on Close
|
||||
self.doBackup = NSwitch(self)
|
||||
self.doBackup.setChecked(not data.doBackup)
|
||||
self.noBackup = NSwitch(self)
|
||||
self.noBackup.setChecked(not data.doBackup)
|
||||
self.addRow(
|
||||
self.tr("Disable backup on close"), self.doBackup,
|
||||
self.tr("Disable backup on close"), self.noBackup,
|
||||
self.tr("Overrides main preferences.")
|
||||
)
|
||||
|
||||
@@ -369,10 +365,10 @@ class _StatusPage(NFixedPage):
|
||||
self.delButton.clicked.connect(self._delItem)
|
||||
|
||||
self.upButton = NIconToolButton(self, iSz, "up")
|
||||
self.upButton.clicked.connect(lambda: self._moveItem(-1))
|
||||
self.upButton.clicked.connect(qtLambda(self._moveItem, -1))
|
||||
|
||||
self.dnButton = NIconToolButton(self, iSz, "down")
|
||||
self.dnButton.clicked.connect(lambda: self._moveItem(1))
|
||||
self.dnButton.clicked.connect(qtLambda(self._moveItem, 1))
|
||||
|
||||
# Edit Form
|
||||
self.editName = QLineEdit(self)
|
||||
|
||||
@@ -75,6 +75,13 @@ class nwTrinary(Enum):
|
||||
POSITIVE = 1
|
||||
|
||||
|
||||
class nwChange(Enum):
|
||||
|
||||
CREATE = 0
|
||||
UPDATE = 1
|
||||
DELETE = 2
|
||||
|
||||
|
||||
class nwDocMode(Enum):
|
||||
|
||||
VIEW = 0
|
||||
@@ -137,6 +144,7 @@ class nwDocInsert(Enum):
|
||||
VSPACE_M = 9
|
||||
LIPSUM = 10
|
||||
FOOTNOTE = 11
|
||||
LINE_BRK = 12
|
||||
|
||||
|
||||
class nwView(Enum):
|
||||
@@ -157,35 +165,40 @@ class nwFocus(Enum):
|
||||
|
||||
class nwOutline(Enum):
|
||||
|
||||
TITLE = 0
|
||||
LEVEL = 1
|
||||
LABEL = 2
|
||||
LINE = 3
|
||||
CCOUNT = 4
|
||||
WCOUNT = 5
|
||||
PCOUNT = 6
|
||||
POV = 7
|
||||
FOCUS = 8
|
||||
CHAR = 9
|
||||
PLOT = 10
|
||||
TIME = 11
|
||||
WORLD = 12
|
||||
OBJECT = 13
|
||||
ENTITY = 14
|
||||
CUSTOM = 15
|
||||
SYNOP = 16
|
||||
TITLE = 0
|
||||
LEVEL = 1
|
||||
LABEL = 2
|
||||
LINE = 3
|
||||
STATUS = 4
|
||||
CCOUNT = 5
|
||||
WCOUNT = 6
|
||||
PCOUNT = 7
|
||||
POV = 8
|
||||
FOCUS = 9
|
||||
CHAR = 10
|
||||
PLOT = 11
|
||||
TIME = 12
|
||||
WORLD = 13
|
||||
OBJECT = 14
|
||||
ENTITY = 15
|
||||
CUSTOM = 16
|
||||
STORY = 17
|
||||
MENTION = 18
|
||||
SYNOP = 19
|
||||
|
||||
|
||||
class nwBuildFmt(Enum):
|
||||
|
||||
ODT = 0
|
||||
FODT = 1
|
||||
HTML = 2
|
||||
NWD = 3
|
||||
STD_MD = 4
|
||||
EXT_MD = 5
|
||||
J_HTML = 6
|
||||
J_NWD = 7
|
||||
DOCX = 2
|
||||
PDF = 3
|
||||
HTML = 4
|
||||
STD_MD = 5
|
||||
EXT_MD = 6
|
||||
NWD = 7
|
||||
J_HTML = 8
|
||||
J_NWD = 9
|
||||
|
||||
|
||||
class nwStatusShape(Enum):
|
||||
|
||||
@@ -27,14 +27,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QColor, QFont, QPalette
|
||||
from PyQt5.QtGui import QColor, QFont, QPalette, QPixmap
|
||||
from PyQt5.QtWidgets import (
|
||||
QAbstractButton, QFrame, QHBoxLayout, QLabel, QLayout, QScrollArea,
|
||||
QVBoxLayout, QWidget
|
||||
)
|
||||
|
||||
from novelwriter import CONFIG
|
||||
from novelwriter.types import QtScrollAsNeeded
|
||||
|
||||
DEFAULT_SCALE = 0.9
|
||||
|
||||
@@ -76,8 +76,8 @@ class NScrollablePage(QScrollArea):
|
||||
self._widget = QWidget(self)
|
||||
self.setWidget(self._widget)
|
||||
self.setWidgetResizable(True)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
|
||||
self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
|
||||
self.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
self.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
return
|
||||
@@ -113,8 +113,8 @@ class NScrollableForm(QScrollArea):
|
||||
|
||||
self.setWidget(self._widget)
|
||||
self.setWidgetResizable(True)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
|
||||
self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
|
||||
self.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
self.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
|
||||
@@ -180,9 +180,16 @@ class NScrollableForm(QScrollArea):
|
||||
self._sections[identifier] = qLabel
|
||||
return
|
||||
|
||||
def addRow(self, label: str, widget: QWidget | list[QWidget], helpText: str = "",
|
||||
unit: str | None = None, button: QWidget | None = None, editable: str | None = None,
|
||||
stretch: tuple[int, int] = (1, 0)) -> None:
|
||||
def addRow(
|
||||
self,
|
||||
label: str | None,
|
||||
widget: QWidget | list[QWidget | QPixmap | int],
|
||||
helpText: str = "",
|
||||
unit: str | None = None,
|
||||
button: QWidget | None = None,
|
||||
editable: str | None = None,
|
||||
stretch: tuple[int, int] = (1, 0),
|
||||
) -> None:
|
||||
"""Add a label and a widget as a new row of the form."""
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(CONFIG.pxInt(12))
|
||||
@@ -191,13 +198,20 @@ class NScrollableForm(QScrollArea):
|
||||
wBox = QHBoxLayout()
|
||||
wBox.setContentsMargins(0, 0, 0, 0)
|
||||
for item in widget:
|
||||
wBox.addWidget(item)
|
||||
if isinstance(item, QWidget):
|
||||
wBox.addWidget(item)
|
||||
elif isinstance(item, QPixmap):
|
||||
icon = QLabel(self)
|
||||
icon.setPixmap(item)
|
||||
wBox.addWidget(icon)
|
||||
elif isinstance(item, int):
|
||||
wBox.addSpacing(CONFIG.pxInt(item))
|
||||
qWidget = QWidget(self)
|
||||
qWidget.setLayout(wBox)
|
||||
else:
|
||||
qWidget = widget
|
||||
|
||||
qLabel = QLabel(label, self)
|
||||
qLabel = QLabel(label or "", self)
|
||||
qLabel.setIndent(self._indent)
|
||||
qLabel.setBuddy(qWidget)
|
||||
|
||||
@@ -230,7 +244,8 @@ class NScrollableForm(QScrollArea):
|
||||
row.addWidget(qWidget, stretch[1])
|
||||
|
||||
self._layout.addLayout(row)
|
||||
self._index[label.strip()] = qWidget
|
||||
if label:
|
||||
self._index[label.strip()] = qWidget
|
||||
self._first = False
|
||||
|
||||
return
|
||||
|
||||
@@ -136,9 +136,21 @@ class NSpinBox(QSpinBox):
|
||||
|
||||
class NDoubleSpinBox(QDoubleSpinBox):
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
min: float = 0.0,
|
||||
max: float = 15.0,
|
||||
step: float = 0.1,
|
||||
prec: int = 2,
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setMinimum(min)
|
||||
self.setMaximum(max)
|
||||
self.setSingleStep(step)
|
||||
self.setDecimals(prec)
|
||||
return
|
||||
|
||||
def wheelEvent(self, event: QWheelEvent) -> None:
|
||||
|
||||
@@ -52,7 +52,7 @@ class NPagedSideBar(QToolBar):
|
||||
|
||||
self._labelCol = None
|
||||
self._spacerHeight = self.fontMetrics().height() // 2
|
||||
self._buttons: dict[int, _NPagedToolButton] = {}
|
||||
self._buttons: dict[int, _PagedToolButton] = {}
|
||||
|
||||
self._group = QButtonGroup(self)
|
||||
self._group.setExclusive(True)
|
||||
@@ -67,7 +67,7 @@ class NPagedSideBar(QToolBar):
|
||||
|
||||
return
|
||||
|
||||
def button(self, buttonId: int) -> _NPagedToolButton:
|
||||
def button(self, buttonId: int) -> _PagedToolButton:
|
||||
"""Return a specific button."""
|
||||
return self._buttons[buttonId]
|
||||
|
||||
@@ -85,7 +85,7 @@ class NPagedSideBar(QToolBar):
|
||||
|
||||
def addButton(self, text: str, buttonId: int = -1) -> QAction:
|
||||
"""Add a new button to the toolbar."""
|
||||
button = _NPagedToolButton(self)
|
||||
button = _PagedToolButton(self)
|
||||
button.setText(text)
|
||||
|
||||
action = self.insertWidget(self._stretchAction, button)
|
||||
@@ -113,7 +113,7 @@ class NPagedSideBar(QToolBar):
|
||||
return
|
||||
|
||||
|
||||
class _NPagedToolButton(QToolButton):
|
||||
class _PagedToolButton(QToolButton):
|
||||
|
||||
__slots__ = ("_bH", "_tM", "_lM", "_cR", "_aH")
|
||||
|
||||
@@ -154,7 +154,7 @@ class _NPagedToolButton(QToolButton):
|
||||
height = self.height()
|
||||
palette = self.palette()
|
||||
|
||||
if opt.state & QtMouseOver == QtMouseOver:
|
||||
if opt.state & QtMouseOver == QtMouseOver: # pragma: no cover
|
||||
backCol = palette.base()
|
||||
paint.setBrush(backCol)
|
||||
paint.setOpacity(0.75)
|
||||
|
||||
155
novelwriter/formats/shared.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
novelWriter – Formats Shared
|
||||
============================
|
||||
|
||||
File History:
|
||||
Created: 2024-10-21 [2.6b1]
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from enum import Flag, IntEnum
|
||||
|
||||
from PyQt5.QtGui import QColor
|
||||
|
||||
ESCAPES = {r"\*": "*", r"\~": "~", r"\_": "_", r"\[": "[", r"\]": "]", r"\ ": ""}
|
||||
RX_ESC = re.compile("|".join([re.escape(k) for k in ESCAPES.keys()]), flags=re.DOTALL)
|
||||
|
||||
|
||||
def stripEscape(text: str) -> str:
|
||||
"""Strip escaped Markdown characters from paragraph text."""
|
||||
if "\\" in text:
|
||||
return RX_ESC.sub(lambda x: ESCAPES[x.group(0)], text)
|
||||
return text
|
||||
|
||||
|
||||
class TextDocumentTheme:
|
||||
"""Default document theme."""
|
||||
|
||||
text: QColor = QColor(0, 0, 0)
|
||||
highlight: QColor = QColor(255, 255, 166)
|
||||
head: QColor = QColor(66, 113, 174)
|
||||
link: QColor = QColor(66, 113, 174)
|
||||
comment: QColor = QColor(100, 100, 100)
|
||||
note: QColor = QColor(129, 55, 9)
|
||||
code: QColor = QColor(66, 113, 174)
|
||||
modifier: QColor = QColor(129, 55, 9)
|
||||
keyword: QColor = QColor(245, 135, 31)
|
||||
tag: QColor = QColor(66, 113, 174)
|
||||
optional: QColor = QColor(66, 113, 174)
|
||||
dialog: QColor = QColor(66, 113, 174)
|
||||
altdialog: QColor = QColor(129, 55, 9)
|
||||
|
||||
|
||||
# Enums
|
||||
# =====
|
||||
|
||||
class TextFmt(IntEnum):
|
||||
"""Text Format.
|
||||
|
||||
An enum indicating the beginning or end of a text format region.
|
||||
They must be paired with a position, and apply to locations in a
|
||||
text block.
|
||||
"""
|
||||
|
||||
B_B = 1 # Begin bold
|
||||
B_E = 2 # End bold
|
||||
I_B = 3 # Begin italics
|
||||
I_E = 4 # End italics
|
||||
D_B = 5 # Begin strikeout
|
||||
D_E = 6 # End strikeout
|
||||
U_B = 7 # Begin underline
|
||||
U_E = 8 # End underline
|
||||
M_B = 9 # Begin mark
|
||||
M_E = 10 # End mark
|
||||
SUP_B = 11 # Begin superscript
|
||||
SUP_E = 12 # End superscript
|
||||
SUB_B = 13 # Begin subscript
|
||||
SUB_E = 14 # End subscript
|
||||
COL_B = 15 # Begin colour
|
||||
COL_E = 16 # End colour
|
||||
ANM_B = 17 # Begin anchor name
|
||||
ANM_E = 18 # End anchor name
|
||||
ARF_B = 19 # Begin anchor link
|
||||
ARF_E = 20 # End anchor link
|
||||
HRF_B = 21 # Begin href link
|
||||
HRF_E = 22 # End href link
|
||||
FNOTE = 23 # Footnote marker
|
||||
FIELD = 24 # Data field
|
||||
STRIP = 25 # Strip the format code
|
||||
|
||||
|
||||
class BlockTyp(IntEnum):
|
||||
"""Text Block Type.
|
||||
|
||||
An enum indicating the type of a text block.
|
||||
"""
|
||||
|
||||
EMPTY = 1 # Empty line (new paragraph)
|
||||
TITLE = 2 # Title
|
||||
HEAD1 = 3 # Heading 1
|
||||
HEAD2 = 4 # Heading 2
|
||||
HEAD3 = 5 # Heading 3
|
||||
HEAD4 = 6 # Heading 4
|
||||
TEXT = 7 # Text line
|
||||
SEP = 8 # Scene separator
|
||||
SKIP = 9 # Paragraph break
|
||||
SUMMARY = 10 # Synopsis/short comment
|
||||
NOTE = 11 # Note
|
||||
COMMENT = 12 # Comment
|
||||
KEYWORD = 13 # Tag/reference keywords
|
||||
|
||||
|
||||
class BlockFmt(Flag):
|
||||
"""Text Block Format.
|
||||
|
||||
An enum of flags that can be combined to format a text block.
|
||||
"""
|
||||
|
||||
NONE = 0x0000 # No special style
|
||||
LEFT = 0x0001 # Left aligned
|
||||
RIGHT = 0x0002 # Right aligned
|
||||
CENTRE = 0x0004 # Centred
|
||||
JUSTIFY = 0x0008 # Justified
|
||||
PBB = 0x0010 # Page break before
|
||||
PBA = 0x0020 # Page break after
|
||||
Z_TOP = 0x0040 # Zero top margin
|
||||
Z_BTM = 0x0080 # Zero bottom margin
|
||||
IND_L = 0x0100 # Left indentation
|
||||
IND_R = 0x0200 # Right indentation
|
||||
IND_T = 0x0400 # Text indentation
|
||||
|
||||
# Masks
|
||||
ALIGNED = LEFT | RIGHT | CENTRE | JUSTIFY
|
||||
|
||||
|
||||
# Types
|
||||
# =====
|
||||
|
||||
# A list of formats for a single text string, consisting of:
|
||||
# text position, text format, and meta data
|
||||
T_Formats = list[tuple[int, TextFmt, str]]
|
||||
|
||||
# A note or comment with text and associated text formats
|
||||
T_Note = tuple[str, T_Formats]
|
||||
|
||||
# A tokenized text block, consisting of:
|
||||
# type, header number, text, text formats, and block format
|
||||
T_Block = tuple[BlockTyp, str, str, T_Formats, BlockFmt]
|
||||
1191
novelwriter/formats/todocx.py
Normal file
451
novelwriter/formats/tohtml.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
novelWriter – HTML Text Converter
|
||||
=================================
|
||||
|
||||
File History:
|
||||
Created: 2019-05-07 [0.0.1] ToHtml
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
|
||||
from novelwriter.common import formatTimeStamp
|
||||
from novelwriter.constants import nwHtmlUnicode
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Formats, TextFmt, stripEscape
|
||||
from novelwriter.formats.tokenizer import Tokenizer
|
||||
from novelwriter.types import FONT_STYLE, FONT_WEIGHTS, QtHexRgb
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Each opener tag, with the id of its corresponding closer and tag format
|
||||
HTML_OPENER: dict[int, tuple[int, str]] = {
|
||||
TextFmt.B_B: (TextFmt.B_E, "<strong>"),
|
||||
TextFmt.I_B: (TextFmt.I_E, "<em>"),
|
||||
TextFmt.D_B: (TextFmt.D_E, "<del>"),
|
||||
TextFmt.U_B: (TextFmt.U_E, "<span style='text-decoration: underline;'>"),
|
||||
TextFmt.M_B: (TextFmt.M_E, "<mark>"),
|
||||
TextFmt.SUP_B: (TextFmt.SUP_E, "<sup>"),
|
||||
TextFmt.SUB_B: (TextFmt.SUB_E, "<sub>"),
|
||||
TextFmt.COL_B: (TextFmt.COL_E, "<span style='color: {0}'>"),
|
||||
TextFmt.ANM_B: (TextFmt.ANM_E, "<a name='{0}'>"),
|
||||
TextFmt.ARF_B: (TextFmt.ARF_E, "<a href='{0}'>"),
|
||||
TextFmt.HRF_B: (TextFmt.HRF_E, "<a href='{0}'>"),
|
||||
}
|
||||
|
||||
# Each closer tag, with the id of its corresponding opener and tag format
|
||||
HTML_CLOSER: dict[int, tuple[int, str]] = {
|
||||
TextFmt.B_E: (TextFmt.B_B, "</strong>"),
|
||||
TextFmt.I_E: (TextFmt.I_B, "</em>"),
|
||||
TextFmt.D_E: (TextFmt.D_B, "</del>"),
|
||||
TextFmt.U_E: (TextFmt.U_B, "</span>"),
|
||||
TextFmt.M_E: (TextFmt.M_B, "</mark>"),
|
||||
TextFmt.SUP_E: (TextFmt.SUP_B, "</sup>"),
|
||||
TextFmt.SUB_E: (TextFmt.SUB_B, "</sub>"),
|
||||
TextFmt.COL_E: (TextFmt.COL_B, "</span>"),
|
||||
TextFmt.ANM_E: (TextFmt.ANM_B, "</a>"),
|
||||
TextFmt.ARF_E: (TextFmt.ARF_B, "</a>"),
|
||||
TextFmt.HRF_E: (TextFmt.HRF_B, "</a>"),
|
||||
}
|
||||
|
||||
# Empty HTML tag record
|
||||
HTML_NONE = (0, "")
|
||||
|
||||
|
||||
class ToHtml(Tokenizer):
|
||||
"""Core: HTML Document Writer
|
||||
|
||||
Extend the Tokenizer class to writer HTML output. This class is
|
||||
also used by the Document Viewer, and Manuscript Build Preview.
|
||||
"""
|
||||
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
super().__init__(project)
|
||||
self._trMap = {}
|
||||
self._cssStyles = True
|
||||
self._usedNotes: dict[str, int] = {}
|
||||
self._usedFields: list[tuple[int, str]] = []
|
||||
self.setReplaceUnicode(False)
|
||||
return
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setStyles(self, cssStyles: bool) -> None:
|
||||
"""Enable or disable CSS styling. Some elements may still have
|
||||
class tags.
|
||||
"""
|
||||
self._cssStyles = cssStyles
|
||||
return
|
||||
|
||||
def setReplaceUnicode(self, doReplace: bool) -> None:
|
||||
"""Set the translation map to either minimal or full unicode for
|
||||
html entities replacement.
|
||||
"""
|
||||
# Control characters must always be replaced
|
||||
# Angle brackets are replaced later as they are also used in
|
||||
# formatting codes
|
||||
self._trMap = str.maketrans({"&": "&"})
|
||||
if doReplace:
|
||||
# Extend to all relevant Unicode characters
|
||||
self._trMap.update(str.maketrans(nwHtmlUnicode.U_TO_H))
|
||||
return
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def getFullResultSize(self) -> int:
|
||||
"""Return the size of the full HTML result."""
|
||||
return sum(len(x) for x in self._pages)
|
||||
|
||||
def doPreProcessing(self) -> None:
|
||||
"""Extend the auto-replace to also properly encode some unicode
|
||||
characters into their respective HTML entities.
|
||||
"""
|
||||
super().doPreProcessing()
|
||||
self._text = self._text.translate(self._trMap)
|
||||
return
|
||||
|
||||
def doConvert(self) -> None:
|
||||
"""Convert the list of text tokens into an HTML document."""
|
||||
if self._isNovel:
|
||||
# For story files, we bump the titles one level up
|
||||
h1Cl = " class='title'"
|
||||
h1 = "h1"
|
||||
h2 = "h1"
|
||||
h3 = "h2"
|
||||
h4 = "h3"
|
||||
else:
|
||||
h1Cl = ""
|
||||
h1 = "h1"
|
||||
h2 = "h2"
|
||||
h3 = "h3"
|
||||
h4 = "h4"
|
||||
|
||||
lines = []
|
||||
for tType, tMeta, tText, tFmt, tStyle in self._blocks:
|
||||
|
||||
# Replace < and > with HTML entities
|
||||
if tFmt:
|
||||
# If we have formatting, we must recompute the locations
|
||||
cText = []
|
||||
i = 0
|
||||
for c in tText:
|
||||
if c == "<":
|
||||
cText.append("<")
|
||||
tFmt = [(p + 3 if p > i else p, f, k) for p, f, k in tFmt]
|
||||
i += 4
|
||||
elif c == ">":
|
||||
cText.append(">")
|
||||
tFmt = [(p + 3 if p > i else p, f, k) for p, f, k in tFmt]
|
||||
i += 4
|
||||
else:
|
||||
cText.append(c)
|
||||
i += 1
|
||||
tText = "".join(cText)
|
||||
else:
|
||||
# If we don't have formatting, we can do a plain replace
|
||||
tText = tText.replace("<", "<").replace(">", ">")
|
||||
|
||||
# Styles
|
||||
aStyle = []
|
||||
if self._cssStyles:
|
||||
if tStyle & BlockFmt.LEFT:
|
||||
aStyle.append("text-align: left;")
|
||||
elif tStyle & BlockFmt.RIGHT:
|
||||
aStyle.append("text-align: right;")
|
||||
elif tStyle & BlockFmt.CENTRE:
|
||||
aStyle.append("text-align: center;")
|
||||
elif tStyle & BlockFmt.JUSTIFY:
|
||||
aStyle.append("text-align: justify;")
|
||||
|
||||
if tStyle & BlockFmt.PBB:
|
||||
aStyle.append("page-break-before: always;")
|
||||
if tStyle & BlockFmt.PBA:
|
||||
aStyle.append("page-break-after: always;")
|
||||
|
||||
if tStyle & BlockFmt.Z_BTM:
|
||||
aStyle.append("margin-bottom: 0;")
|
||||
if tStyle & BlockFmt.Z_TOP:
|
||||
aStyle.append("margin-top: 0;")
|
||||
|
||||
if tStyle & BlockFmt.IND_L:
|
||||
aStyle.append(f"margin-left: {self._blockIndent:.2f}em;")
|
||||
if tStyle & BlockFmt.IND_R:
|
||||
aStyle.append(f"margin-right: {self._blockIndent:.2f}em;")
|
||||
if tStyle & BlockFmt.IND_T:
|
||||
aStyle.append(f"text-indent: {self._firstWidth:.2f}em;")
|
||||
|
||||
if aStyle:
|
||||
stVals = " ".join(aStyle)
|
||||
hStyle = f" style='{stVals}'"
|
||||
else:
|
||||
hStyle = ""
|
||||
|
||||
if self._linkHeadings and tMeta:
|
||||
aNm = f"<a name='{tMeta}'></a>"
|
||||
else:
|
||||
aNm = ""
|
||||
|
||||
# Process Text Type
|
||||
if tType == BlockTyp.TEXT:
|
||||
lines.append(f"<p{hStyle}>{self._formatText(tText, tFmt)}</p>\n")
|
||||
|
||||
elif tType == BlockTyp.TITLE:
|
||||
tHead = tText.replace("\n", "<br>")
|
||||
lines.append(f"<h1 class='title'{hStyle}>{aNm}{tHead}</h1>\n")
|
||||
|
||||
elif tType == BlockTyp.HEAD1:
|
||||
tHead = tText.replace("\n", "<br>")
|
||||
lines.append(f"<{h1}{h1Cl}{hStyle}>{aNm}{tHead}</{h1}>\n")
|
||||
|
||||
elif tType == BlockTyp.HEAD2:
|
||||
tHead = tText.replace("\n", "<br>")
|
||||
lines.append(f"<{h2}{hStyle}>{aNm}{tHead}</{h2}>\n")
|
||||
|
||||
elif tType == BlockTyp.HEAD3:
|
||||
tHead = tText.replace("\n", "<br>")
|
||||
lines.append(f"<{h3}{hStyle}>{aNm}{tHead}</{h3}>\n")
|
||||
|
||||
elif tType == BlockTyp.HEAD4:
|
||||
tHead = tText.replace("\n", "<br>")
|
||||
lines.append(f"<{h4}{hStyle}>{aNm}{tHead}</{h4}>\n")
|
||||
|
||||
elif tType == BlockTyp.SEP:
|
||||
lines.append(f"<p class='sep'{hStyle}>{tText}</p>\n")
|
||||
|
||||
elif tType == BlockTyp.SKIP:
|
||||
lines.append(f"<p{hStyle}> </p>\n")
|
||||
|
||||
elif tType == BlockTyp.COMMENT:
|
||||
lines.append(f"<p class='comment'{hStyle}>{self._formatText(tText, tFmt)}</p>\n")
|
||||
|
||||
elif tType == BlockTyp.KEYWORD:
|
||||
tClass = f"meta meta-{tMeta}"
|
||||
lines.append(f"<p class='{tClass}'{hStyle}>{self._formatText(tText, tFmt)}</p>\n")
|
||||
|
||||
self._pages.append("".join(lines))
|
||||
|
||||
return
|
||||
|
||||
def closeDocument(self) -> None:
|
||||
"""Run close document tasks."""
|
||||
# Replace fields if there are stats available
|
||||
if self._usedFields and self._counts:
|
||||
pages = len(self._pages)
|
||||
for doc, field in self._usedFields:
|
||||
if doc >= 0 and doc < pages and (value := self._counts.get(field)) is not None:
|
||||
self._pages[doc] = self._pages[doc].replace(
|
||||
f"{{{{{field}}}}}", self._formatInt(value)
|
||||
)
|
||||
|
||||
# Add footnotes
|
||||
if self._usedNotes:
|
||||
footnotes = self._localLookup("Footnotes")
|
||||
|
||||
lines = []
|
||||
lines.append(f"<h3>{footnotes}</h3>\n")
|
||||
lines.append("<ol>\n")
|
||||
for key, index in self._usedNotes.items():
|
||||
if content := self._footnotes.get(key):
|
||||
text = self._formatText(*content)
|
||||
lines.append(f"<li id='footnote_{index}'><p>{text}</p></li>\n")
|
||||
lines.append("</ol>\n")
|
||||
|
||||
self._pages.append("".join(lines))
|
||||
|
||||
return
|
||||
|
||||
def saveDocument(self, path: Path) -> None:
|
||||
"""Save the data to an HTML file."""
|
||||
if path.suffix.lower() == ".json":
|
||||
ts = time()
|
||||
data = {
|
||||
"meta": {
|
||||
"projectName": self._project.data.name,
|
||||
"novelAuthor": self._project.data.author,
|
||||
"buildTime": int(ts),
|
||||
"buildTimeStr": formatTimeStamp(ts),
|
||||
},
|
||||
"text": {
|
||||
"css": self.getStyleSheet(),
|
||||
"html": [t.replace("\t", "	").rstrip().split("\n") for t in self._pages],
|
||||
}
|
||||
}
|
||||
with open(path, mode="w", encoding="utf-8") as fObj:
|
||||
json.dump(data, fObj, indent=2)
|
||||
|
||||
else:
|
||||
with open(path, mode="w", encoding="utf-8") as fObj:
|
||||
fObj.write((
|
||||
"<!DOCTYPE html>\n"
|
||||
"<html>\n"
|
||||
"<head>\n"
|
||||
"<meta charset='utf-8'>\n"
|
||||
"<title>{title:s}</title>\n"
|
||||
"<style>\n"
|
||||
"{style:s}\n"
|
||||
"</style>\n"
|
||||
"</head>\n"
|
||||
"<body>\n"
|
||||
"<article>\n"
|
||||
"{body:s}\n"
|
||||
"</article>\n"
|
||||
"</body>\n"
|
||||
"</html>\n"
|
||||
).format(
|
||||
title=self._project.data.name,
|
||||
style="\n".join(self.getStyleSheet()),
|
||||
body=("".join(self._pages)).replace("\t", "	").rstrip(),
|
||||
))
|
||||
|
||||
logger.info("Wrote file: %s", path)
|
||||
|
||||
return
|
||||
|
||||
def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " ") -> None:
|
||||
"""Replace tabs with spaces in the html."""
|
||||
pages = []
|
||||
tabSpace = spaceChar*nSpaces
|
||||
for aLine in self._pages:
|
||||
pages.append(aLine.replace("\t", tabSpace))
|
||||
self._pages = pages
|
||||
return
|
||||
|
||||
def getStyleSheet(self) -> list[str]:
|
||||
"""Generate a stylesheet for the current settings."""
|
||||
if not self._cssStyles:
|
||||
return []
|
||||
|
||||
tColor = self._theme.text.name(QtHexRgb)
|
||||
hColor = self._theme.head.name(QtHexRgb) if self._colorHeads else tColor
|
||||
lColor = self._theme.head.name(QtHexRgb)
|
||||
mColor = self._theme.highlight.name(QtHexRgb)
|
||||
|
||||
mtH0 = self._marginTitle[0]
|
||||
mbH0 = self._marginTitle[1]
|
||||
mtH1 = self._marginHead1[0]
|
||||
mbH1 = self._marginHead1[1]
|
||||
mtH2 = self._marginHead2[0]
|
||||
mbH2 = self._marginHead2[1]
|
||||
mtH3 = self._marginHead3[0]
|
||||
mbH3 = self._marginHead3[1]
|
||||
mtH4 = self._marginHead4[0]
|
||||
mbH4 = self._marginHead4[1]
|
||||
mtTT = self._marginText[0]
|
||||
mbTT = self._marginText[1]
|
||||
mtSP = self._marginSep[0]
|
||||
mbSP = self._marginSep[1]
|
||||
|
||||
font = self._textFont
|
||||
fFam = font.family()
|
||||
fSz = font.pointSize()
|
||||
fW = FONT_WEIGHTS.get(font.weight(), 400)
|
||||
fS = FONT_STYLE.get(font.style(), "normal")
|
||||
|
||||
lHeight = round(100 * self._lineHeight)
|
||||
|
||||
styles = []
|
||||
styles.append(
|
||||
f"body {{color: {tColor}; font-family: '{fFam}'; font-size: {fSz}pt; "
|
||||
f"font-weight: {fW}; font-style: {fS};}}"
|
||||
)
|
||||
styles.append(
|
||||
f"p {{text-align: {self._defaultAlign}; line-height: {lHeight}%; "
|
||||
f"margin-top: {mtTT:.2f}em; margin-bottom: {mbTT:.2f}em;}}"
|
||||
)
|
||||
styles.append(f"a {{color: {lColor};}}")
|
||||
styles.append(f"mark {{background: {mColor};}}")
|
||||
styles.append(f"h1, h2, h3, h4 {{color: {hColor}; page-break-after: avoid;}}")
|
||||
styles.append(f"h1 {{margin-top: {mtH1:.2f}em; margin-bottom: {mbH1:.2f}em;}}")
|
||||
styles.append(f"h2 {{margin-top: {mtH2:.2f}em; margin-bottom: {mbH2:.2f}em;}}")
|
||||
styles.append(f"h3 {{margin-top: {mtH3:.2f}em; margin-bottom: {mbH3:.2f}em;}}")
|
||||
styles.append(f"h4 {{margin-top: {mtH4:.2f}em; margin-bottom: {mbH4:.2f}em;}}")
|
||||
styles.append(
|
||||
f".title {{font-size: 2.5em; margin-top: {mtH0:.2f}em; margin-bottom: {mbH0:.2f}em;}}"
|
||||
)
|
||||
styles.append(
|
||||
f".sep {{text-align: center; margin-top: {mtSP:.2f}em; margin-bottom: {mbSP:.2f}em;}}"
|
||||
)
|
||||
|
||||
return styles
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _formatText(self, text: str, tFmt: T_Formats) -> str:
|
||||
"""Apply formatting tags to text."""
|
||||
temp = text
|
||||
|
||||
# Build a list of all html tags that need to be inserted in the text.
|
||||
# This is done in the forward direction, and a tag is only opened if it
|
||||
# isn't already open, and only closed if it has previously been opened.
|
||||
tags: list[tuple[int, str]] = []
|
||||
state = dict.fromkeys(HTML_OPENER, False)
|
||||
for pos, fmt, data in tFmt:
|
||||
if m := HTML_OPENER.get(fmt):
|
||||
if not state.get(fmt, True):
|
||||
if fmt == TextFmt.COL_B and (color := self._classes.get(data)):
|
||||
tags.append((pos, m[1].format(color.name(QtHexRgb))))
|
||||
elif fmt in (TextFmt.ANM_B, TextFmt.ARF_B, TextFmt.HRF_B):
|
||||
tags.append((pos, m[1].format(data or "#")))
|
||||
else:
|
||||
tags.append((pos, m[1]))
|
||||
state[fmt] = True
|
||||
elif m := HTML_CLOSER.get(fmt):
|
||||
if state.get(m[0], False):
|
||||
tags.append((pos, m[1]))
|
||||
state[m[0]] = False
|
||||
elif fmt == TextFmt.FNOTE:
|
||||
if data in self._footnotes:
|
||||
index = len(self._usedNotes) + 1
|
||||
self._usedNotes[data] = index
|
||||
tags.append((pos, f"<sup><a href='#footnote_{index}'>{index}</a></sup>"))
|
||||
else:
|
||||
tags.append((pos, "<sup>ERR</sup>"))
|
||||
elif fmt == TextFmt.FIELD:
|
||||
if field := data.partition(":")[2]:
|
||||
self._usedFields.append((len(self._pages), field))
|
||||
tags.append((pos, f"{{{{{field}}}}}"))
|
||||
|
||||
# Check all format types and close any tag that is still open. This
|
||||
# ensures that unclosed tags don't spill over to the next paragraph.
|
||||
end = len(text)
|
||||
for opener, active in state.items():
|
||||
if active:
|
||||
closer = HTML_OPENER.get(opener, HTML_NONE)[0]
|
||||
tags.append((end, HTML_CLOSER.get(closer, HTML_NONE)[1]))
|
||||
|
||||
# Insert all tags at their correct position, starting from the back.
|
||||
# The reverse order ensures that the positions are not shifted while we
|
||||
# insert tags.
|
||||
for pos, tag in reversed(tags):
|
||||
temp = f"{temp[:pos]}{tag}{temp[pos:]}"
|
||||
|
||||
# Replace all line breaks with proper HTML break tags
|
||||
temp = temp.replace("\n", "<br>")
|
||||
|
||||
return stripEscape(temp)
|
||||
217
novelwriter/formats/tomarkdown.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
novelWriter – Markdown Text Converter
|
||||
=====================================
|
||||
|
||||
File History:
|
||||
Created: 2021-02-06 [1.2b1] ToMarkdown
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from novelwriter.constants import nwUnicode
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Formats, TextFmt
|
||||
from novelwriter.formats.tokenizer import Tokenizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Standard Markdown
|
||||
STD_MD = {
|
||||
TextFmt.B_B: "**",
|
||||
TextFmt.B_E: "**",
|
||||
TextFmt.I_B: "_",
|
||||
TextFmt.I_E: "_",
|
||||
TextFmt.D_B: "",
|
||||
TextFmt.D_E: "",
|
||||
TextFmt.U_B: "",
|
||||
TextFmt.U_E: "",
|
||||
TextFmt.M_B: "",
|
||||
TextFmt.M_E: "",
|
||||
TextFmt.SUP_B: "",
|
||||
TextFmt.SUP_E: "",
|
||||
TextFmt.SUB_B: "",
|
||||
TextFmt.SUB_E: "",
|
||||
TextFmt.STRIP: "",
|
||||
}
|
||||
|
||||
# Extended Markdown
|
||||
EXT_MD = {
|
||||
TextFmt.B_B: "**",
|
||||
TextFmt.B_E: "**",
|
||||
TextFmt.I_B: "_",
|
||||
TextFmt.I_E: "_",
|
||||
TextFmt.D_B: "~~",
|
||||
TextFmt.D_E: "~~",
|
||||
TextFmt.U_B: "",
|
||||
TextFmt.U_E: "",
|
||||
TextFmt.M_B: "==",
|
||||
TextFmt.M_E: "==",
|
||||
TextFmt.SUP_B: "^",
|
||||
TextFmt.SUP_E: "^",
|
||||
TextFmt.SUB_B: "~",
|
||||
TextFmt.SUB_E: "~",
|
||||
TextFmt.STRIP: "",
|
||||
}
|
||||
|
||||
|
||||
class ToMarkdown(Tokenizer):
|
||||
"""Core: Markdown Document Writer
|
||||
|
||||
Extend the Tokenizer class to writer Markdown output. It supports
|
||||
both Standard Markdown and Extended Markdown. The class also
|
||||
supports concatenating novelWriter markup files.
|
||||
"""
|
||||
|
||||
def __init__(self, project: NWProject, extended: bool) -> None:
|
||||
super().__init__(project)
|
||||
self._extended = extended
|
||||
self._usedNotes: dict[str, int] = {}
|
||||
self._usedFields: list[tuple[int, str]] = []
|
||||
return
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def getFullResultSize(self) -> int:
|
||||
"""Return the size of the full Markdown result."""
|
||||
return sum(len(x) for x in self._pages)
|
||||
|
||||
def doConvert(self) -> None:
|
||||
"""Convert the list of text tokens into a Markdown document."""
|
||||
if self._extended:
|
||||
mTags = EXT_MD
|
||||
cSkip = nwUnicode.U_MMSP
|
||||
else:
|
||||
mTags = STD_MD
|
||||
cSkip = ""
|
||||
|
||||
lines = []
|
||||
for tType, _, tText, tFormat, tStyle in self._blocks:
|
||||
|
||||
if tType == BlockTyp.TEXT:
|
||||
tTemp = self._formatText(tText, tFormat, mTags).replace("\n", " \n")
|
||||
lines.append(f"{tTemp}\n\n")
|
||||
|
||||
elif tType == BlockTyp.TITLE:
|
||||
tHead = tText.replace("\n", " - ")
|
||||
lines.append(f"# {tHead}\n\n")
|
||||
|
||||
elif tType == BlockTyp.HEAD1:
|
||||
tHead = tText.replace("\n", " - ")
|
||||
lines.append(f"# {tHead}\n\n")
|
||||
|
||||
elif tType == BlockTyp.HEAD2:
|
||||
tHead = tText.replace("\n", " - ")
|
||||
lines.append(f"## {tHead}\n\n")
|
||||
|
||||
elif tType == BlockTyp.HEAD3:
|
||||
tHead = tText.replace("\n", " - ")
|
||||
lines.append(f"### {tHead}\n\n")
|
||||
|
||||
elif tType == BlockTyp.HEAD4:
|
||||
tHead = tText.replace("\n", " - ")
|
||||
lines.append(f"#### {tHead}\n\n")
|
||||
|
||||
elif tType == BlockTyp.SEP:
|
||||
lines.append(f"{tText}\n\n")
|
||||
|
||||
elif tType == BlockTyp.SKIP:
|
||||
lines.append(f"{cSkip}\n\n")
|
||||
|
||||
elif tType == BlockTyp.COMMENT:
|
||||
lines.append(f"{self._formatText(tText, tFormat, mTags)}\n\n")
|
||||
|
||||
elif tType == BlockTyp.KEYWORD:
|
||||
end = " \n" if tStyle & BlockFmt.Z_BTM else "\n\n"
|
||||
lines.append(f"{self._formatText(tText, tFormat, mTags)}{end}")
|
||||
|
||||
self._pages.append("".join(lines))
|
||||
|
||||
return
|
||||
|
||||
def closeDocument(self) -> None:
|
||||
"""Run close document tasks."""
|
||||
# Replace fields if there are stats available
|
||||
if self._usedFields and self._counts:
|
||||
pages = len(self._pages)
|
||||
for doc, field in self._usedFields:
|
||||
if doc >= 0 and doc < pages and (value := self._counts.get(field)) is not None:
|
||||
self._pages[doc] = self._pages[doc].replace(
|
||||
f"{{{{{field}}}}}", self._formatInt(value)
|
||||
)
|
||||
|
||||
# Add footnotes
|
||||
if self._usedNotes:
|
||||
tags = EXT_MD if self._extended else STD_MD
|
||||
footnotes = self._localLookup("Footnotes")
|
||||
|
||||
lines = []
|
||||
lines.append(f"### {footnotes}\n\n")
|
||||
for key, index in self._usedNotes.items():
|
||||
if content := self._footnotes.get(key):
|
||||
marker = f"{index}. "
|
||||
text = self._formatText(content[0], content[1], tags)
|
||||
lines.append(f"{marker}{text}\n")
|
||||
lines.append("\n")
|
||||
self._pages.append("".join(lines))
|
||||
|
||||
return
|
||||
|
||||
def saveDocument(self, path: Path) -> None:
|
||||
"""Save the data to a plain text file."""
|
||||
with open(path, mode="w", encoding="utf-8") as outFile:
|
||||
outFile.write("".join(self._pages))
|
||||
logger.info("Wrote file: %s", path)
|
||||
return
|
||||
|
||||
def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " ") -> None:
|
||||
"""Replace tabs with spaces."""
|
||||
spaces = spaceChar*nSpaces
|
||||
self._pages = [p.replace("\t", spaces) for p in self._pages]
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _formatText(self, text: str, tFmt: T_Formats, tags: dict[TextFmt, str]) -> str:
|
||||
"""Apply formatting tags to text."""
|
||||
temp = text
|
||||
for pos, fmt, data in reversed(tFmt):
|
||||
md = ""
|
||||
if fmt == TextFmt.FNOTE:
|
||||
if data in self._footnotes:
|
||||
index = len(self._usedNotes) + 1
|
||||
self._usedNotes[data] = index
|
||||
md = f"[{index}]"
|
||||
else:
|
||||
md = "[ERR]"
|
||||
elif fmt == TextFmt.FIELD:
|
||||
if field := data.partition(":")[2]:
|
||||
self._usedFields.append((len(self._pages), field))
|
||||
md = f"{{{{{field}}}}}"
|
||||
else:
|
||||
md = tags.get(fmt, "")
|
||||
temp = f"{temp[:pos]}{md}{temp[pos:]}"
|
||||
return temp
|
||||
484
novelwriter/formats/toqdoc.py
Normal file
@@ -0,0 +1,484 @@
|
||||
"""
|
||||
novelWriter – QTextDocument Converter
|
||||
=====================================
|
||||
|
||||
File History:
|
||||
Created: 2024-05-21 [2.5b1] ToQTextDocument
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import QMarginsF, QSizeF
|
||||
from PyQt5.QtGui import (
|
||||
QColor, QFont, QFontDatabase, QPageSize, QTextBlockFormat, QTextCharFormat,
|
||||
QTextCursor, QTextDocument, QTextFrameFormat
|
||||
)
|
||||
from PyQt5.QtPrintSupport import QPrinter
|
||||
|
||||
from novelwriter import __version__
|
||||
from novelwriter.constants import nwStyles, nwUnicode
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Formats, TextFmt
|
||||
from novelwriter.formats.tokenizer import HEADINGS, Tokenizer
|
||||
from novelwriter.types import (
|
||||
QtAlignAbsolute, QtAlignCenter, QtAlignJustify, QtAlignLeft, QtAlignRight,
|
||||
QtKeepAnchor, QtMoveAnchor, QtPageBreakAfter, QtPageBreakBefore,
|
||||
QtPropLineHeight, QtTransparent, QtVAlignNormal, QtVAlignSub, QtVAlignSuper
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T_TextStyle = tuple[QTextBlockFormat, QTextCharFormat]
|
||||
|
||||
|
||||
def newBlock(cursor: QTextCursor, bFmt: QTextBlockFormat) -> None:
|
||||
if cursor.position() > 0:
|
||||
cursor.insertBlock(bFmt)
|
||||
else:
|
||||
cursor.setBlockFormat(bFmt)
|
||||
|
||||
|
||||
class ToQTextDocument(Tokenizer):
|
||||
"""Core: QTextDocument Writer
|
||||
|
||||
Extend the Tokenizer class to generate a QTextDocument output. This
|
||||
is intended for usage in the document viewer and build tool preview.
|
||||
"""
|
||||
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
super().__init__(project)
|
||||
self._document = QTextDocument()
|
||||
self._document.setUndoRedoEnabled(False)
|
||||
self._document.setDocumentMargin(0.0)
|
||||
|
||||
self._usedNotes: dict[str, int] = {}
|
||||
self._usedFields: list[tuple[int, str]] = []
|
||||
|
||||
self._init = False
|
||||
self._newPage = False
|
||||
self._anchors = True
|
||||
|
||||
self._hWeight = QFont.Weight.Bold
|
||||
self._dWeight = QFont.Weight.Normal
|
||||
self._dItalic = False
|
||||
self._dStrike = False
|
||||
self._dUnderline = False
|
||||
|
||||
self._dpi = 96
|
||||
self._pageSize = QPageSize(QPageSize.PageSizeId.A4)
|
||||
self._pageMargins = QMarginsF(20.0, 20.0, 20.0, 20.0)
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def document(self) -> QTextDocument:
|
||||
"""Return the document."""
|
||||
return self._document
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setPageLayout(
|
||||
self, width: float, height: float, top: float, bottom: float, left: float, right: float
|
||||
) -> None:
|
||||
"""Set the document page size and margins in millimetres."""
|
||||
self._pageSize = QPageSize(QSizeF(width, height), QPageSize.Unit.Millimeter)
|
||||
self._pageMargins = QMarginsF(left, top, right, bottom)
|
||||
return
|
||||
|
||||
def setShowNewPage(self, state: bool) -> None:
|
||||
"""Add markers for page breaks."""
|
||||
self._newPage = state
|
||||
return
|
||||
|
||||
def disableAnchors(self) -> None:
|
||||
"""Disable anchors for when writing to file."""
|
||||
self._anchors = False
|
||||
return
|
||||
|
||||
##
|
||||
# Class Methods
|
||||
##
|
||||
|
||||
def initDocument(self, pdf: bool = False) -> None:
|
||||
"""Initialise all computed values of the document."""
|
||||
super().initDocument()
|
||||
|
||||
if pdf:
|
||||
fontDB = QFontDatabase()
|
||||
family = self._textFont.family()
|
||||
style = self._textFont.styleName()
|
||||
self._dpi = 1200 if fontDB.isScalable(family, style) else 72
|
||||
|
||||
self._document.setUndoRedoEnabled(False)
|
||||
self._document.blockSignals(True)
|
||||
self._document.clear()
|
||||
self._document.setDefaultFont(self._textFont)
|
||||
|
||||
# Default Styles
|
||||
self._dWeight = self._textFont.weight()
|
||||
self._dItalic = self._textFont.italic()
|
||||
self._dStrike = self._textFont.strikeOut()
|
||||
self._dUnderline = self._textFont.underline()
|
||||
|
||||
# Header Weight
|
||||
self._hWeight = QFont.Weight.Bold if self._boldHeads else self._dWeight
|
||||
|
||||
# Scaled Sizes
|
||||
# ============
|
||||
|
||||
fPt = self._textFont.pointSizeF()
|
||||
fPx = fPt*96.0/72.0 # 1 em in pixels
|
||||
mPx = fPx * self._dpi/96.0
|
||||
|
||||
self._mHead = {
|
||||
BlockTyp.TITLE: (fPx * self._marginTitle[0], fPx * self._marginTitle[1]),
|
||||
BlockTyp.HEAD1: (fPx * self._marginHead1[0], fPx * self._marginHead1[1]),
|
||||
BlockTyp.HEAD2: (fPx * self._marginHead2[0], fPx * self._marginHead2[1]),
|
||||
BlockTyp.HEAD3: (fPx * self._marginHead3[0], fPx * self._marginHead3[1]),
|
||||
BlockTyp.HEAD4: (fPx * self._marginHead4[0], fPx * self._marginHead4[1]),
|
||||
}
|
||||
|
||||
hScale = self._scaleHeads
|
||||
self._sHead = {
|
||||
BlockTyp.TITLE: (nwStyles.H_SIZES.get(0, 1.0) * fPt) if hScale else fPt,
|
||||
BlockTyp.HEAD1: (nwStyles.H_SIZES.get(1, 1.0) * fPt) if hScale else fPt,
|
||||
BlockTyp.HEAD2: (nwStyles.H_SIZES.get(2, 1.0) * fPt) if hScale else fPt,
|
||||
BlockTyp.HEAD3: (nwStyles.H_SIZES.get(3, 1.0) * fPt) if hScale else fPt,
|
||||
BlockTyp.HEAD4: (nwStyles.H_SIZES.get(4, 1.0) * fPt) if hScale else fPt,
|
||||
}
|
||||
|
||||
self._mText = (fPx * self._marginText[0], fPx * self._marginText[1])
|
||||
self._mMeta = (fPx * self._marginMeta[0], fPx * self._marginMeta[1])
|
||||
self._mSep = (fPx * self._marginSep[0], fPx * self._marginSep[1])
|
||||
|
||||
self._mIndent = mPx * 2.0
|
||||
self._tIndent = mPx * self._firstWidth
|
||||
|
||||
# Text Formats
|
||||
# ============
|
||||
|
||||
self._blockFmt = QTextBlockFormat()
|
||||
self._blockFmt.setTopMargin(self._mText[0])
|
||||
self._blockFmt.setBottomMargin(self._mText[1])
|
||||
self._blockFmt.setAlignment(QtAlignAbsolute)
|
||||
self._blockFmt.setLineHeight(100.0*self._lineHeight, QtPropLineHeight)
|
||||
|
||||
self._charFmt = QTextCharFormat()
|
||||
self._charFmt.setBackground(QtTransparent)
|
||||
self._charFmt.setForeground(self._theme.text)
|
||||
|
||||
self._init = True
|
||||
|
||||
return
|
||||
|
||||
def doConvert(self) -> None:
|
||||
"""Write text tokens into the document."""
|
||||
if not self._init:
|
||||
return
|
||||
|
||||
self._document.blockSignals(True)
|
||||
cursor = QTextCursor(self._document)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
|
||||
for tType, tMeta, tText, tFormat, tStyle in self._blocks:
|
||||
|
||||
bFmt = QTextBlockFormat(self._blockFmt)
|
||||
if tType in (BlockTyp.COMMENT, BlockTyp.KEYWORD):
|
||||
bFmt.setTopMargin(self._mMeta[0])
|
||||
bFmt.setBottomMargin(self._mMeta[1])
|
||||
elif tType == BlockTyp.SEP:
|
||||
bFmt.setTopMargin(self._mSep[0])
|
||||
bFmt.setBottomMargin(self._mSep[1])
|
||||
|
||||
if tStyle & BlockFmt.LEFT:
|
||||
bFmt.setAlignment(QtAlignLeft)
|
||||
elif tStyle & BlockFmt.RIGHT:
|
||||
bFmt.setAlignment(QtAlignRight)
|
||||
elif tStyle & BlockFmt.CENTRE:
|
||||
bFmt.setAlignment(QtAlignCenter)
|
||||
elif tStyle & BlockFmt.JUSTIFY:
|
||||
bFmt.setAlignment(QtAlignJustify)
|
||||
|
||||
if tStyle & BlockFmt.PBB:
|
||||
self._insertNewPageMarker(cursor)
|
||||
bFmt.setPageBreakPolicy(QtPageBreakBefore)
|
||||
if tStyle & BlockFmt.PBA:
|
||||
bFmt.setPageBreakPolicy(QtPageBreakAfter)
|
||||
|
||||
if tStyle & BlockFmt.Z_BTM:
|
||||
bFmt.setBottomMargin(0.0)
|
||||
if tStyle & BlockFmt.Z_TOP:
|
||||
bFmt.setTopMargin(0.0)
|
||||
|
||||
if tStyle & BlockFmt.IND_L:
|
||||
bFmt.setLeftMargin(self._mIndent)
|
||||
if tStyle & BlockFmt.IND_R:
|
||||
bFmt.setRightMargin(self._mIndent)
|
||||
if tStyle & BlockFmt.IND_T:
|
||||
bFmt.setTextIndent(self._tIndent)
|
||||
|
||||
if tType in (BlockTyp.TEXT, BlockTyp.COMMENT, BlockTyp.KEYWORD):
|
||||
newBlock(cursor, bFmt)
|
||||
self._insertFragments(tText, tFormat, cursor, self._charFmt)
|
||||
|
||||
elif tType in HEADINGS:
|
||||
bFmt, cFmt = self._genHeadStyle(tType, tMeta, bFmt)
|
||||
newBlock(cursor, bFmt)
|
||||
cursor.insertText(tText, cFmt)
|
||||
|
||||
elif tType == BlockTyp.SEP:
|
||||
newBlock(cursor, bFmt)
|
||||
cursor.insertText(tText, self._charFmt)
|
||||
|
||||
elif tType == BlockTyp.SKIP:
|
||||
newBlock(cursor, bFmt)
|
||||
cursor.insertText(nwUnicode.U_NBSP, self._charFmt)
|
||||
|
||||
if tStyle & BlockFmt.PBA:
|
||||
self._insertNewPageMarker(cursor)
|
||||
|
||||
self._document.blockSignals(False)
|
||||
|
||||
return
|
||||
|
||||
def saveDocument(self, path: Path) -> None:
|
||||
"""Save the document as a PDF file."""
|
||||
m = self._pageMargins
|
||||
logger.info("Writing PDF at %d DPI", self._dpi)
|
||||
|
||||
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
|
||||
printer.setDocName(self._project.data.name)
|
||||
printer.setCreator(f"novelWriter/{__version__}")
|
||||
printer.setResolution(self._dpi)
|
||||
printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
|
||||
printer.setPageSize(self._pageSize)
|
||||
printer.setPageMargins(m.left(), m.top(), m.right(), m.bottom(), QPrinter.Unit.Millimeter)
|
||||
printer.setOutputFileName(str(path))
|
||||
|
||||
self._document.documentLayout().setPaintDevice(printer)
|
||||
self._document.setPageSize(QSizeF(printer.pageRect().size()))
|
||||
self._document.print(printer)
|
||||
|
||||
return
|
||||
|
||||
def closeDocument(self) -> None:
|
||||
"""Run close document tasks."""
|
||||
self._document.blockSignals(True)
|
||||
|
||||
# Replace fields if there are stats available
|
||||
if self._usedFields and self._counts:
|
||||
cursor = QTextCursor(self._document)
|
||||
for pos, field in reversed(self._usedFields):
|
||||
if (value := self._counts.get(field)) is not None:
|
||||
cursor.setPosition(pos, QtMoveAnchor)
|
||||
cursor.setPosition(pos + 1, QtKeepAnchor)
|
||||
cursor.insertText(self._formatInt(value))
|
||||
|
||||
# Add footnotes
|
||||
if self._usedNotes:
|
||||
cursor = QTextCursor(self._document)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
|
||||
bFmt, cFmt = self._genHeadStyle(BlockTyp.HEAD4, "", self._blockFmt)
|
||||
newBlock(cursor, bFmt)
|
||||
cursor.insertText(self._localLookup("Footnotes"), cFmt)
|
||||
|
||||
for key, index in self._usedNotes.items():
|
||||
if content := self._footnotes.get(key):
|
||||
cFmt = QTextCharFormat(self._charFmt)
|
||||
cFmt.setForeground(self._theme.code)
|
||||
cFmt.setAnchor(True)
|
||||
cFmt.setAnchorNames([f"footnote_{index}"])
|
||||
newBlock(cursor, self._blockFmt)
|
||||
cursor.insertText(f"{index}. ", cFmt)
|
||||
self._insertFragments(*content, cursor, self._charFmt)
|
||||
|
||||
self._document.blockSignals(False)
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _insertFragments(
|
||||
self, text: str, tFmt: T_Formats, cursor: QTextCursor, dFmt: QTextCharFormat
|
||||
) -> None:
|
||||
"""Apply formatting tags to text."""
|
||||
cFmt = QTextCharFormat(dFmt)
|
||||
temp = text.replace("\n", nwUnicode.U_LSEP)
|
||||
start = 0
|
||||
primary: QColor | None = None
|
||||
for pos, fmt, data in tFmt:
|
||||
|
||||
# Insert buffer with previous format
|
||||
cursor.insertText(temp[start:pos], cFmt)
|
||||
|
||||
# Construct next format
|
||||
if fmt == TextFmt.B_B:
|
||||
cFmt.setFontWeight(QFont.Weight.Bold)
|
||||
elif fmt == TextFmt.B_E:
|
||||
cFmt.setFontWeight(self._dWeight)
|
||||
elif fmt == TextFmt.I_B:
|
||||
cFmt.setFontItalic(True)
|
||||
elif fmt == TextFmt.I_E:
|
||||
cFmt.setFontItalic(self._dItalic)
|
||||
elif fmt == TextFmt.D_B:
|
||||
cFmt.setFontStrikeOut(True)
|
||||
elif fmt == TextFmt.D_E:
|
||||
cFmt.setFontStrikeOut(self._dStrike)
|
||||
elif fmt == TextFmt.U_B:
|
||||
cFmt.setFontUnderline(True)
|
||||
elif fmt == TextFmt.U_E:
|
||||
cFmt.setFontUnderline(self._dUnderline)
|
||||
elif fmt == TextFmt.M_B:
|
||||
cFmt.setBackground(self._theme.highlight)
|
||||
elif fmt == TextFmt.M_E:
|
||||
cFmt.setBackground(QtTransparent)
|
||||
elif fmt == TextFmt.SUP_B:
|
||||
cFmt.setVerticalAlignment(QtVAlignSuper)
|
||||
elif fmt == TextFmt.SUP_E:
|
||||
cFmt.setVerticalAlignment(QtVAlignNormal)
|
||||
elif fmt == TextFmt.SUB_B:
|
||||
cFmt.setVerticalAlignment(QtVAlignSub)
|
||||
elif fmt == TextFmt.SUB_E:
|
||||
cFmt.setVerticalAlignment(QtVAlignNormal)
|
||||
elif fmt == TextFmt.COL_B:
|
||||
if color := self._classes.get(data):
|
||||
cFmt.setForeground(color)
|
||||
primary = color
|
||||
elif fmt == TextFmt.COL_E:
|
||||
cFmt.setForeground(self._theme.text)
|
||||
primary = None
|
||||
elif fmt == TextFmt.ANM_B:
|
||||
if self._anchors:
|
||||
cFmt.setAnchor(True)
|
||||
cFmt.setAnchorNames([data])
|
||||
elif fmt == TextFmt.ANM_E:
|
||||
if self._anchors:
|
||||
cFmt.setAnchor(False)
|
||||
elif fmt == TextFmt.ARF_B:
|
||||
if self._anchors:
|
||||
cFmt.setFontUnderline(True)
|
||||
cFmt.setAnchor(True)
|
||||
cFmt.setAnchorHref(data)
|
||||
elif fmt == TextFmt.ARF_E:
|
||||
if self._anchors:
|
||||
cFmt.setFontUnderline(False)
|
||||
cFmt.setAnchor(False)
|
||||
cFmt.setAnchorHref("")
|
||||
elif fmt == TextFmt.HRF_B:
|
||||
cFmt.setForeground(self._theme.link)
|
||||
cFmt.setFontUnderline(True)
|
||||
cFmt.setAnchor(True)
|
||||
cFmt.setAnchorHref(data)
|
||||
elif fmt == TextFmt.HRF_E:
|
||||
cFmt.setForeground(primary or self._theme.text)
|
||||
cFmt.setFontUnderline(self._dUnderline)
|
||||
cFmt.setAnchor(False)
|
||||
cFmt.setAnchorHref("")
|
||||
elif fmt == TextFmt.FNOTE:
|
||||
xFmt = QTextCharFormat(self._charFmt)
|
||||
xFmt.setForeground(self._theme.code)
|
||||
xFmt.setVerticalAlignment(QtVAlignSuper)
|
||||
if data in self._footnotes:
|
||||
index = len(self._usedNotes) + 1
|
||||
self._usedNotes[data] = index
|
||||
xFmt.setAnchor(True)
|
||||
xFmt.setAnchorHref(f"#footnote_{index}")
|
||||
xFmt.setFontUnderline(True)
|
||||
cursor.insertText(f"[{index}]", xFmt)
|
||||
else:
|
||||
cursor.insertText("[ERR]", cFmt)
|
||||
elif fmt == TextFmt.FIELD:
|
||||
if field := data.partition(":")[2]:
|
||||
self._usedFields.append((cursor.position(), field))
|
||||
cursor.insertText("0", cFmt)
|
||||
pass
|
||||
|
||||
# Move pos for next pass
|
||||
start = pos
|
||||
|
||||
# Insert whatever is left in the buffer
|
||||
cursor.insertText(temp[start:], cFmt)
|
||||
|
||||
return
|
||||
|
||||
def _insertNewPageMarker(self, cursor: QTextCursor) -> None:
|
||||
"""Insert a new page marker."""
|
||||
if self._newPage:
|
||||
bgCol = QColor(self._theme.text)
|
||||
bgCol.setAlphaF(0.1)
|
||||
fgCol = QColor(self._theme.text)
|
||||
fgCol.setAlphaF(0.8)
|
||||
|
||||
fFmt = QTextFrameFormat()
|
||||
fFmt.setBorderStyle(QTextFrameFormat.BorderStyle.BorderStyle_None)
|
||||
fFmt.setBackground(bgCol)
|
||||
fFmt.setTopMargin(self._mSep[0])
|
||||
fFmt.setBottomMargin(self._mSep[1])
|
||||
|
||||
bFmt = QTextBlockFormat(self._blockFmt)
|
||||
bFmt.setAlignment(QtAlignCenter)
|
||||
bFmt.setTopMargin(0.0)
|
||||
bFmt.setBottomMargin(0.0)
|
||||
bFmt.setLineHeight(100.0, QtPropLineHeight)
|
||||
|
||||
cFmt = QTextCharFormat(self._charFmt)
|
||||
cFmt.setFontItalic(False)
|
||||
cFmt.setFontUnderline(False)
|
||||
cFmt.setFontStrikeOut(False)
|
||||
cFmt.setFontWeight(QFont.Weight.Normal)
|
||||
cFmt.setFontPointSize(0.75*self._textFont.pointSizeF())
|
||||
cFmt.setForeground(fgCol)
|
||||
|
||||
cursor.insertFrame(fFmt)
|
||||
cursor.setBlockFormat(bFmt)
|
||||
cursor.insertText(self._project.localLookup("New Page"), cFmt)
|
||||
cursor.swap(self._document.rootFrame().lastCursorPosition())
|
||||
|
||||
return
|
||||
|
||||
def _genHeadStyle(self, hType: BlockTyp, hKey: str, rFmt: QTextBlockFormat) -> T_TextStyle:
|
||||
"""Generate a heading style set."""
|
||||
mTop, mBottom = self._mHead.get(hType, (0.0, 0.0))
|
||||
|
||||
bFmt = QTextBlockFormat(rFmt)
|
||||
bFmt.setTopMargin(mTop)
|
||||
bFmt.setBottomMargin(mBottom)
|
||||
|
||||
hCol = self._colorHeads and hType != BlockTyp.TITLE
|
||||
cFmt = QTextCharFormat(self._charFmt)
|
||||
cFmt.setForeground(self._theme.head if hCol else self._theme.text)
|
||||
cFmt.setFontWeight(self._hWeight)
|
||||
cFmt.setFontPointSize(self._sHead.get(hType, 1.0))
|
||||
if hKey and self._anchors:
|
||||
cFmt.setAnchorNames([hKey])
|
||||
cFmt.setAnchor(True)
|
||||
|
||||
return bFmt, cFmt
|
||||
91
novelwriter/formats/toraw.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
novelWriter – Raw NW Text Format
|
||||
================================
|
||||
|
||||
File History:
|
||||
Created: 2024-10-15 [2.6b1] ToRaw
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
|
||||
from novelwriter.common import formatTimeStamp
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.formats.tokenizer import Tokenizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToRaw(Tokenizer):
|
||||
"""Core: Raw novelWriter Text Writer
|
||||
|
||||
A class that will collect the minimally altered original source text
|
||||
and write it to either a text or JSON file.
|
||||
"""
|
||||
|
||||
def __init__(self, project: NWProject) -> None:
|
||||
super().__init__(project)
|
||||
self._keepRaw = True
|
||||
self._noTokens = True
|
||||
return
|
||||
|
||||
def doConvert(self) -> None:
|
||||
"""No conversion to perform."""
|
||||
return
|
||||
|
||||
def closeDocument(self) -> None:
|
||||
"""Nothing to close."""
|
||||
return
|
||||
|
||||
def saveDocument(self, path: Path) -> None:
|
||||
"""Save the raw text to a plain text file."""
|
||||
if path.suffix.lower() == ".json":
|
||||
ts = time()
|
||||
data = {
|
||||
"meta": {
|
||||
"projectName": self._project.data.name,
|
||||
"novelAuthor": self._project.data.author,
|
||||
"buildTime": int(ts),
|
||||
"buildTimeStr": formatTimeStamp(ts),
|
||||
},
|
||||
"text": {
|
||||
"nwd": [page.rstrip("\n").split("\n") for page in self._raw],
|
||||
}
|
||||
}
|
||||
with open(path, mode="w", encoding="utf-8") as fObj:
|
||||
json.dump(data, fObj, indent=2)
|
||||
|
||||
else:
|
||||
with open(path, mode="w", encoding="utf-8") as outFile:
|
||||
for nwdPage in self._raw:
|
||||
outFile.write(nwdPage)
|
||||
|
||||
logger.info("Wrote file: %s", path)
|
||||
|
||||
return
|
||||
|
||||
def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " ") -> None:
|
||||
"""Replace tabs with spaces."""
|
||||
spaces = spaceChar*nSpaces
|
||||
self._raw = [p.replace("\t", spaces) for p in self._raw]
|
||||
return
|
||||
@@ -25,10 +25,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from time import time
|
||||
|
||||
from PyQt5.QtCore import QRegularExpression, Qt
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import (
|
||||
QBrush, QColor, QFont, QSyntaxHighlighter, QTextBlockUserData,
|
||||
QTextCharFormat, QTextDocument
|
||||
@@ -36,20 +37,17 @@ from PyQt5.QtGui import (
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import checkInt
|
||||
from novelwriter.constants import nwHeaders, nwRegEx, nwUnicode
|
||||
from novelwriter.constants import nwStyles, nwUnicode
|
||||
from novelwriter.core.index import processComment
|
||||
from novelwriter.enum import nwComment
|
||||
from novelwriter.text.patterns import REGEX_PATTERNS
|
||||
from novelwriter.types import QRegExUnicode
|
||||
from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—\[\]:]+\b")
|
||||
SPELLRX.setPatternOptions(QRegExUnicode)
|
||||
SPELLSC = QRegularExpression(nwRegEx.FMT_SC)
|
||||
SPELLSC.setPatternOptions(QRegExUnicode)
|
||||
SPELLSV = QRegularExpression(nwRegEx.FMT_SV)
|
||||
SPELLSV.setPatternOptions(QRegExUnicode)
|
||||
RX_URL = REGEX_PATTERNS.url
|
||||
RX_WORDS = REGEX_PATTERNS.wordSplit
|
||||
RX_FMT_SC = REGEX_PATTERNS.shortcodePlain
|
||||
RX_FMT_SV = REGEX_PATTERNS.shortcodeValue
|
||||
|
||||
BLOCK_NONE = 0
|
||||
BLOCK_TEXT = 1
|
||||
@@ -61,7 +59,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
|
||||
__slots__ = (
|
||||
"_tHandle", "_isNovel", "_isInactive", "_spellCheck", "_spellErr",
|
||||
"_hStyles", "_minRules", "_txtRules", "_cmnRules",
|
||||
"_hStyles", "_minRules", "_txtRules", "_cmnRules", "_dialogParser",
|
||||
)
|
||||
|
||||
def __init__(self, document: QTextDocument) -> None:
|
||||
@@ -76,9 +74,11 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
self._spellErr = QTextCharFormat()
|
||||
|
||||
self._hStyles: dict[str, QTextCharFormat] = {}
|
||||
self._minRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = []
|
||||
self._txtRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = []
|
||||
self._cmnRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = []
|
||||
self._minRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = []
|
||||
self._txtRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = []
|
||||
self._cmnRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = []
|
||||
|
||||
self._dialogParser = DialogParser()
|
||||
|
||||
self.initHighlighter()
|
||||
|
||||
@@ -98,14 +98,14 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
|
||||
# Create Character Formats
|
||||
self._addCharFormat("text", SHARED.theme.colText)
|
||||
self._addCharFormat("header1", SHARED.theme.colHead, "b", nwHeaders.H_SIZES[1])
|
||||
self._addCharFormat("header2", SHARED.theme.colHead, "b", nwHeaders.H_SIZES[2])
|
||||
self._addCharFormat("header3", SHARED.theme.colHead, "b", nwHeaders.H_SIZES[3])
|
||||
self._addCharFormat("header4", SHARED.theme.colHead, "b", nwHeaders.H_SIZES[4])
|
||||
self._addCharFormat("head1h", SHARED.theme.colHeadH, "b", nwHeaders.H_SIZES[1])
|
||||
self._addCharFormat("head2h", SHARED.theme.colHeadH, "b", nwHeaders.H_SIZES[2])
|
||||
self._addCharFormat("head3h", SHARED.theme.colHeadH, "b", nwHeaders.H_SIZES[3])
|
||||
self._addCharFormat("head4h", SHARED.theme.colHeadH, "b", nwHeaders.H_SIZES[4])
|
||||
self._addCharFormat("header1", SHARED.theme.colHead, "b", nwStyles.H_SIZES[1])
|
||||
self._addCharFormat("header2", SHARED.theme.colHead, "b", nwStyles.H_SIZES[2])
|
||||
self._addCharFormat("header3", SHARED.theme.colHead, "b", nwStyles.H_SIZES[3])
|
||||
self._addCharFormat("header4", SHARED.theme.colHead, "b", nwStyles.H_SIZES[4])
|
||||
self._addCharFormat("head1h", SHARED.theme.colHeadH, "b", nwStyles.H_SIZES[1])
|
||||
self._addCharFormat("head2h", SHARED.theme.colHeadH, "b", nwStyles.H_SIZES[2])
|
||||
self._addCharFormat("head3h", SHARED.theme.colHeadH, "b", nwStyles.H_SIZES[3])
|
||||
self._addCharFormat("head4h", SHARED.theme.colHeadH, "b", nwStyles.H_SIZES[4])
|
||||
self._addCharFormat("bold", colEmph, "b")
|
||||
self._addCharFormat("italic", colEmph, "i")
|
||||
self._addCharFormat("strike", SHARED.theme.colHidden, "s")
|
||||
@@ -116,10 +116,11 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
self._addCharFormat("replace", SHARED.theme.colRepTag)
|
||||
self._addCharFormat("hidden", SHARED.theme.colHidden)
|
||||
self._addCharFormat("markup", SHARED.theme.colHidden)
|
||||
self._addCharFormat("link", SHARED.theme.colLink, "u")
|
||||
self._addCharFormat("note", SHARED.theme.colNote)
|
||||
self._addCharFormat("code", SHARED.theme.colCode)
|
||||
self._addCharFormat("keyword", SHARED.theme.colKey)
|
||||
self._addCharFormat("tag", SHARED.theme.colTag)
|
||||
self._addCharFormat("tag", SHARED.theme.colTag, "u")
|
||||
self._addCharFormat("modifier", SHARED.theme.colMod)
|
||||
self._addCharFormat("value", SHARED.theme.colVal)
|
||||
self._addCharFormat("optional", SHARED.theme.colOpt)
|
||||
@@ -133,10 +134,11 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
self._txtRules.clear()
|
||||
self._cmnRules.clear()
|
||||
|
||||
self._dialogParser.initParser()
|
||||
|
||||
# Multiple or Trailing Spaces
|
||||
if CONFIG.showMultiSpaces:
|
||||
rxRule = QRegularExpression(r"[ ]{2,}|[ ]*$")
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
rxRule = re.compile(r"[ ]{2,}|[ ]*$", re.UNICODE)
|
||||
hlRule = {
|
||||
0: self._hStyles["mspaces"],
|
||||
}
|
||||
@@ -145,8 +147,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
self._cmnRules.append((rxRule, hlRule))
|
||||
|
||||
# Non-Breaking Spaces
|
||||
rxRule = QRegularExpression(f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+")
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
rxRule = re.compile(f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+", re.UNICODE)
|
||||
hlRule = {
|
||||
0: self._hStyles["nobreak"],
|
||||
}
|
||||
@@ -154,30 +155,8 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
self._txtRules.append((rxRule, hlRule))
|
||||
self._cmnRules.append((rxRule, hlRule))
|
||||
|
||||
# Dialogue
|
||||
if CONFIG.dialogStyle > 0:
|
||||
rxRule = REGEX_PATTERNS.dialogStyle
|
||||
hlRule = {
|
||||
0: self._hStyles["dialog"],
|
||||
}
|
||||
self._txtRules.append((rxRule, hlRule))
|
||||
|
||||
if CONFIG.dialogLine:
|
||||
rxRule = REGEX_PATTERNS.dialogLine
|
||||
hlRule = {
|
||||
0: self._hStyles["dialog"],
|
||||
}
|
||||
self._txtRules.append((rxRule, hlRule))
|
||||
|
||||
if CONFIG.narratorBreak:
|
||||
rxRule = REGEX_PATTERNS.narratorBreak
|
||||
hlRule = {
|
||||
0: self._hStyles["text"],
|
||||
}
|
||||
self._txtRules.append((rxRule, hlRule))
|
||||
|
||||
if CONFIG.altDialogOpen and CONFIG.altDialogClose:
|
||||
rxRule = REGEX_PATTERNS.altDialogStyle
|
||||
# Alt Dialogue
|
||||
if rxRule := REGEX_PATTERNS.altDialogStyle:
|
||||
hlRule = {
|
||||
0: self._hStyles["altdialog"],
|
||||
}
|
||||
@@ -236,9 +215,17 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
self._txtRules.append((rxRule, hlRule))
|
||||
self._cmnRules.append((rxRule, hlRule))
|
||||
|
||||
# URLs
|
||||
rxRule = REGEX_PATTERNS.url
|
||||
hlRule = {
|
||||
0: self._hStyles["link"],
|
||||
}
|
||||
self._minRules.append((rxRule, hlRule))
|
||||
self._txtRules.append((rxRule, hlRule))
|
||||
self._cmnRules.append((rxRule, hlRule))
|
||||
|
||||
# Alignment Tags
|
||||
rxRule = QRegularExpression(r"(^>{1,2}|<{1,2}$)")
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
rxRule = re.compile(r"(^>{1,2}|<{1,2}$)", re.UNICODE)
|
||||
hlRule = {
|
||||
1: self._hStyles["markup"],
|
||||
}
|
||||
@@ -246,8 +233,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
self._txtRules.append((rxRule, hlRule))
|
||||
|
||||
# Auto-Replace Tags
|
||||
rxRule = QRegularExpression(r"<(\S+?)>")
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
rxRule = re.compile(r"<(\S+?)>", re.UNICODE)
|
||||
hlRule = {
|
||||
0: self._hStyles["replace"],
|
||||
}
|
||||
@@ -406,15 +392,17 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
else: # Text Paragraph
|
||||
self.setCurrentBlockState(BLOCK_TEXT)
|
||||
hRules = self._txtRules if self._isNovel else self._minRules
|
||||
if self._dialogParser.enabled:
|
||||
for pos, end in self._dialogParser(text):
|
||||
length = end - pos
|
||||
self.setFormat(pos, length, self._hStyles["dialog"])
|
||||
|
||||
if hRules:
|
||||
for rX, hRule in hRules:
|
||||
rxItt = rX.globalMatch(text, xOff)
|
||||
while rxItt.hasNext():
|
||||
rxMatch = rxItt.next()
|
||||
for res in re.finditer(rX, text[xOff:]):
|
||||
for xM, hFmt in hRule.items():
|
||||
xPos = rxMatch.capturedStart(xM)
|
||||
xEnd = rxMatch.capturedEnd(xM)
|
||||
xPos = res.start(xM) + xOff
|
||||
xEnd = res.end(xM) + xOff
|
||||
for x in range(xPos, xEnd):
|
||||
cFmt = self.format(x)
|
||||
if cFmt.fontStyleName() != "markup":
|
||||
@@ -426,9 +414,10 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
data = TextBlockData()
|
||||
self.setCurrentBlockUserData(data)
|
||||
|
||||
data.processText(text, xOff)
|
||||
if self._spellCheck:
|
||||
for xPos, xLen in data.spellCheck(text, xOff):
|
||||
for x in range(xPos, xPos+xLen):
|
||||
for xPos, xEnd in data.spellCheck():
|
||||
for x in range(xPos, xEnd):
|
||||
cFmt = self.format(x)
|
||||
cFmt.merge(self._spellErr)
|
||||
self.setFormat(x, 1, cFmt)
|
||||
@@ -456,6 +445,8 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
charFormat.setFontWeight(QFont.Weight.Bold)
|
||||
if "i" in styles:
|
||||
charFormat.setFontItalic(True)
|
||||
if "u" in styles:
|
||||
charFormat.setFontUnderline(True)
|
||||
if "s" in styles:
|
||||
charFormat.setFontStrikeOut(True)
|
||||
if "err" in styles:
|
||||
@@ -474,40 +465,61 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
||||
|
||||
class TextBlockData(QTextBlockUserData):
|
||||
|
||||
__slots__ = ("_spellErrors")
|
||||
__slots__ = ("_text", "_offset", "_metaData", "_spellErrors")
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._spellErrors: list[tuple[int, int]] = []
|
||||
self._text = ""
|
||||
self._offset = 0
|
||||
self._metaData: list[tuple[int, int, str, str]] = []
|
||||
self._spellErrors: list[tuple[int, int,]] = []
|
||||
return
|
||||
|
||||
@property
|
||||
def metaData(self) -> list[tuple[int, int, str, str]]:
|
||||
"""Return meta data from last check."""
|
||||
return self._metaData
|
||||
|
||||
@property
|
||||
def spellErrors(self) -> list[tuple[int, int]]:
|
||||
"""Return spell error data from last check."""
|
||||
return self._spellErrors
|
||||
|
||||
def spellCheck(self, text: str, offset: int) -> list[tuple[int, int]]:
|
||||
def processText(self, text: str, offset: int) -> None:
|
||||
"""Extract meta data from the text."""
|
||||
self._metaData = []
|
||||
if "[" in text:
|
||||
# Strip shortcodes
|
||||
for regEx in [RX_FMT_SC, RX_FMT_SV]:
|
||||
for res in regEx.finditer(text, offset):
|
||||
if (s := res.start(0)) >= 0 and (e := res.end(0)) >= 0:
|
||||
pad = " "*(e - s)
|
||||
text = f"{text[:s]}{pad}{text[e:]}"
|
||||
|
||||
if "http" in text:
|
||||
# Strip URLs
|
||||
for res in RX_URL.finditer(text, offset):
|
||||
if (s := res.start(0)) >= 0 and (e := res.end(0)) >= 0:
|
||||
pad = " "*(e - s)
|
||||
text = f"{text[:s]}{pad}{text[e:]}"
|
||||
self._metaData.append((s, e, res.group(0), "url"))
|
||||
|
||||
self._text = text
|
||||
self._offset = offset
|
||||
|
||||
return
|
||||
|
||||
def spellCheck(self) -> list[tuple[int, int]]:
|
||||
"""Run the spell checker and cache the result, and return the
|
||||
list of spell check errors.
|
||||
"""
|
||||
if "[" in text:
|
||||
# Strip shortcodes
|
||||
for rX in [SPELLSC, SPELLSV]:
|
||||
rxItt = rX.globalMatch(text, offset)
|
||||
while rxItt.hasNext():
|
||||
rxMatch = rxItt.next()
|
||||
xPos = rxMatch.capturedStart(0)
|
||||
xLen = rxMatch.capturedLength(0)
|
||||
xEnd = rxMatch.capturedEnd(0)
|
||||
text = text[:xPos] + " "*xLen + text[xEnd:]
|
||||
|
||||
self._spellErrors = []
|
||||
rxSpell = SPELLRX.globalMatch(text.replace("_", " "), offset)
|
||||
while rxSpell.hasNext():
|
||||
rxMatch = rxSpell.next()
|
||||
if not SHARED.spelling.checkWord(rxMatch.captured(0)):
|
||||
if not rxMatch.captured(0).isnumeric() and not rxMatch.captured(0).isupper():
|
||||
self._spellErrors.append(
|
||||
(rxMatch.capturedStart(0), rxMatch.capturedLength(0))
|
||||
)
|
||||
checker = SHARED.spelling
|
||||
for res in RX_WORDS.finditer(self._text.replace("_", " "), self._offset):
|
||||
if (
|
||||
(word := res.group(0))
|
||||
and not (word.isnumeric() or word.isupper() or checker.checkWord(word))
|
||||
):
|
||||
self._spellErrors.append((res.start(0), res.end(0)))
|
||||
|
||||
return self._spellErrors
|
||||
|
||||
@@ -31,34 +31,43 @@ import logging
|
||||
from enum import Enum
|
||||
|
||||
from PyQt5.QtCore import QPoint, Qt, QUrl, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtGui import QCursor, QMouseEvent, QPalette, QResizeEvent, QTextCursor
|
||||
from PyQt5.QtGui import (
|
||||
QCursor, QDesktopServices, QDragEnterEvent, QDragMoveEvent, QDropEvent,
|
||||
QMouseEvent, QPalette, QResizeEvent, QTextCursor
|
||||
)
|
||||
from PyQt5.QtWidgets import (
|
||||
QAction, QApplication, QFrame, QHBoxLayout, QMenu, QTextBrowser,
|
||||
QToolButton, QWidget
|
||||
)
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.constants import nwHeaders, nwUnicode
|
||||
from novelwriter.core.toqdoc import TextDocumentTheme, ToQTextDocument
|
||||
from novelwriter.enum import nwDocAction, nwDocMode, nwItemType
|
||||
from novelwriter.common import decodeMimeHandles, qtLambda
|
||||
from novelwriter.constants import nwConst, nwStyles, nwUnicode
|
||||
from novelwriter.enum import nwChange, nwDocAction, nwDocMode, nwItemType
|
||||
from novelwriter.error import logException
|
||||
from novelwriter.extensions.configlayout import NColourLabel
|
||||
from novelwriter.extensions.eventfilters import WheelEventFilter
|
||||
from novelwriter.extensions.modified import NIconToolButton
|
||||
from novelwriter.formats.shared import TextDocumentTheme
|
||||
from novelwriter.formats.toqdoc import ToQTextDocument
|
||||
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
|
||||
from novelwriter.types import QtAlignCenterTop, QtKeepAnchor, QtMouseLeft, QtMoveAnchor
|
||||
from novelwriter.types import (
|
||||
QtAlignCenterTop, QtKeepAnchor, QtMouseLeft, QtMoveAnchor,
|
||||
QtScrollAlwaysOff, QtScrollAsNeeded
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GuiDocViewer(QTextBrowser):
|
||||
|
||||
closeDocumentRequest = pyqtSignal()
|
||||
documentLoaded = pyqtSignal(str)
|
||||
loadDocumentTagRequest = pyqtSignal(str, Enum)
|
||||
closeDocumentRequest = pyqtSignal()
|
||||
openDocumentRequest = pyqtSignal(str, Enum, str, bool)
|
||||
reloadDocumentRequest = pyqtSignal()
|
||||
togglePanelVisibility = pyqtSignal()
|
||||
requestProjectItemSelected = pyqtSignal(str, bool)
|
||||
togglePanelVisibility = pyqtSignal()
|
||||
|
||||
def __init__(self, parent: QWidget) -> None:
|
||||
super().__init__(parent=parent)
|
||||
@@ -72,6 +81,7 @@ class GuiDocViewer(QTextBrowser):
|
||||
# Settings
|
||||
self.setMinimumWidth(CONFIG.pxInt(300))
|
||||
self.setAutoFillBackground(True)
|
||||
self.setOpenLinks(False)
|
||||
self.setOpenExternalLinks(False)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setFrameStyle(QFrame.Shape.NoFrame)
|
||||
@@ -162,6 +172,7 @@ class GuiDocViewer(QTextBrowser):
|
||||
self._docTheme.text = SHARED.theme.colText
|
||||
self._docTheme.highlight = SHARED.theme.colMark
|
||||
self._docTheme.head = SHARED.theme.colHead
|
||||
self._docTheme.link = SHARED.theme.colLink
|
||||
self._docTheme.comment = SHARED.theme.colHidden
|
||||
self._docTheme.note = SHARED.theme.colNote
|
||||
self._docTheme.code = SHARED.theme.colCode
|
||||
@@ -177,14 +188,14 @@ class GuiDocViewer(QTextBrowser):
|
||||
|
||||
# Scroll bars
|
||||
if CONFIG.hideVScroll:
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
|
||||
else:
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
|
||||
|
||||
if CONFIG.hideHScroll:
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
|
||||
else:
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
|
||||
|
||||
# Refresh the tab stops
|
||||
self.setTabStopDistance(CONFIG.getTabWidth())
|
||||
@@ -207,8 +218,10 @@ class GuiDocViewer(QTextBrowser):
|
||||
sPos = self.verticalScrollBar().value()
|
||||
qDoc = ToQTextDocument(SHARED.project)
|
||||
qDoc.setJustify(CONFIG.doJustify)
|
||||
qDoc.setDialogueHighlight(True)
|
||||
qDoc.initDocument(CONFIG.textFont, self._docTheme)
|
||||
qDoc.setDialogHighlight(True)
|
||||
qDoc.setTextFont(CONFIG.textFont)
|
||||
qDoc.setTheme(self._docTheme)
|
||||
qDoc.initDocument()
|
||||
qDoc.setKeywords(True)
|
||||
qDoc.setComments(CONFIG.viewComments)
|
||||
qDoc.setSynopsis(CONFIG.viewSynopsis)
|
||||
@@ -221,7 +234,7 @@ class GuiDocViewer(QTextBrowser):
|
||||
qDoc.doPreProcessing()
|
||||
qDoc.tokenizeText()
|
||||
qDoc.doConvert()
|
||||
qDoc.appendFootnotes()
|
||||
qDoc.closeDocument()
|
||||
except Exception:
|
||||
logger.error("Failed to generate preview for document with handle '%s'", tHandle)
|
||||
logException()
|
||||
@@ -229,15 +242,13 @@ class GuiDocViewer(QTextBrowser):
|
||||
QApplication.restoreOverrideCursor()
|
||||
return False
|
||||
|
||||
# Refresh the tab stops
|
||||
self.setTabStopDistance(CONFIG.getTabWidth())
|
||||
|
||||
# Must be before setHtml
|
||||
# Must be before setDocument
|
||||
if updateHistory:
|
||||
self.docHistory.append(tHandle)
|
||||
|
||||
self.setDocumentTitle(tHandle)
|
||||
self.setDocument(qDoc.document)
|
||||
self.setTabStopDistance(CONFIG.getTabWidth())
|
||||
|
||||
if self._docHandle == tHandle:
|
||||
# This is a refresh, so we set the scrollbar back to where it was
|
||||
@@ -247,7 +258,7 @@ class GuiDocViewer(QTextBrowser):
|
||||
SHARED.project.data.setLastHandle(tHandle, "viewer")
|
||||
self.docHeader.setHandle(tHandle)
|
||||
self.docHeader.setOutline({
|
||||
sTitle: (hItem.title, nwHeaders.H_LEVEL.get(hItem.level, 0))
|
||||
sTitle: (hItem.title, nwStyles.H_LEVEL.get(hItem.level, 0))
|
||||
for sTitle, hItem in SHARED.project.index.iterItemHeadings(tHandle)
|
||||
})
|
||||
self.updateDocMargins()
|
||||
@@ -335,10 +346,10 @@ class GuiDocViewer(QTextBrowser):
|
||||
# Public Slots
|
||||
##
|
||||
|
||||
@pyqtSlot(str)
|
||||
def updateDocInfo(self, tHandle: str) -> None:
|
||||
@pyqtSlot(str, Enum)
|
||||
def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
|
||||
"""Update the header title bar if needed."""
|
||||
if tHandle and tHandle == self._docHandle:
|
||||
if tHandle == self._docHandle and change == nwChange.UPDATE:
|
||||
self.docHeader.setHandle(tHandle)
|
||||
self.updateDocMargins()
|
||||
return
|
||||
@@ -374,8 +385,10 @@ class GuiDocViewer(QTextBrowser):
|
||||
logger.debug("Clicked link: '%s'", link)
|
||||
if (bits := link.partition("_")) and bits[0] == "#tag" and bits[2]:
|
||||
self.loadDocumentTagRequest.emit(bits[2], nwDocMode.VIEW)
|
||||
else:
|
||||
elif link.startswith("#"):
|
||||
self.navigateTo(link)
|
||||
elif link.startswith("http"):
|
||||
QDesktopServices.openUrl(QUrl(url))
|
||||
return
|
||||
|
||||
@pyqtSlot("QPoint")
|
||||
@@ -388,25 +401,25 @@ class GuiDocViewer(QTextBrowser):
|
||||
|
||||
if userSelection:
|
||||
mnuCopy = QAction(self.tr("Copy"), ctxMenu)
|
||||
mnuCopy.triggered.connect(lambda: self.docAction(nwDocAction.COPY))
|
||||
mnuCopy.triggered.connect(qtLambda(self.docAction, nwDocAction.COPY))
|
||||
ctxMenu.addAction(mnuCopy)
|
||||
|
||||
ctxMenu.addSeparator()
|
||||
|
||||
mnuSelAll = QAction(self.tr("Select All"), ctxMenu)
|
||||
mnuSelAll.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL))
|
||||
mnuSelAll.triggered.connect(qtLambda(self.docAction, nwDocAction.SEL_ALL))
|
||||
ctxMenu.addAction(mnuSelAll)
|
||||
|
||||
mnuSelWord = QAction(self.tr("Select Word"), ctxMenu)
|
||||
mnuSelWord.triggered.connect(
|
||||
lambda: self._makePosSelection(QTextCursor.SelectionType.WordUnderCursor, point)
|
||||
)
|
||||
mnuSelWord.triggered.connect(qtLambda(
|
||||
self._makePosSelection, QTextCursor.SelectionType.WordUnderCursor, point
|
||||
))
|
||||
ctxMenu.addAction(mnuSelWord)
|
||||
|
||||
mnuSelPara = QAction(self.tr("Select Paragraph"), ctxMenu)
|
||||
mnuSelPara.triggered.connect(
|
||||
lambda: self._makePosSelection(QTextCursor.SelectionType.BlockUnderCursor, point)
|
||||
)
|
||||
mnuSelPara.triggered.connect(qtLambda(
|
||||
self._makePosSelection, QTextCursor.SelectionType.BlockUnderCursor, point
|
||||
))
|
||||
ctxMenu.addAction(mnuSelPara)
|
||||
|
||||
# Open the context menu
|
||||
@@ -435,6 +448,32 @@ class GuiDocViewer(QTextBrowser):
|
||||
super().mouseReleaseEvent(event)
|
||||
return
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
||||
"""Overload drag enter event to handle dragged items."""
|
||||
if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
super().dragEnterEvent(event)
|
||||
return
|
||||
|
||||
def dragMoveEvent(self, event: QDragMoveEvent) -> None:
|
||||
"""Overload drag move event to handle dragged items."""
|
||||
if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
super().dragMoveEvent(event)
|
||||
return
|
||||
|
||||
def dropEvent(self, event: QDropEvent) -> None:
|
||||
"""Overload drop event to handle dragged items."""
|
||||
if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
|
||||
if handles := decodeMimeHandles(event.mimeData()):
|
||||
if SHARED.project.tree.checkType(handles[0], nwItemType.FILE):
|
||||
self.openDocumentRequest.emit(handles[0], nwDocMode.VIEW, "", True)
|
||||
else:
|
||||
super().dropEvent(event)
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
@@ -631,6 +670,11 @@ class GuiDocViewHeader(QWidget):
|
||||
self.forwardButton.setToolTip(self.tr("Go Forward"))
|
||||
self.forwardButton.clicked.connect(self.docViewer.navForward)
|
||||
|
||||
self.editButton = NIconToolButton(self, iSz)
|
||||
self.editButton.setVisible(False)
|
||||
self.editButton.setToolTip(self.tr("Open in Editor"))
|
||||
self.editButton.clicked.connect(self._editDocument)
|
||||
|
||||
self.refreshButton = NIconToolButton(self, iSz)
|
||||
self.refreshButton.setVisible(False)
|
||||
self.refreshButton.setToolTip(self.tr("Reload"))
|
||||
@@ -649,7 +693,7 @@ class GuiDocViewHeader(QWidget):
|
||||
self.outerBox.addSpacing(mPx)
|
||||
self.outerBox.addWidget(self.itemTitle, 1)
|
||||
self.outerBox.addSpacing(mPx)
|
||||
self.outerBox.addSpacing(iPx)
|
||||
self.outerBox.addWidget(self.editButton, 0)
|
||||
self.outerBox.addWidget(self.refreshButton, 0)
|
||||
self.outerBox.addWidget(self.closeButton, 0)
|
||||
self.outerBox.setSpacing(0)
|
||||
@@ -683,8 +727,9 @@ class GuiDocViewHeader(QWidget):
|
||||
self.outlineButton.setVisible(False)
|
||||
self.backButton.setVisible(False)
|
||||
self.forwardButton.setVisible(False)
|
||||
self.closeButton.setVisible(False)
|
||||
self.editButton.setVisible(False)
|
||||
self.refreshButton.setVisible(False)
|
||||
self.closeButton.setVisible(False)
|
||||
return
|
||||
|
||||
def setOutline(self, data: dict[str, tuple[str, int]]) -> None:
|
||||
@@ -718,6 +763,7 @@ class GuiDocViewHeader(QWidget):
|
||||
self.outlineButton.setThemeIcon("list")
|
||||
self.backButton.setThemeIcon("backward")
|
||||
self.forwardButton.setThemeIcon("forward")
|
||||
self.editButton.setThemeIcon("edit")
|
||||
self.refreshButton.setThemeIcon("refresh")
|
||||
self.closeButton.setThemeIcon("close")
|
||||
|
||||
@@ -725,6 +771,7 @@ class GuiDocViewHeader(QWidget):
|
||||
self.outlineButton.setStyleSheet(buttonStyle)
|
||||
self.backButton.setStyleSheet(buttonStyle)
|
||||
self.forwardButton.setStyleSheet(buttonStyle)
|
||||
self.editButton.setStyleSheet(buttonStyle)
|
||||
self.refreshButton.setStyleSheet(buttonStyle)
|
||||
self.closeButton.setStyleSheet(buttonStyle)
|
||||
|
||||
@@ -759,7 +806,7 @@ class GuiDocViewHeader(QWidget):
|
||||
|
||||
if CONFIG.showFullPath:
|
||||
self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed(
|
||||
[name for name in SHARED.project.tree.getItemPath(tHandle, asName=True)]
|
||||
[name for name in SHARED.project.tree.itemPath(tHandle, asName=True)]
|
||||
)))
|
||||
else:
|
||||
self.itemTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "")
|
||||
@@ -767,8 +814,9 @@ class GuiDocViewHeader(QWidget):
|
||||
self.backButton.setVisible(True)
|
||||
self.forwardButton.setVisible(True)
|
||||
self.outlineButton.setVisible(True)
|
||||
self.closeButton.setVisible(True)
|
||||
self.editButton.setVisible(True)
|
||||
self.refreshButton.setVisible(True)
|
||||
self.closeButton.setVisible(True)
|
||||
|
||||
return
|
||||
|
||||
@@ -795,6 +843,13 @@ class GuiDocViewHeader(QWidget):
|
||||
self.docViewer.reloadDocumentRequest.emit()
|
||||
return
|
||||
|
||||
@pyqtSlot()
|
||||
def _editDocument(self) -> None:
|
||||
"""Open the document in the editor."""
|
||||
if tHandle := self._docHandle:
|
||||
self.docViewer.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "", True)
|
||||
return
|
||||
|
||||
##
|
||||
# Events
|
||||
##
|
||||
|
||||
@@ -29,18 +29,18 @@ from enum import Enum
|
||||
|
||||
from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import (
|
||||
QAbstractItemView, QFrame, QHeaderView, QMenu, QTabWidget, QToolButton,
|
||||
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
QAbstractItemView, QFrame, QMenu, QTabWidget, QToolButton, QTreeWidget,
|
||||
QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
)
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import checkInt
|
||||
from novelwriter.constants import nwHeaders, nwLabels, nwLists, trConst
|
||||
from novelwriter.constants import nwLabels, nwLists, nwStyles, trConst
|
||||
from novelwriter.core.index import IndexHeading, IndexItem
|
||||
from novelwriter.enum import nwDocMode, nwItemClass
|
||||
from novelwriter.enum import nwChange, nwDocMode, nwItemClass
|
||||
from novelwriter.extensions.modified import NIconToolButton
|
||||
from novelwriter.gui.theme import STYLES_FLAT_TABS, STYLES_MIN_TOOLBUTTON
|
||||
from novelwriter.types import QtDecoration, QtUserRole
|
||||
from novelwriter.types import QtDecoration, QtHeaderFixed, QtHeaderToContents, QtUserRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -151,8 +151,8 @@ class GuiDocViewerPanel(QWidget):
|
||||
self.updateHandle(self._lastHandle)
|
||||
return
|
||||
|
||||
@pyqtSlot(str)
|
||||
def projectItemChanged(self, tHandle: str) -> None:
|
||||
@pyqtSlot(str, Enum)
|
||||
def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
|
||||
"""Update meta data for project item."""
|
||||
self.tabBackRefs.refreshDocument(tHandle)
|
||||
activeOnly = self.aInactive.isChecked()
|
||||
@@ -189,6 +189,13 @@ class GuiDocViewerPanel(QWidget):
|
||||
self._updateTabVisibility()
|
||||
return
|
||||
|
||||
@pyqtSlot(str)
|
||||
def updateStatusLabels(self, kind: str) -> None:
|
||||
"""Update the importance labels."""
|
||||
if kind == "i":
|
||||
self._loadAllTags()
|
||||
return
|
||||
|
||||
##
|
||||
# Private Slots
|
||||
##
|
||||
@@ -252,10 +259,10 @@ class _ViewPanelBackRefs(QTreeWidget):
|
||||
treeHeader = self.header()
|
||||
treeHeader.setStretchLastSection(True)
|
||||
treeHeader.setMinimumSectionSize(iPx + cMg) # See Issue #1627
|
||||
treeHeader.setSectionResizeMode(self.C_DOC, QHeaderView.ResizeMode.ResizeToContents)
|
||||
treeHeader.setSectionResizeMode(self.C_EDIT, QHeaderView.ResizeMode.Fixed)
|
||||
treeHeader.setSectionResizeMode(self.C_VIEW, QHeaderView.ResizeMode.Fixed)
|
||||
treeHeader.setSectionResizeMode(self.C_TITLE, QHeaderView.ResizeMode.ResizeToContents)
|
||||
treeHeader.setSectionResizeMode(self.C_DOC, QtHeaderToContents)
|
||||
treeHeader.setSectionResizeMode(self.C_EDIT, QtHeaderFixed)
|
||||
treeHeader.setSectionResizeMode(self.C_VIEW, QtHeaderFixed)
|
||||
treeHeader.setSectionResizeMode(self.C_TITLE, QtHeaderToContents)
|
||||
treeHeader.resizeSection(self.C_EDIT, iPx + cMg)
|
||||
treeHeader.resizeSection(self.C_VIEW, iPx + cMg)
|
||||
treeHeader.setSectionsMovable(False)
|
||||
@@ -332,17 +339,13 @@ class _ViewPanelBackRefs(QTreeWidget):
|
||||
def _setTreeItemValues(self, tHandle: str, sTitle: str, hItem: IndexHeading) -> None:
|
||||
"""Add or update a tree item."""
|
||||
if nwItem := SHARED.project.tree[tHandle]:
|
||||
docIcon = SHARED.theme.getItemIcon(
|
||||
nwItem.itemType, nwItem.itemClass,
|
||||
nwItem.itemLayout, nwItem.mainHeading
|
||||
)
|
||||
iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0) if nwItem.isDocumentLayout() else 5
|
||||
iLevel = nwStyles.H_LEVEL.get(hItem.level, 0) if nwItem.isDocumentLayout() else 5
|
||||
hDec = SHARED.theme.getHeaderDecorationNarrow(iLevel)
|
||||
|
||||
tKey = f"{tHandle}:{sTitle}"
|
||||
trItem = self._treeMap[tKey] if tKey in self._treeMap else QTreeWidgetItem()
|
||||
|
||||
trItem.setIcon(self.C_DOC, docIcon)
|
||||
trItem.setIcon(self.C_DOC, nwItem.getMainIcon())
|
||||
trItem.setText(self.C_DOC, nwItem.itemName)
|
||||
trItem.setToolTip(self.C_DOC, nwItem.itemName)
|
||||
trItem.setIcon(self.C_EDIT, self._editIcon)
|
||||
@@ -400,8 +403,8 @@ class _ViewPanelKeyWords(QTreeWidget):
|
||||
treeHeader = self.header()
|
||||
treeHeader.setStretchLastSection(True)
|
||||
treeHeader.setMinimumSectionSize(iPx + cMg) # See Issue #1627
|
||||
treeHeader.setSectionResizeMode(self.C_EDIT, QHeaderView.ResizeMode.Fixed)
|
||||
treeHeader.setSectionResizeMode(self.C_VIEW, QHeaderView.ResizeMode.Fixed)
|
||||
treeHeader.setSectionResizeMode(self.C_EDIT, QtHeaderFixed)
|
||||
treeHeader.setSectionResizeMode(self.C_VIEW, QtHeaderFixed)
|
||||
treeHeader.resizeSection(self.C_EDIT, iPx + cMg)
|
||||
treeHeader.resizeSection(self.C_VIEW, iPx + cMg)
|
||||
treeHeader.setSectionsMovable(False)
|
||||
@@ -441,12 +444,8 @@ class _ViewPanelKeyWords(QTreeWidget):
|
||||
def addUpdateEntry(self, tag: str, name: str, iItem: IndexItem, hItem: IndexHeading) -> None:
|
||||
"""Add a new entry, or update an existing one."""
|
||||
nwItem = iItem.item
|
||||
docIcon = SHARED.theme.getItemIcon(
|
||||
nwItem.itemType, nwItem.itemClass,
|
||||
nwItem.itemLayout, nwItem.mainHeading
|
||||
)
|
||||
impLabel, impIcon = nwItem.getImportStatus()
|
||||
iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0) if nwItem.isDocumentLayout() else 5
|
||||
iLevel = nwStyles.H_LEVEL.get(hItem.level, 0) if nwItem.isDocumentLayout() else 5
|
||||
hDec = SHARED.theme.getHeaderDecorationNarrow(iLevel)
|
||||
|
||||
# This can not use a get call to the dictionary as that would create an
|
||||
@@ -461,7 +460,7 @@ class _ViewPanelKeyWords(QTreeWidget):
|
||||
trItem.setIcon(self.C_IMPORT, impIcon)
|
||||
trItem.setText(self.C_IMPORT, impLabel)
|
||||
trItem.setToolTip(self.C_IMPORT, impLabel)
|
||||
trItem.setIcon(self.C_DOC, docIcon)
|
||||
trItem.setIcon(self.C_DOC, nwItem.getMainIcon())
|
||||
trItem.setText(self.C_DOC, nwItem.itemName)
|
||||
trItem.setToolTip(self.C_DOC, nwItem.itemName)
|
||||
trItem.setData(self.C_TITLE, QtDecoration, hDec)
|
||||
|
||||
@@ -95,6 +95,21 @@ class GuiTextDocument(QTextDocument):
|
||||
|
||||
return
|
||||
|
||||
def metaDataAtPos(self, pos: int) -> tuple[str, str]:
|
||||
"""Check if there is meta data available at a given position in
|
||||
the document, and if so, return it.
|
||||
"""
|
||||
cursor = QTextCursor(self)
|
||||
cursor.setPosition(pos)
|
||||
block = cursor.block()
|
||||
data = block.userData()
|
||||
if block.isValid() and isinstance(data, TextBlockData):
|
||||
if (check := pos - block.position()) >= 0:
|
||||
for cPos, cEnd, cData, cType in data.metaData:
|
||||
if cPos <= check <= cEnd:
|
||||
return cData, cType
|
||||
return "", ""
|
||||
|
||||
def spellErrorAtPos(self, pos: int) -> tuple[str, int, int, list[str]]:
|
||||
"""Check if there is a misspelled word at a given position in
|
||||
the document, and if so, return it.
|
||||
@@ -107,8 +122,8 @@ class GuiTextDocument(QTextDocument):
|
||||
text = block.text()
|
||||
check = pos - block.position()
|
||||
if check >= 0:
|
||||
for cPos, cLen in data.spellErrors:
|
||||
cEnd = cPos + cLen
|
||||
for cPos, cEnd in data.spellErrors:
|
||||
cLen = cEnd - cPos
|
||||
if cPos <= check <= cEnd:
|
||||
word = text[cPos:cEnd]
|
||||
return word, cPos, cLen, SHARED.spelling.suggestWords(word)
|
||||
|
||||
@@ -25,12 +25,15 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtWidgets import QGridLayout, QLabel, QWidget
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import elide
|
||||
from novelwriter.constants import nwLabels, trConst
|
||||
from novelwriter.constants import nwLabels, nwStats, trConst
|
||||
from novelwriter.enum import nwChange
|
||||
from novelwriter.types import (
|
||||
QtAlignLeft, QtAlignLeftBase, QtAlignRight, QtAlignRightBase,
|
||||
QtAlignRightMiddle
|
||||
@@ -62,6 +65,10 @@ class GuiItemDetails(QWidget):
|
||||
fntValue = self.font()
|
||||
fntValue.setPointSizeF(0.9*fPt)
|
||||
|
||||
trStats1 = trConst(nwLabels.STATS_NAME[nwStats.CHARS_ALL])
|
||||
trStats2 = trConst(nwLabels.STATS_NAME[nwStats.WORDS_ALL])
|
||||
trStats3 = trConst(nwLabels.STATS_NAME[nwStats.PARAGRAPHS])
|
||||
|
||||
# Label
|
||||
self.labelName = QLabel(self.tr("Label"), self)
|
||||
self.labelName.setFont(fntLabel)
|
||||
@@ -113,7 +120,7 @@ class GuiItemDetails(QWidget):
|
||||
self.usageData.setWordWrap(True)
|
||||
|
||||
# Character Count
|
||||
self.cCountName = QLabel(" "+self.tr("Characters"), self)
|
||||
self.cCountName = QLabel(trStats1, self)
|
||||
self.cCountName.setFont(fntLabel)
|
||||
self.cCountName.setAlignment(QtAlignRight)
|
||||
|
||||
@@ -122,7 +129,7 @@ class GuiItemDetails(QWidget):
|
||||
self.cCountData.setAlignment(QtAlignRight)
|
||||
|
||||
# Word Count
|
||||
self.wCountName = QLabel(" "+self.tr("Words"), self)
|
||||
self.wCountName = QLabel(trStats2, self)
|
||||
self.wCountName.setFont(fntLabel)
|
||||
self.wCountName.setAlignment(QtAlignRight)
|
||||
|
||||
@@ -131,7 +138,7 @@ class GuiItemDetails(QWidget):
|
||||
self.wCountData.setAlignment(QtAlignRight)
|
||||
|
||||
# Paragraph Count
|
||||
self.pCountName = QLabel(" "+self.tr("Paragraphs"), self)
|
||||
self.pCountName = QLabel(trStats3, self)
|
||||
self.pCountName.setFont(fntLabel)
|
||||
self.pCountName.setAlignment(QtAlignRight)
|
||||
|
||||
@@ -216,19 +223,9 @@ class GuiItemDetails(QWidget):
|
||||
self.updateViewBox(self._handle)
|
||||
return
|
||||
|
||||
##
|
||||
# Public Slots
|
||||
##
|
||||
|
||||
@pyqtSlot(str)
|
||||
def updateViewBox(self, tHandle: str) -> None:
|
||||
def updateViewBox(self, tHandle: str | None) -> None:
|
||||
"""Populate the details box from a given handle."""
|
||||
if tHandle is None:
|
||||
self.clearDetails()
|
||||
return
|
||||
|
||||
nwItem = SHARED.project.tree[tHandle]
|
||||
if nwItem is None:
|
||||
if not (tHandle and (nwItem := SHARED.project.tree[tHandle])):
|
||||
self.clearDetails()
|
||||
return
|
||||
|
||||
@@ -265,10 +262,7 @@ class GuiItemDetails(QWidget):
|
||||
# Layout
|
||||
# ======
|
||||
|
||||
usageIcon = SHARED.theme.getItemIcon(
|
||||
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, nwItem.mainHeading
|
||||
)
|
||||
self.usageIcon.setPixmap(usageIcon.pixmap(iPx, iPx))
|
||||
self.usageIcon.setPixmap(nwItem.getMainIcon().pixmap(iPx, iPx))
|
||||
self.usageData.setText(nwItem.describeMe())
|
||||
|
||||
# Counts
|
||||
@@ -285,13 +279,16 @@ class GuiItemDetails(QWidget):
|
||||
|
||||
return
|
||||
|
||||
@pyqtSlot(str, int, int, int)
|
||||
def updateCounts(self, tHandle: str, cC: int, wC: int, pC: int) -> None:
|
||||
"""Update the counts if the handle is the same as the one we're
|
||||
already showing. Otherwise, do nothing.
|
||||
"""
|
||||
##
|
||||
# Public Slots
|
||||
##
|
||||
|
||||
@pyqtSlot(str, Enum)
|
||||
def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
|
||||
"""Process project item change."""
|
||||
if tHandle == self._handle:
|
||||
self.cCountData.setText(f"{cC:n}")
|
||||
self.wCountData.setText(f"{wC:n}")
|
||||
self.pCountData.setText(f"{pC:n}")
|
||||
if change == nwChange.UPDATE:
|
||||
self.updateViewBox(tHandle)
|
||||
elif change == nwChange.DELETE:
|
||||
self.updateViewBox(None)
|
||||
return
|
||||
|
||||
@@ -28,13 +28,15 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PyQt5.QtCore import QUrl, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import QAction, QMenuBar
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import openExternalPath
|
||||
from novelwriter.constants import nwConst, nwKeyWords, nwLabels, nwUnicode, trConst
|
||||
from novelwriter.common import openExternalPath, qtLambda
|
||||
from novelwriter.constants import (
|
||||
nwConst, nwKeyWords, nwLabels, nwShortcode, nwStats, nwStyles, nwUnicode,
|
||||
trConst
|
||||
)
|
||||
from novelwriter.enum import nwDocAction, nwDocInsert, nwFocus, nwView
|
||||
from novelwriter.extensions.eventfilters import StatusTipFilter
|
||||
|
||||
@@ -104,12 +106,6 @@ class GuiMainMenu(QMenuBar):
|
||||
self.mainGui.docEditor.toggleSpellCheck(None)
|
||||
return
|
||||
|
||||
@pyqtSlot(str)
|
||||
def _openWebsite(self, url: str) -> None:
|
||||
"""Open a URL in the system's default browser."""
|
||||
QDesktopServices.openUrl(QUrl(url))
|
||||
return
|
||||
|
||||
@pyqtSlot()
|
||||
def _openUserManualFile(self) -> None:
|
||||
"""Open the documentation in PDF format."""
|
||||
@@ -141,12 +137,13 @@ class GuiMainMenu(QMenuBar):
|
||||
# Project > Save Project
|
||||
self.aSaveProject = self.projMenu.addAction(self.tr("Save Project"))
|
||||
self.aSaveProject.setShortcut("Ctrl+Shift+S")
|
||||
self.aSaveProject.triggered.connect(lambda: self.mainGui.saveProject())
|
||||
self.aSaveProject.triggered.connect(qtLambda(self.mainGui.saveProject))
|
||||
self.mainGui.addAction(self.aSaveProject)
|
||||
|
||||
# Project > Close Project
|
||||
self.aCloseProject = self.projMenu.addAction(self.tr("Close Project"))
|
||||
self.aCloseProject.setShortcut("Ctrl+Shift+W")
|
||||
self.aCloseProject.triggered.connect(lambda: self.mainGui.closeProject(False))
|
||||
self.aCloseProject.triggered.connect(qtLambda(self.mainGui.closeProject, False))
|
||||
|
||||
# Project > Separator
|
||||
self.projMenu.addSeparator()
|
||||
@@ -165,18 +162,20 @@ class GuiMainMenu(QMenuBar):
|
||||
self.projMenu.addSeparator()
|
||||
|
||||
# Project > Edit
|
||||
self.aEditItem = self.projMenu.addAction(self.tr("Rename Item"))
|
||||
self.aEditItem.setShortcut("F2")
|
||||
self.aEditItem.triggered.connect(lambda: self.mainGui.projView.renameTreeItem(None))
|
||||
self.aRenameItem = self.projMenu.addAction(self.tr("Rename Item"))
|
||||
self.aRenameItem.setShortcut("F2")
|
||||
|
||||
# Project > Delete
|
||||
self.aDeleteItem = self.projMenu.addAction(self.tr("Delete Item"))
|
||||
self.aDeleteItem.setShortcut("Ctrl+Shift+Del") # Cannot be Ctrl+Del, see #629
|
||||
self.aDeleteItem.triggered.connect(lambda: self.mainGui.projView.requestDeleteItem(None))
|
||||
self.aDeleteItem.setShortcut("Del")
|
||||
self.aDeleteItem.setShortcutContext(Qt.ShortcutContext.WidgetShortcut)
|
||||
|
||||
# Project > Empty Trash
|
||||
self.aEmptyTrash = self.projMenu.addAction(self.tr("Empty Trash"))
|
||||
self.aEmptyTrash.triggered.connect(lambda: self.mainGui.projView.emptyTrash())
|
||||
|
||||
self.mainGui.projView.connectMenuActions(
|
||||
self.aRenameItem, self.aDeleteItem, self.aEmptyTrash
|
||||
)
|
||||
|
||||
# Project > Separator
|
||||
self.projMenu.addSeparator()
|
||||
@@ -184,8 +183,9 @@ class GuiMainMenu(QMenuBar):
|
||||
# Project > Exit
|
||||
self.aExitNW = self.projMenu.addAction(self.tr("Exit"))
|
||||
self.aExitNW.setShortcut("Ctrl+Q")
|
||||
self.aExitNW.setMenuRole(QAction.QuitRole)
|
||||
self.aExitNW.triggered.connect(lambda: self.mainGui.closeMain())
|
||||
self.aExitNW.setMenuRole(QAction.MenuRole.QuitRole)
|
||||
self.aExitNW.triggered.connect(qtLambda(self.mainGui.closeMain))
|
||||
self.mainGui.addAction(self.aExitNW)
|
||||
|
||||
return
|
||||
|
||||
@@ -203,11 +203,13 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aSaveDoc = self.docuMenu.addAction(self.tr("Save Document"))
|
||||
self.aSaveDoc.setShortcut("Ctrl+S")
|
||||
self.aSaveDoc.triggered.connect(self.mainGui.forceSaveDocument)
|
||||
self.mainGui.addAction(self.aSaveDoc)
|
||||
|
||||
# Document > Close
|
||||
self.aCloseDoc = self.docuMenu.addAction(self.tr("Close Document"))
|
||||
self.aCloseDoc.setShortcut("Ctrl+W")
|
||||
self.aCloseDoc.triggered.connect(self.mainGui.closeDocEditor)
|
||||
self.mainGui.addAction(self.aCloseDoc)
|
||||
|
||||
# Document > Separator
|
||||
self.docuMenu.addSeparator()
|
||||
@@ -215,7 +217,7 @@ class GuiMainMenu(QMenuBar):
|
||||
# Document > Preview
|
||||
self.aViewDoc = self.docuMenu.addAction(self.tr("View Document"))
|
||||
self.aViewDoc.setShortcut("Ctrl+R")
|
||||
self.aViewDoc.triggered.connect(lambda: self.mainGui.viewDocument(None))
|
||||
self.aViewDoc.triggered.connect(qtLambda(self.mainGui.viewDocument, None))
|
||||
|
||||
# Document > Close Preview
|
||||
self.aCloseView = self.docuMenu.addAction(self.tr("Close Document View"))
|
||||
@@ -227,11 +229,11 @@ class GuiMainMenu(QMenuBar):
|
||||
|
||||
# Document > Show File Details
|
||||
self.aFileDetails = self.docuMenu.addAction(self.tr("Show File Details"))
|
||||
self.aFileDetails.triggered.connect(lambda: self.mainGui.docEditor.revealLocation())
|
||||
self.aFileDetails.triggered.connect(qtLambda(self.mainGui.docEditor.revealLocation))
|
||||
|
||||
# Document > Import From File
|
||||
self.aImportFile = self.docuMenu.addAction(self.tr("Import Text from File"))
|
||||
self.aImportFile.triggered.connect(lambda: self.mainGui.importDocument())
|
||||
self.aImportFile.triggered.connect(qtLambda(self.mainGui.importDocument))
|
||||
|
||||
return
|
||||
|
||||
@@ -246,6 +248,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aEditUndo.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.UNDO)
|
||||
)
|
||||
self.mainGui.addAction(self.aEditUndo)
|
||||
|
||||
# Edit > Redo
|
||||
self.aEditRedo = self.editMenu.addAction(self.tr("Redo"))
|
||||
@@ -253,6 +256,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aEditRedo.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.REDO)
|
||||
)
|
||||
self.mainGui.addAction(self.aEditRedo)
|
||||
|
||||
# Edit > Separator
|
||||
self.editMenu.addSeparator()
|
||||
@@ -263,6 +267,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aEditCut.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.CUT)
|
||||
)
|
||||
self.mainGui.addAction(self.aEditCut)
|
||||
|
||||
# Edit > Copy
|
||||
self.aEditCopy = self.editMenu.addAction(self.tr("Copy"))
|
||||
@@ -270,6 +275,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aEditCopy.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.COPY)
|
||||
)
|
||||
self.mainGui.addAction(self.aEditCopy)
|
||||
|
||||
# Edit > Paste
|
||||
self.aEditPaste = self.editMenu.addAction(self.tr("Paste"))
|
||||
@@ -277,6 +283,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aEditPaste.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.PASTE)
|
||||
)
|
||||
self.mainGui.addAction(self.aEditPaste)
|
||||
|
||||
# Edit > Separator
|
||||
self.editMenu.addSeparator()
|
||||
@@ -287,6 +294,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aSelectAll.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.SEL_ALL)
|
||||
)
|
||||
self.mainGui.addAction(self.aSelectAll)
|
||||
|
||||
# Edit > Select Paragraph
|
||||
self.aSelectPar = self.editMenu.addAction(self.tr("Select Paragraph"))
|
||||
@@ -294,6 +302,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aSelectPar.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.SEL_PARA)
|
||||
)
|
||||
self.mainGui.addAction(self.aSelectPar)
|
||||
|
||||
return
|
||||
|
||||
@@ -329,12 +338,16 @@ class GuiMainMenu(QMenuBar):
|
||||
# View > Go Backward
|
||||
self.aViewPrev = self.viewMenu.addAction(self.tr("Navigate Backward"))
|
||||
self.aViewPrev.setShortcut("Alt+Left")
|
||||
self.aViewPrev.setShortcutContext(Qt.ShortcutContext.WidgetShortcut)
|
||||
self.aViewPrev.triggered.connect(self.mainGui.docViewer.navBackward)
|
||||
self.mainGui.docViewer.addAction(self.aViewPrev)
|
||||
|
||||
# View > Go Forward
|
||||
self.aViewNext = self.viewMenu.addAction(self.tr("Navigate Forward"))
|
||||
self.aViewNext.setShortcut("Alt+Right")
|
||||
self.aViewNext.setShortcutContext(Qt.ShortcutContext.WidgetShortcut)
|
||||
self.aViewNext.triggered.connect(self.mainGui.docViewer.navForward)
|
||||
self.mainGui.docViewer.addAction(self.aViewNext)
|
||||
|
||||
# View > Separator
|
||||
self.viewMenu.addSeparator()
|
||||
@@ -343,11 +356,13 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFocusMode = self.viewMenu.addAction(self.tr("Focus Mode"))
|
||||
self.aFocusMode.setShortcut("F8")
|
||||
self.aFocusMode.triggered.connect(self.mainGui.toggleFocusMode)
|
||||
self.mainGui.addAction(self.aFocusMode)
|
||||
|
||||
# View > Toggle Full Screen
|
||||
self.aFullScreen = self.viewMenu.addAction(self.tr("Full Screen Mode"))
|
||||
self.aFullScreen.setShortcut("F11")
|
||||
self.aFullScreen.triggered.connect(lambda: self.mainGui.toggleFullScreenMode())
|
||||
self.aFullScreen.triggered.connect(self.mainGui.toggleFullScreenMode)
|
||||
self.mainGui.addAction(self.aFullScreen)
|
||||
|
||||
return
|
||||
|
||||
@@ -365,6 +380,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsENDash.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_ENDASH)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsENDash)
|
||||
|
||||
# Insert > Long Dash
|
||||
self.aInsEMDash = self.mInsDashes.addAction(self.tr("Long Dash"))
|
||||
@@ -372,6 +388,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsEMDash.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_EMDASH)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsEMDash)
|
||||
|
||||
# Insert > Long Dash
|
||||
self.aInsHorBar = self.mInsDashes.addAction(self.tr("Horizontal Bar"))
|
||||
@@ -379,6 +396,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsHorBar.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_HBAR)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsHorBar)
|
||||
|
||||
# Insert > Figure Dash
|
||||
self.aInsFigDash = self.mInsDashes.addAction(self.tr("Figure Dash"))
|
||||
@@ -386,6 +404,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsFigDash.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_FGDASH)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsFigDash)
|
||||
|
||||
# Insert > Quote Marks
|
||||
self.mInsQuotes = self.insMenu.addMenu(self.tr("Quote Marks"))
|
||||
@@ -396,6 +415,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsQuoteLS.triggered.connect(
|
||||
lambda: self.requestDocInsert.emit(nwDocInsert.QUOTE_LS)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsQuoteLS)
|
||||
|
||||
# Insert > Right Single Quote
|
||||
self.aInsQuoteRS = self.mInsQuotes.addAction(self.tr("Right Single Quote"))
|
||||
@@ -403,6 +423,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsQuoteRS.triggered.connect(
|
||||
lambda: self.requestDocInsert.emit(nwDocInsert.QUOTE_RS)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsQuoteRS)
|
||||
|
||||
# Insert > Left Double Quote
|
||||
self.aInsQuoteLD = self.mInsQuotes.addAction(self.tr("Left Double Quote"))
|
||||
@@ -410,6 +431,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsQuoteLD.triggered.connect(
|
||||
lambda: self.requestDocInsert.emit(nwDocInsert.QUOTE_LD)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsQuoteLD)
|
||||
|
||||
# Insert > Right Double Quote
|
||||
self.aInsQuoteRD = self.mInsQuotes.addAction(self.tr("Right Double Quote"))
|
||||
@@ -417,6 +439,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsQuoteRD.triggered.connect(
|
||||
lambda: self.requestDocInsert.emit(nwDocInsert.QUOTE_RD)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsQuoteRD)
|
||||
|
||||
# Insert > Alternative Apostrophe
|
||||
self.aInsMSApos = self.mInsQuotes.addAction(self.tr("Alternative Apostrophe"))
|
||||
@@ -424,6 +447,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsMSApos.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_MAPOS)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsMSApos)
|
||||
|
||||
# Insert > Symbols
|
||||
self.mInsPunct = self.insMenu.addMenu(self.tr("General Punctuation"))
|
||||
@@ -434,6 +458,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsEllipsis.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_HELLIP)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsEllipsis)
|
||||
|
||||
# Insert > Prime
|
||||
self.aInsPrime = self.mInsPunct.addAction(self.tr("Prime"))
|
||||
@@ -441,6 +466,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsPrime.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_PRIME)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsPrime)
|
||||
|
||||
# Insert > Double Prime
|
||||
self.aInsDPrime = self.mInsPunct.addAction(self.tr("Double Prime"))
|
||||
@@ -448,6 +474,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsDPrime.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_DPRIME)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsDPrime)
|
||||
|
||||
# Insert > White Spaces
|
||||
self.mInsSpace = self.insMenu.addMenu(self.tr("White Spaces"))
|
||||
@@ -458,6 +485,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsNBSpace.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_NBSP)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsNBSpace)
|
||||
|
||||
# Insert > Thin Space
|
||||
self.aInsThinSpace = self.mInsSpace.addAction(self.tr("Thin Space"))
|
||||
@@ -465,6 +493,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsThinSpace.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_THSP)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsThinSpace)
|
||||
|
||||
# Insert > Thin Non-Breaking Space
|
||||
self.aInsThinNBSpace = self.mInsSpace.addAction(self.tr("Thin Non-Breaking Space"))
|
||||
@@ -472,6 +501,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsThinNBSpace.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_THNBSP)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsThinNBSpace)
|
||||
|
||||
# Insert > Symbols
|
||||
self.mInsSymbol = self.insMenu.addMenu(self.tr("Other Symbols"))
|
||||
@@ -482,6 +512,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsBullet.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_BULL)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsBullet)
|
||||
|
||||
# Insert > Hyphen Bullet
|
||||
self.aInsHyBull = self.mInsSymbol.addAction(self.tr("Hyphen Bullet"))
|
||||
@@ -489,6 +520,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsHyBull.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_HYBULL)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsHyBull)
|
||||
|
||||
# Insert > Flower Mark
|
||||
self.aInsFlower = self.mInsSymbol.addAction(self.tr("Flower Mark"))
|
||||
@@ -496,6 +528,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsFlower.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_FLOWER)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsFlower)
|
||||
|
||||
# Insert > Per Mille
|
||||
self.aInsPerMille = self.mInsSymbol.addAction(self.tr("Per Mille"))
|
||||
@@ -503,6 +536,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsPerMille.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_PERMIL)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsPerMille)
|
||||
|
||||
# Insert > Degree Symbol
|
||||
self.aInsDegree = self.mInsSymbol.addAction(self.tr("Degree Symbol"))
|
||||
@@ -510,6 +544,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsDegree.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_DEGREE)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsDegree)
|
||||
|
||||
# Insert > Minus Sign
|
||||
self.aInsMinus = self.mInsSymbol.addAction(self.tr("Minus Sign"))
|
||||
@@ -517,6 +552,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsMinus.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_MINUS)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsMinus)
|
||||
|
||||
# Insert > Times Sign
|
||||
self.aInsTimes = self.mInsSymbol.addAction(self.tr("Times Sign"))
|
||||
@@ -524,6 +560,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsTimes.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_TIMES)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsTimes)
|
||||
|
||||
# Insert > Division
|
||||
self.aInsDivide = self.mInsSymbol.addAction(self.tr("Division Sign"))
|
||||
@@ -531,27 +568,15 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsDivide.triggered.connect(
|
||||
lambda: self.requestDocInsertText.emit(nwUnicode.U_DIVIDE)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsDivide)
|
||||
|
||||
# Insert > Tags and References
|
||||
self.mInsKeywords = self.insMenu.addMenu(self.tr("Tags and References"))
|
||||
self.mInsKWItems = {}
|
||||
self.mInsKWItems[nwKeyWords.TAG_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, G")
|
||||
self.mInsKWItems[nwKeyWords.POV_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, V")
|
||||
self.mInsKWItems[nwKeyWords.FOCUS_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, F")
|
||||
self.mInsKWItems[nwKeyWords.CHAR_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, C")
|
||||
self.mInsKWItems[nwKeyWords.PLOT_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, P")
|
||||
self.mInsKWItems[nwKeyWords.TIME_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, T")
|
||||
self.mInsKWItems[nwKeyWords.WORLD_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, L")
|
||||
self.mInsKWItems[nwKeyWords.OBJECT_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, O")
|
||||
self.mInsKWItems[nwKeyWords.ENTITY_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, E")
|
||||
self.mInsKWItems[nwKeyWords.CUSTOM_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, X")
|
||||
for n, keyWord in enumerate(self.mInsKWItems):
|
||||
self.mInsKWItems[keyWord][0].setText(trConst(nwLabels.KEY_NAME[keyWord]))
|
||||
self.mInsKWItems[keyWord][0].setShortcut(self.mInsKWItems[keyWord][1])
|
||||
self.mInsKWItems[keyWord][0].triggered.connect(
|
||||
lambda n, keyWord=keyWord: self.requestDocKeyWordInsert.emit(keyWord)
|
||||
)
|
||||
self.mInsKeywords.addAction(self.mInsKWItems[keyWord][0])
|
||||
for key in nwKeyWords.ALL_KEYS:
|
||||
action = self.mInsKeywords.addAction(trConst(nwLabels.KEY_NAME[key]))
|
||||
action.setShortcut(nwLabels.KEY_SHORTCUT[key])
|
||||
action.triggered.connect(qtLambda(self.requestDocKeyWordInsert.emit, key))
|
||||
self.mainGui.addAction(action)
|
||||
|
||||
# Insert > Special Comments
|
||||
self.mInsComments = self.insMenu.addMenu(self.tr("Special Comments"))
|
||||
@@ -562,6 +587,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsSynopsis.triggered.connect(
|
||||
lambda: self.requestDocInsert.emit(nwDocInsert.SYNOPSIS)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsSynopsis)
|
||||
|
||||
# Insert > Short Description Comment
|
||||
self.aInsShort = self.mInsComments.addAction(self.tr("Short Description Comment"))
|
||||
@@ -569,9 +595,17 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aInsShort.triggered.connect(
|
||||
lambda: self.requestDocInsert.emit(nwDocInsert.SHORT)
|
||||
)
|
||||
self.mainGui.addAction(self.aInsShort)
|
||||
|
||||
# Insert > Symbols
|
||||
self.mInsBreaks = self.insMenu.addMenu(self.tr("Page Break and Space"))
|
||||
# Insert > Word/Character Count
|
||||
self.mInsField = self.insMenu.addMenu(self.tr("Word/Character Count"))
|
||||
for field in nwStats.ALL_FIELDS:
|
||||
value = nwShortcode.FIELD_VALUE.format(field)
|
||||
action = self.mInsField.addAction(trConst(nwLabels.STATS_NAME[field]))
|
||||
action.triggered.connect(qtLambda(self.requestDocInsertText.emit, value))
|
||||
|
||||
# Insert > Breaks and Vertical Space
|
||||
self.mInsBreaks = self.insMenu.addMenu(self.tr("Breaks and Vertical Space"))
|
||||
|
||||
# Insert > New Page
|
||||
self.aInsNewPage = self.mInsBreaks.addAction(self.tr("Page Break"))
|
||||
@@ -579,6 +613,12 @@ class GuiMainMenu(QMenuBar):
|
||||
lambda: self.requestDocInsert.emit(nwDocInsert.NEW_PAGE)
|
||||
)
|
||||
|
||||
# Insert > Forced Line Break
|
||||
self.aInsLineBreak = self.mInsBreaks.addAction(self.tr("Forced Line Break"))
|
||||
self.aInsLineBreak.triggered.connect(
|
||||
lambda: self.requestDocInsert.emit(nwDocInsert.LINE_BRK)
|
||||
)
|
||||
|
||||
# Insert > Vertical Space (Single)
|
||||
self.aInsVSpaceS = self.mInsBreaks.addAction(self.tr("Vertical Space (Single)"))
|
||||
self.aInsVSpaceS.triggered.connect(
|
||||
@@ -616,6 +656,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtBold.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.MD_BOLD)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtBold)
|
||||
|
||||
# Format > Italic
|
||||
self.aFmtItalic = self.fmtMenu.addAction(self.tr("Italic"))
|
||||
@@ -623,6 +664,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtItalic.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.MD_ITALIC)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtItalic)
|
||||
|
||||
# Format > Strikethrough
|
||||
self.aFmtStrike = self.fmtMenu.addAction(self.tr("Strikethrough"))
|
||||
@@ -630,6 +672,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtStrike.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.MD_STRIKE)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtStrike)
|
||||
|
||||
# Edit > Separator
|
||||
self.fmtMenu.addSeparator()
|
||||
@@ -640,6 +683,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtDQuote.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.D_QUOTE)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtDQuote)
|
||||
|
||||
# Format > Single Quotes
|
||||
self.aFmtSQuote = self.fmtMenu.addAction(self.tr("Wrap Single Quotes"))
|
||||
@@ -647,6 +691,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtSQuote.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.S_QUOTE)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtSQuote)
|
||||
|
||||
# Format > Separator
|
||||
self.fmtMenu.addSeparator()
|
||||
@@ -700,32 +745,36 @@ class GuiMainMenu(QMenuBar):
|
||||
self.fmtMenu.addSeparator()
|
||||
|
||||
# Format > Heading 1 (Partition)
|
||||
self.aFmtHead1 = self.fmtMenu.addAction(self.tr("Heading 1 (Partition)"))
|
||||
self.aFmtHead1 = self.fmtMenu.addAction(trConst(nwStyles.T_LABEL["H1"]))
|
||||
self.aFmtHead1.setShortcut("Ctrl+1")
|
||||
self.aFmtHead1.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.BLOCK_H1)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtHead1)
|
||||
|
||||
# Format > Heading 2 (Chapter)
|
||||
self.aFmtHead2 = self.fmtMenu.addAction(self.tr("Heading 2 (Chapter)"))
|
||||
self.aFmtHead2 = self.fmtMenu.addAction(trConst(nwStyles.T_LABEL["H2"]))
|
||||
self.aFmtHead2.setShortcut("Ctrl+2")
|
||||
self.aFmtHead2.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.BLOCK_H2)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtHead2)
|
||||
|
||||
# Format > Heading 3 (Scene)
|
||||
self.aFmtHead3 = self.fmtMenu.addAction(self.tr("Heading 3 (Scene)"))
|
||||
self.aFmtHead3 = self.fmtMenu.addAction(trConst(nwStyles.T_LABEL["H3"]))
|
||||
self.aFmtHead3.setShortcut("Ctrl+3")
|
||||
self.aFmtHead3.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.BLOCK_H3)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtHead3)
|
||||
|
||||
# Format > Heading 4 (Section)
|
||||
self.aFmtHead4 = self.fmtMenu.addAction(self.tr("Heading 4 (Section)"))
|
||||
self.aFmtHead4 = self.fmtMenu.addAction(trConst(nwStyles.T_LABEL["H4"]))
|
||||
self.aFmtHead4.setShortcut("Ctrl+4")
|
||||
self.aFmtHead4.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.BLOCK_H4)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtHead4)
|
||||
|
||||
# Format > Separator
|
||||
self.fmtMenu.addSeparator()
|
||||
@@ -757,6 +806,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtAlignLeft.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.ALIGN_L)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtAlignLeft)
|
||||
|
||||
# Format > Align Centre
|
||||
self.aFmtAlignCentre = self.fmtMenu.addAction(self.tr("Align Centre"))
|
||||
@@ -764,6 +814,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtAlignCentre.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.ALIGN_C)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtAlignCentre)
|
||||
|
||||
# Format > Align Right
|
||||
self.aFmtAlignRight = self.fmtMenu.addAction(self.tr("Align Right"))
|
||||
@@ -771,6 +822,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtAlignRight.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.ALIGN_R)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtAlignRight)
|
||||
|
||||
# Format > Separator
|
||||
self.fmtMenu.addSeparator()
|
||||
@@ -781,6 +833,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtIndentLeft.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.INDENT_L)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtIndentLeft)
|
||||
|
||||
# Format > Indent Right
|
||||
self.aFmtIndentRight = self.fmtMenu.addAction(self.tr("Indent Right"))
|
||||
@@ -788,6 +841,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtIndentRight.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.INDENT_R)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtIndentRight)
|
||||
|
||||
# Format > Separator
|
||||
self.fmtMenu.addSeparator()
|
||||
@@ -798,6 +852,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtComment.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.BLOCK_COM)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtComment)
|
||||
|
||||
# Format > Ignore Text
|
||||
self.aFmtIgnore = self.fmtMenu.addAction(self.tr("Toggle Ignore Text"))
|
||||
@@ -812,6 +867,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aFmtNoFormat.triggered.connect(
|
||||
lambda: self.requestDocAction.emit(nwDocAction.BLOCK_TXT)
|
||||
)
|
||||
self.mainGui.addAction(self.aFmtNoFormat)
|
||||
|
||||
# Format > Separator
|
||||
self.fmtMenu.addSeparator()
|
||||
@@ -844,29 +900,34 @@ class GuiMainMenu(QMenuBar):
|
||||
# Search > Find
|
||||
self.aFind = self.srcMenu.addAction(self.tr("Find"))
|
||||
self.aFind.setShortcut("Ctrl+F")
|
||||
self.aFind.triggered.connect(lambda: self.mainGui.docEditor.beginSearch())
|
||||
self.aFind.triggered.connect(qtLambda(self.mainGui.docEditor.beginSearch))
|
||||
self.mainGui.addAction(self.aFind)
|
||||
|
||||
# Search > Replace
|
||||
self.aReplace = self.srcMenu.addAction(self.tr("Replace"))
|
||||
self.aReplace.setShortcut("Ctrl+=" if CONFIG.osDarwin else "Ctrl+H")
|
||||
self.aReplace.triggered.connect(lambda: self.mainGui.docEditor.beginReplace())
|
||||
self.aReplace.triggered.connect(qtLambda(self.mainGui.docEditor.beginReplace))
|
||||
self.mainGui.addAction(self.aReplace)
|
||||
|
||||
# Search > Find Next
|
||||
self.aFindNext = self.srcMenu.addAction(self.tr("Find Next"))
|
||||
self.aFindNext.setShortcuts(["Ctrl+G", "F3"] if CONFIG.osDarwin else ["F3", "Ctrl+G"])
|
||||
self.aFindNext.triggered.connect(lambda: self.mainGui.docEditor.findNext())
|
||||
self.aFindNext.triggered.connect(qtLambda(self.mainGui.docEditor.findNext))
|
||||
self.mainGui.addAction(self.aFindNext)
|
||||
|
||||
# Search > Find Prev
|
||||
self.aFindPrev = self.srcMenu.addAction(self.tr("Find Previous"))
|
||||
self.aFindPrev.setShortcuts(
|
||||
["Ctrl+Shift+G", "Shift+F3"] if CONFIG.osDarwin else ["Shift+F3", "Ctrl+Shift+G"]
|
||||
)
|
||||
self.aFindPrev.triggered.connect(lambda: self.mainGui.docEditor.findNext(goBack=True))
|
||||
self.aFindPrev.triggered.connect(qtLambda(self.mainGui.docEditor.findNext, goBack=True))
|
||||
self.mainGui.addAction(self.aFindPrev)
|
||||
|
||||
# Search > Replace Next
|
||||
self.aReplaceNext = self.srcMenu.addAction(self.tr("Replace Next"))
|
||||
self.aReplaceNext.setShortcut("Ctrl+Shift+1")
|
||||
self.aReplaceNext.triggered.connect(lambda: self.mainGui.docEditor.replaceNext())
|
||||
self.aReplaceNext.triggered.connect(qtLambda(self.mainGui.docEditor.replaceNext))
|
||||
self.mainGui.addAction(self.aReplaceNext)
|
||||
|
||||
# Search > Separator
|
||||
self.srcMenu.addSeparator()
|
||||
@@ -889,6 +950,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aSpellCheck.setChecked(SHARED.project.data.spellCheck)
|
||||
self.aSpellCheck.triggered.connect(self._toggleSpellCheck) # triggered, not toggled!
|
||||
self.aSpellCheck.setShortcut("Ctrl+F7")
|
||||
self.mainGui.addAction(self.aSpellCheck)
|
||||
|
||||
self.mSelectLanguage = self.toolsMenu.addMenu(self.tr("Spell Check Language"))
|
||||
languages = SHARED.spelling.listDictionaries()
|
||||
@@ -902,7 +964,8 @@ class GuiMainMenu(QMenuBar):
|
||||
# Tools > Re-Run Spell Check
|
||||
self.aReRunSpell = self.toolsMenu.addAction(self.tr("Re-Run Spell Check"))
|
||||
self.aReRunSpell.setShortcut("F7")
|
||||
self.aReRunSpell.triggered.connect(lambda: self.mainGui.docEditor.spellCheckDocument())
|
||||
self.aReRunSpell.triggered.connect(qtLambda(self.mainGui.docEditor.spellCheckDocument))
|
||||
self.mainGui.addAction(self.aReRunSpell)
|
||||
|
||||
# Tools > Project Word List
|
||||
self.aEditWordList = self.toolsMenu.addAction(self.tr("Project Word List"))
|
||||
@@ -919,14 +982,14 @@ class GuiMainMenu(QMenuBar):
|
||||
# Tools > Rebuild Index
|
||||
self.aRebuildIndex = self.toolsMenu.addAction(self.tr("Rebuild Index"))
|
||||
self.aRebuildIndex.setShortcut("F9")
|
||||
self.aRebuildIndex.triggered.connect(lambda: self.mainGui.rebuildIndex())
|
||||
self.aRebuildIndex.triggered.connect(qtLambda(self.mainGui.rebuildIndex))
|
||||
|
||||
# Tools > Separator
|
||||
self.toolsMenu.addSeparator()
|
||||
|
||||
# Tools > Backup Project
|
||||
self.aBackupProject = self.toolsMenu.addAction(self.tr("Backup Project"))
|
||||
self.aBackupProject.triggered.connect(lambda: SHARED.project.backupProject(True))
|
||||
self.aBackupProject.triggered.connect(qtLambda(SHARED.project.backupProject, True))
|
||||
|
||||
# Tools > Build Manuscript
|
||||
self.aBuildManuscript = self.toolsMenu.addAction(self.tr("Build Manuscript"))
|
||||
@@ -941,8 +1004,9 @@ class GuiMainMenu(QMenuBar):
|
||||
# Tools > Preferences
|
||||
self.aPreferences = self.toolsMenu.addAction(self.tr("Preferences"))
|
||||
self.aPreferences.setShortcut("Ctrl+,")
|
||||
self.aPreferences.setMenuRole(QAction.PreferencesRole)
|
||||
self.aPreferences.setMenuRole(QAction.MenuRole.PreferencesRole)
|
||||
self.aPreferences.triggered.connect(self.mainGui.showPreferencesDialog)
|
||||
self.mainGui.addAction(self.aPreferences)
|
||||
|
||||
return
|
||||
|
||||
@@ -953,12 +1017,12 @@ class GuiMainMenu(QMenuBar):
|
||||
|
||||
# Help > About
|
||||
self.aAboutNW = self.helpMenu.addAction(self.tr("About novelWriter"))
|
||||
self.aAboutNW.setMenuRole(QAction.AboutRole)
|
||||
self.aAboutNW.setMenuRole(QAction.MenuRole.AboutRole)
|
||||
self.aAboutNW.triggered.connect(self.mainGui.showAboutNWDialog)
|
||||
|
||||
# Help > About Qt5
|
||||
self.aAboutQt = self.helpMenu.addAction(self.tr("About Qt5"))
|
||||
self.aAboutQt.setMenuRole(QAction.AboutQtRole)
|
||||
self.aAboutQt.setMenuRole(QAction.MenuRole.AboutQtRole)
|
||||
self.aAboutQt.triggered.connect(self.mainGui.showAboutQtDialog)
|
||||
|
||||
# Help > Separator
|
||||
@@ -967,27 +1031,29 @@ class GuiMainMenu(QMenuBar):
|
||||
# Help > User Manual (Online)
|
||||
self.aHelpDocs = self.helpMenu.addAction(self.tr("User Manual (Online)"))
|
||||
self.aHelpDocs.setShortcut("F1")
|
||||
self.aHelpDocs.triggered.connect(lambda: self._openWebsite(nwConst.URL_DOCS))
|
||||
self.aHelpDocs.triggered.connect(qtLambda(SHARED.openWebsite, nwConst.URL_DOCS))
|
||||
self.mainGui.addAction(self.aHelpDocs)
|
||||
|
||||
# Help > User Manual (PDF)
|
||||
if isinstance(CONFIG.pdfDocs, Path):
|
||||
self.aPdfDocs = self.helpMenu.addAction(self.tr("User Manual (PDF)"))
|
||||
self.aPdfDocs.setShortcut("Shift+F1")
|
||||
self.aPdfDocs.triggered.connect(self._openUserManualFile)
|
||||
self.mainGui.addAction(self.aPdfDocs)
|
||||
|
||||
# Help > Separator
|
||||
self.helpMenu.addSeparator()
|
||||
|
||||
# Document > Report an Issue
|
||||
self.aIssue = self.helpMenu.addAction(self.tr("Report an Issue (GitHub)"))
|
||||
self.aIssue.triggered.connect(lambda: self._openWebsite(nwConst.URL_REPORT))
|
||||
self.aIssue.triggered.connect(qtLambda(SHARED.openWebsite, nwConst.URL_REPORT))
|
||||
|
||||
# Document > Ask a Question
|
||||
self.aQuestion = self.helpMenu.addAction(self.tr("Ask a Question (GitHub)"))
|
||||
self.aQuestion.triggered.connect(lambda: self._openWebsite(nwConst.URL_HELP))
|
||||
self.aQuestion.triggered.connect(qtLambda(SHARED.openWebsite, nwConst.URL_HELP))
|
||||
|
||||
# Document > Main Website
|
||||
self.aWebsite = self.helpMenu.addAction(self.tr("The novelWriter Website"))
|
||||
self.aWebsite.triggered.connect(lambda: self._openWebsite(nwConst.URL_WEB))
|
||||
self.aWebsite.triggered.connect(qtLambda(SHARED.openWebsite, nwConst.URL_WEB))
|
||||
|
||||
return
|
||||
|
||||
@@ -33,22 +33,22 @@ from time import time
|
||||
from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtGui import QFocusEvent, QFont, QMouseEvent, QPalette, QResizeEvent
|
||||
from PyQt5.QtWidgets import (
|
||||
QAbstractItemView, QActionGroup, QFrame, QHBoxLayout, QHeaderView,
|
||||
QInputDialog, QMenu, QToolTip, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
|
||||
QWidget
|
||||
QAbstractItemView, QActionGroup, QFrame, QHBoxLayout, QInputDialog, QMenu,
|
||||
QToolTip, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
)
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import minmax
|
||||
from novelwriter.constants import nwHeaders, nwKeyWords, nwLabels, trConst
|
||||
from novelwriter.common import minmax, qtLambda
|
||||
from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst
|
||||
from novelwriter.core.index import IndexHeading
|
||||
from novelwriter.enum import nwDocMode, nwItemClass, nwOutline
|
||||
from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwOutline
|
||||
from novelwriter.extensions.modified import NIconToolButton
|
||||
from novelwriter.extensions.novelselector import NovelSelector
|
||||
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
|
||||
from novelwriter.types import (
|
||||
QtAlignRight, QtDecoration, QtMouseLeft, QtMouseMiddle, QtSizeExpanding,
|
||||
QtUserRole
|
||||
QtAlignRight, QtDecoration, QtHeaderStretch, QtHeaderToContents,
|
||||
QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded,
|
||||
QtSizeExpanding, QtUserRole
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -87,7 +87,6 @@ class GuiNovelView(QWidget):
|
||||
|
||||
# Function Mappings
|
||||
self.getSelectedHandle = self.novelTree.getSelectedHandle
|
||||
self.setActiveHandle = self.novelTree.setActiveHandle
|
||||
|
||||
return
|
||||
|
||||
@@ -163,14 +162,20 @@ class GuiNovelView(QWidget):
|
||||
# Public Slots
|
||||
##
|
||||
|
||||
@pyqtSlot(str)
|
||||
def setActiveHandle(self, tHandle: str) -> None:
|
||||
"""Highlight the rows associated with a given handle."""
|
||||
self.novelTree.setActiveHandle(tHandle)
|
||||
return
|
||||
|
||||
@pyqtSlot()
|
||||
def refreshTree(self) -> None:
|
||||
"""Refresh the current tree."""
|
||||
self.novelTree.refreshTree(rootHandle=SHARED.project.data.getLastHandle("novelTree"))
|
||||
return
|
||||
|
||||
@pyqtSlot(str)
|
||||
def updateRootItem(self, tHandle: str) -> None:
|
||||
@pyqtSlot(str, Enum)
|
||||
def updateRootItem(self, tHandle: str, change: nwChange) -> None:
|
||||
"""If any root item changes, rebuild the novel root menu."""
|
||||
self.novelBar.buildNovelRootMenu()
|
||||
return
|
||||
@@ -341,7 +346,7 @@ class GuiNovelToolBar(QWidget):
|
||||
aLast = self.mLastCol.addAction(actionLabel)
|
||||
aLast.setCheckable(True)
|
||||
aLast.setActionGroup(self.gLastCol)
|
||||
aLast.triggered.connect(lambda: self.setLastColType(colType))
|
||||
aLast.triggered.connect(qtLambda(self.setLastColType, colType))
|
||||
self.aLastCol[colType] = aLast
|
||||
return
|
||||
|
||||
@@ -367,11 +372,11 @@ class GuiNovelTree(QTreeWidget):
|
||||
self.novelView = novelView
|
||||
|
||||
# Internal Variables
|
||||
self._treeMap = {}
|
||||
self._lastBuild = 0
|
||||
self._lastCol = NovelTreeColumn.POV
|
||||
self._lastColSize = 0.25
|
||||
self._actHandle = None
|
||||
self._treeMap: dict[str, QTreeWidgetItem] = {}
|
||||
|
||||
# Cached Strings
|
||||
self._povLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])
|
||||
@@ -401,10 +406,10 @@ class GuiNovelTree(QTreeWidget):
|
||||
treeHeader = self.header()
|
||||
treeHeader.setStretchLastSection(False)
|
||||
treeHeader.setMinimumSectionSize(iPx + cMg)
|
||||
treeHeader.setSectionResizeMode(self.C_TITLE, QHeaderView.ResizeMode.Stretch)
|
||||
treeHeader.setSectionResizeMode(self.C_WORDS, QHeaderView.ResizeMode.ResizeToContents)
|
||||
treeHeader.setSectionResizeMode(self.C_EXTRA, QHeaderView.ResizeMode.ResizeToContents)
|
||||
treeHeader.setSectionResizeMode(self.C_MORE, QHeaderView.ResizeMode.ResizeToContents)
|
||||
treeHeader.setSectionResizeMode(self.C_TITLE, QtHeaderStretch)
|
||||
treeHeader.setSectionResizeMode(self.C_WORDS, QtHeaderToContents)
|
||||
treeHeader.setSectionResizeMode(self.C_EXTRA, QtHeaderToContents)
|
||||
treeHeader.setSectionResizeMode(self.C_MORE, QtHeaderToContents)
|
||||
|
||||
# Pre-Generate Tree Formatting
|
||||
fH1 = self.font()
|
||||
@@ -433,14 +438,14 @@ class GuiNovelTree(QTreeWidget):
|
||||
"""Set or update tree widget settings."""
|
||||
# Scroll bars
|
||||
if CONFIG.hideVScroll:
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
|
||||
else:
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
|
||||
|
||||
if CONFIG.hideHScroll:
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
|
||||
else:
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
|
||||
|
||||
return
|
||||
|
||||
@@ -479,16 +484,9 @@ class GuiNovelTree(QTreeWidget):
|
||||
if rootHandle is None:
|
||||
rootHandle = SHARED.project.tree.findRoot(nwItemClass.NOVEL)
|
||||
|
||||
treeChanged = SHARED.mainGui.projView.changedSince(self._lastBuild)
|
||||
indexChanged = SHARED.project.index.rootChangedSince(rootHandle, self._lastBuild)
|
||||
if not (treeChanged or indexChanged or overRide):
|
||||
logger.debug("No changes have been made to the novel index")
|
||||
return
|
||||
|
||||
selItem = self.selectedItems()
|
||||
titleKey = None
|
||||
if selItem:
|
||||
titleKey = selItem[0].data(self.C_DATA, self.D_KEY)
|
||||
if selItems := self.selectedItems():
|
||||
titleKey = selItems[0].data(self.C_DATA, self.D_KEY)
|
||||
|
||||
self._populateTree(rootHandle)
|
||||
SHARED.project.data.setLastHandle(rootHandle, "novelTree")
|
||||
@@ -540,25 +538,25 @@ class GuiNovelTree(QTreeWidget):
|
||||
self._lastColSize = minmax(colSize, 15, 75)/100.0
|
||||
return
|
||||
|
||||
def setActiveHandle(self, tHandle: str | None, doScroll: bool = False) -> None:
|
||||
def setActiveHandle(self, tHandle: str | None) -> None:
|
||||
"""Highlight the rows associated with a given handle."""
|
||||
didScroll = False
|
||||
self._actHandle = tHandle
|
||||
for i in range(self.topLevelItemCount()):
|
||||
if tItem := self.topLevelItem(i):
|
||||
if tItem.data(self.C_DATA, self.D_HANDLE) == tHandle:
|
||||
tItem.setBackground(self.C_TITLE, self.palette().alternateBase())
|
||||
tItem.setBackground(self.C_WORDS, self.palette().alternateBase())
|
||||
tItem.setBackground(self.C_EXTRA, self.palette().alternateBase())
|
||||
tItem.setBackground(self.C_MORE, self.palette().alternateBase())
|
||||
if doScroll and not didScroll:
|
||||
self.scrollToItem(tItem, QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||
brushOn = self.palette().alternateBase()
|
||||
brushOff = self.palette().base()
|
||||
if pHandle := self._actHandle:
|
||||
for key, item in self._treeMap.items():
|
||||
if key.startswith(pHandle):
|
||||
for i in range(self.columnCount()):
|
||||
item.setBackground(i, brushOff)
|
||||
if tHandle:
|
||||
for key, item in self._treeMap.items():
|
||||
if key.startswith(tHandle):
|
||||
for i in range(self.columnCount()):
|
||||
item.setBackground(i, brushOn)
|
||||
if not didScroll:
|
||||
self.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||
didScroll = True
|
||||
else:
|
||||
tItem.setBackground(self.C_TITLE, self.palette().base())
|
||||
tItem.setBackground(self.C_WORDS, self.palette().base())
|
||||
tItem.setBackground(self.C_EXTRA, self.palette().base())
|
||||
tItem.setBackground(self.C_MORE, self.palette().base())
|
||||
self._actHandle = tHandle or None
|
||||
return
|
||||
|
||||
##
|
||||
@@ -683,7 +681,7 @@ class GuiNovelTree(QTreeWidget):
|
||||
def _updateTreeItemValues(self, trItem: QTreeWidgetItem, idxItem: IndexHeading,
|
||||
tHandle: str, sTitle: str) -> None:
|
||||
"""Set the tree item values from the index entry."""
|
||||
iLevel = nwHeaders.H_LEVEL.get(idxItem.level, 0)
|
||||
iLevel = nwStyles.H_LEVEL.get(idxItem.level, 0)
|
||||
hDec = SHARED.theme.getHeaderDecoration(iLevel)
|
||||
|
||||
trItem.setData(self.C_TITLE, QtDecoration, hDec)
|
||||
|
||||
@@ -42,14 +42,14 @@ from PyQt5.QtWidgets import (
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import checkInt, formatFileFilter, makeFileNameSafe
|
||||
from novelwriter.constants import nwHeaders, nwKeyWords, nwLabels, trConst
|
||||
from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
|
||||
from novelwriter.constants import nwKeyWords, nwLabels, nwStats, nwStyles, trConst
|
||||
from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
|
||||
from novelwriter.error import logException
|
||||
from novelwriter.extensions.configlayout import NColourLabel
|
||||
from novelwriter.extensions.novelselector import NovelSelector
|
||||
from novelwriter.types import (
|
||||
QtAlignLeftTop, QtAlignRight, QtAlignRightTop, QtDecoration,
|
||||
QtSizeExpanding, QtUserRole
|
||||
QtScrollAlwaysOff, QtScrollAsNeeded, QtSizeExpanding, QtUserRole
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -135,11 +135,15 @@ class GuiOutlineView(QWidget):
|
||||
self.outlineBar.populateNovelList()
|
||||
self.outlineBar.setCurrentRoot(lastOutline)
|
||||
self.outlineBar.setEnabled(True)
|
||||
self.outlineData.loadGuiSettings()
|
||||
|
||||
return
|
||||
|
||||
def closeProjectTasks(self) -> None:
|
||||
"""Run closing project tasks."""
|
||||
if self.outlineTree.wasRendered:
|
||||
# If the panel hasn't been drawn yet, those values are incorrect
|
||||
self.outlineData.saveGuiSettings()
|
||||
self.outlineTree.closeProjectTasks()
|
||||
self.outlineData.updateClasses()
|
||||
self.clearOutline()
|
||||
@@ -161,8 +165,8 @@ class GuiOutlineView(QWidget):
|
||||
# Public Slots
|
||||
##
|
||||
|
||||
@pyqtSlot(str)
|
||||
def updateRootItem(self, tHandle: str) -> None:
|
||||
@pyqtSlot(str, Enum)
|
||||
def updateRootItem(self, tHandle: str, change: nwChange) -> None:
|
||||
"""Handle tasks whenever a root folders changes."""
|
||||
self.outlineBar.populateNovelList()
|
||||
self.outlineData.updateClasses()
|
||||
@@ -312,43 +316,49 @@ class GuiOutlineToolBar(QToolBar):
|
||||
class GuiOutlineTree(QTreeWidget):
|
||||
|
||||
DEF_WIDTH = {
|
||||
nwOutline.TITLE: 200,
|
||||
nwOutline.LEVEL: 40,
|
||||
nwOutline.LABEL: 150,
|
||||
nwOutline.LINE: 40,
|
||||
nwOutline.CCOUNT: 50,
|
||||
nwOutline.WCOUNT: 50,
|
||||
nwOutline.PCOUNT: 50,
|
||||
nwOutline.POV: 100,
|
||||
nwOutline.FOCUS: 100,
|
||||
nwOutline.CHAR: 100,
|
||||
nwOutline.PLOT: 100,
|
||||
nwOutline.TIME: 100,
|
||||
nwOutline.WORLD: 100,
|
||||
nwOutline.OBJECT: 100,
|
||||
nwOutline.ENTITY: 100,
|
||||
nwOutline.CUSTOM: 100,
|
||||
nwOutline.SYNOP: 200,
|
||||
nwOutline.TITLE: 200,
|
||||
nwOutline.LEVEL: 40,
|
||||
nwOutline.LABEL: 150,
|
||||
nwOutline.LINE: 40,
|
||||
nwOutline.STATUS: 100,
|
||||
nwOutline.CCOUNT: 50,
|
||||
nwOutline.WCOUNT: 50,
|
||||
nwOutline.PCOUNT: 50,
|
||||
nwOutline.POV: 100,
|
||||
nwOutline.FOCUS: 100,
|
||||
nwOutline.CHAR: 100,
|
||||
nwOutline.PLOT: 100,
|
||||
nwOutline.TIME: 100,
|
||||
nwOutline.WORLD: 100,
|
||||
nwOutline.OBJECT: 100,
|
||||
nwOutline.ENTITY: 100,
|
||||
nwOutline.CUSTOM: 100,
|
||||
nwOutline.STORY: 100,
|
||||
nwOutline.MENTION: 100,
|
||||
nwOutline.SYNOP: 200,
|
||||
}
|
||||
|
||||
DEF_HIDDEN = {
|
||||
nwOutline.TITLE: False,
|
||||
nwOutline.LEVEL: True,
|
||||
nwOutline.LABEL: False,
|
||||
nwOutline.LINE: True,
|
||||
nwOutline.CCOUNT: True,
|
||||
nwOutline.WCOUNT: False,
|
||||
nwOutline.PCOUNT: False,
|
||||
nwOutline.POV: False,
|
||||
nwOutline.FOCUS: True,
|
||||
nwOutline.CHAR: False,
|
||||
nwOutline.PLOT: False,
|
||||
nwOutline.TIME: True,
|
||||
nwOutline.WORLD: False,
|
||||
nwOutline.OBJECT: True,
|
||||
nwOutline.ENTITY: True,
|
||||
nwOutline.CUSTOM: True,
|
||||
nwOutline.SYNOP: False,
|
||||
nwOutline.TITLE: False,
|
||||
nwOutline.LEVEL: True,
|
||||
nwOutline.LABEL: False,
|
||||
nwOutline.LINE: True,
|
||||
nwOutline.STATUS: True,
|
||||
nwOutline.CCOUNT: True,
|
||||
nwOutline.WCOUNT: False,
|
||||
nwOutline.PCOUNT: False,
|
||||
nwOutline.POV: False,
|
||||
nwOutline.FOCUS: True,
|
||||
nwOutline.CHAR: False,
|
||||
nwOutline.PLOT: False,
|
||||
nwOutline.TIME: True,
|
||||
nwOutline.WORLD: False,
|
||||
nwOutline.OBJECT: True,
|
||||
nwOutline.ENTITY: True,
|
||||
nwOutline.CUSTOM: True,
|
||||
nwOutline.STORY: True,
|
||||
nwOutline.MENTION: True,
|
||||
nwOutline.SYNOP: False,
|
||||
}
|
||||
|
||||
D_HANDLE = QtUserRole
|
||||
@@ -370,8 +380,8 @@ class GuiOutlineTree(QTreeWidget):
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.setExpandsOnDoubleClick(False)
|
||||
self.setDragEnabled(False)
|
||||
self.itemDoubleClicked.connect(self._treeDoubleClick)
|
||||
self.itemSelectionChanged.connect(self._itemSelected)
|
||||
self.itemDoubleClicked.connect(self._onItemDoubleClicked)
|
||||
self.itemSelectionChanged.connect(self._onItemSelectionChanged)
|
||||
|
||||
self.setIconSize(SHARED.theme.baseIconSize)
|
||||
self.setIndentation(0)
|
||||
@@ -426,6 +436,11 @@ class GuiOutlineTree(QTreeWidget):
|
||||
def hiddenColumns(self) -> dict[nwOutline, bool]:
|
||||
return self._colHidden
|
||||
|
||||
@property
|
||||
def wasRendered(self) -> bool:
|
||||
"""Returns True after the Outline has been rendered once."""
|
||||
return not self._firstView
|
||||
|
||||
##
|
||||
# Methods
|
||||
##
|
||||
@@ -433,13 +448,13 @@ class GuiOutlineTree(QTreeWidget):
|
||||
def initSettings(self) -> None:
|
||||
"""Set or update outline settings."""
|
||||
if CONFIG.hideVScroll:
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
|
||||
else:
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
|
||||
if CONFIG.hideHScroll:
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
|
||||
else:
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
|
||||
return
|
||||
|
||||
def clearContent(self) -> None:
|
||||
@@ -465,8 +480,10 @@ class GuiOutlineTree(QTreeWidget):
|
||||
|
||||
return
|
||||
|
||||
def refreshTree(self, rootHandle: str | None = None,
|
||||
overRide: bool = False, novelChanged: bool = False) -> None:
|
||||
def refreshTree(
|
||||
self, rootHandle: str | None = None,
|
||||
overRide: bool = False, novelChanged: bool = False
|
||||
) -> None:
|
||||
"""Called whenever the Outline tab is activated and controls
|
||||
what data to load, and if necessary, force a rebuild of the
|
||||
tree.
|
||||
@@ -546,7 +563,7 @@ class GuiOutlineTree(QTreeWidget):
|
||||
##
|
||||
|
||||
@pyqtSlot("QTreeWidgetItem*", int)
|
||||
def _treeDoubleClick(self, tItem: QTreeWidgetItem, tCol: int) -> None:
|
||||
def _onItemDoubleClicked(self, tItem: QTreeWidgetItem, tCol: int) -> None:
|
||||
"""Extract the handle and line number of the title double-
|
||||
clicked, and send it to the main gui class for opening in the
|
||||
document editor.
|
||||
@@ -557,14 +574,13 @@ class GuiOutlineTree(QTreeWidget):
|
||||
return
|
||||
|
||||
@pyqtSlot()
|
||||
def _itemSelected(self) -> None:
|
||||
def _onItemSelectionChanged(self) -> None:
|
||||
"""Extract the handle and line number of the currently selected
|
||||
title, and send it to the details panel.
|
||||
"""
|
||||
selItems = self.selectedItems()
|
||||
if selItems:
|
||||
tHandle = selItems[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
|
||||
sTitle = selItems[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
|
||||
if items := self.selectedItems():
|
||||
tHandle = items[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
|
||||
sTitle = items[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
|
||||
self.activeItemChanged.emit(tHandle, sTitle)
|
||||
return
|
||||
|
||||
@@ -653,6 +669,7 @@ class GuiOutlineTree(QTreeWidget):
|
||||
is fast and doesn't require a rebuild of the tree.
|
||||
"""
|
||||
logger.debug("Rebuilding Outline tree")
|
||||
tStart = time()
|
||||
self.clear()
|
||||
|
||||
if self._firstView:
|
||||
@@ -681,45 +698,51 @@ class GuiOutlineTree(QTreeWidget):
|
||||
novStruct = SHARED.project.index.novelStructure(rootHandle=rootHandle, activeOnly=True)
|
||||
for _, tHandle, sTitle, novIdx in novStruct:
|
||||
|
||||
iLevel = nwHeaders.H_LEVEL.get(novIdx.level, 0)
|
||||
iLevel = nwStyles.H_LEVEL.get(novIdx.level, 0)
|
||||
nwItem = SHARED.project.tree[tHandle]
|
||||
if iLevel == 0 or nwItem is None:
|
||||
continue
|
||||
sLabel, sIcon = nwItem.getImportStatus()
|
||||
|
||||
trItem = QTreeWidgetItem()
|
||||
item = QTreeWidgetItem()
|
||||
hDec = SHARED.theme.getHeaderDecoration(iLevel)
|
||||
|
||||
trItem.setData(self._colIdx[nwOutline.TITLE], QtDecoration, hDec)
|
||||
trItem.setText(self._colIdx[nwOutline.TITLE], novIdx.title)
|
||||
trItem.setData(self._colIdx[nwOutline.TITLE], self.D_HANDLE, tHandle)
|
||||
trItem.setData(self._colIdx[nwOutline.TITLE], self.D_TITLE, sTitle)
|
||||
trItem.setFont(self._colIdx[nwOutline.TITLE], self._hFonts[iLevel])
|
||||
trItem.setText(self._colIdx[nwOutline.LEVEL], novIdx.level)
|
||||
trItem.setIcon(self._colIdx[nwOutline.LABEL], self._dIcon[nwItem.mainHeading])
|
||||
trItem.setText(self._colIdx[nwOutline.LABEL], nwItem.itemName)
|
||||
trItem.setText(self._colIdx[nwOutline.LINE], f"{novIdx.line:n}")
|
||||
trItem.setText(self._colIdx[nwOutline.SYNOP], novIdx.synopsis)
|
||||
trItem.setText(self._colIdx[nwOutline.CCOUNT], f"{novIdx.charCount:n}")
|
||||
trItem.setText(self._colIdx[nwOutline.WCOUNT], f"{novIdx.wordCount:n}")
|
||||
trItem.setText(self._colIdx[nwOutline.PCOUNT], f"{novIdx.paraCount:n}")
|
||||
trItem.setTextAlignment(self._colIdx[nwOutline.CCOUNT], QtAlignRight)
|
||||
trItem.setTextAlignment(self._colIdx[nwOutline.WCOUNT], QtAlignRight)
|
||||
trItem.setTextAlignment(self._colIdx[nwOutline.PCOUNT], QtAlignRight)
|
||||
item.setData(self._colIdx[nwOutline.TITLE], QtDecoration, hDec)
|
||||
item.setText(self._colIdx[nwOutline.TITLE], novIdx.title)
|
||||
item.setData(self._colIdx[nwOutline.TITLE], self.D_HANDLE, tHandle)
|
||||
item.setData(self._colIdx[nwOutline.TITLE], self.D_TITLE, sTitle)
|
||||
item.setFont(self._colIdx[nwOutline.TITLE], self._hFonts[iLevel])
|
||||
item.setText(self._colIdx[nwOutline.LEVEL], novIdx.level)
|
||||
item.setIcon(self._colIdx[nwOutline.LABEL], self._dIcon[nwItem.mainHeading])
|
||||
item.setText(self._colIdx[nwOutline.LABEL], nwItem.itemName)
|
||||
item.setText(self._colIdx[nwOutline.LINE], f"{novIdx.line:n}")
|
||||
item.setText(self._colIdx[nwOutline.STATUS], sLabel)
|
||||
item.setIcon(self._colIdx[nwOutline.STATUS], sIcon)
|
||||
item.setText(self._colIdx[nwOutline.SYNOP], novIdx.synopsis)
|
||||
item.setText(self._colIdx[nwOutline.CCOUNT], f"{novIdx.charCount:n}")
|
||||
item.setText(self._colIdx[nwOutline.WCOUNT], f"{novIdx.wordCount:n}")
|
||||
item.setText(self._colIdx[nwOutline.PCOUNT], f"{novIdx.paraCount:n}")
|
||||
item.setTextAlignment(self._colIdx[nwOutline.CCOUNT], QtAlignRight)
|
||||
item.setTextAlignment(self._colIdx[nwOutline.WCOUNT], QtAlignRight)
|
||||
item.setTextAlignment(self._colIdx[nwOutline.PCOUNT], QtAlignRight)
|
||||
|
||||
refs = SHARED.project.index.getReferences(tHandle, sTitle)
|
||||
trItem.setText(self._colIdx[nwOutline.POV], ", ".join(refs[nwKeyWords.POV_KEY]))
|
||||
trItem.setText(self._colIdx[nwOutline.FOCUS], ", ".join(refs[nwKeyWords.FOCUS_KEY]))
|
||||
trItem.setText(self._colIdx[nwOutline.CHAR], ", ".join(refs[nwKeyWords.CHAR_KEY]))
|
||||
trItem.setText(self._colIdx[nwOutline.PLOT], ", ".join(refs[nwKeyWords.PLOT_KEY]))
|
||||
trItem.setText(self._colIdx[nwOutline.TIME], ", ".join(refs[nwKeyWords.TIME_KEY]))
|
||||
trItem.setText(self._colIdx[nwOutline.WORLD], ", ".join(refs[nwKeyWords.WORLD_KEY]))
|
||||
trItem.setText(self._colIdx[nwOutline.OBJECT], ", ".join(refs[nwKeyWords.OBJECT_KEY]))
|
||||
trItem.setText(self._colIdx[nwOutline.ENTITY], ", ".join(refs[nwKeyWords.ENTITY_KEY]))
|
||||
trItem.setText(self._colIdx[nwOutline.CUSTOM], ", ".join(refs[nwKeyWords.CUSTOM_KEY]))
|
||||
item.setText(self._colIdx[nwOutline.POV], ", ".join(refs[nwKeyWords.POV_KEY]))
|
||||
item.setText(self._colIdx[nwOutline.FOCUS], ", ".join(refs[nwKeyWords.FOCUS_KEY]))
|
||||
item.setText(self._colIdx[nwOutline.CHAR], ", ".join(refs[nwKeyWords.CHAR_KEY]))
|
||||
item.setText(self._colIdx[nwOutline.PLOT], ", ".join(refs[nwKeyWords.PLOT_KEY]))
|
||||
item.setText(self._colIdx[nwOutline.TIME], ", ".join(refs[nwKeyWords.TIME_KEY]))
|
||||
item.setText(self._colIdx[nwOutline.WORLD], ", ".join(refs[nwKeyWords.WORLD_KEY]))
|
||||
item.setText(self._colIdx[nwOutline.OBJECT], ", ".join(refs[nwKeyWords.OBJECT_KEY]))
|
||||
item.setText(self._colIdx[nwOutline.ENTITY], ", ".join(refs[nwKeyWords.ENTITY_KEY]))
|
||||
item.setText(self._colIdx[nwOutline.CUSTOM], ", ".join(refs[nwKeyWords.CUSTOM_KEY]))
|
||||
item.setText(self._colIdx[nwOutline.STORY], ", ".join(refs[nwKeyWords.STORY_KEY]))
|
||||
item.setText(self._colIdx[nwOutline.MENTION], ", ".join(refs[nwKeyWords.MENTION_KEY]))
|
||||
|
||||
self.addTopLevelItem(trItem)
|
||||
self.addTopLevelItem(item)
|
||||
|
||||
self._lastBuild = time()
|
||||
logger.debug("Project outline built in %.3f ms", 1000.0*(time() - tStart))
|
||||
|
||||
return
|
||||
|
||||
@@ -793,6 +816,10 @@ class GuiOutlineDetails(QScrollArea):
|
||||
|
||||
bFont = SHARED.theme.guiFontB
|
||||
|
||||
trStats1 = trConst(nwLabels.STATS_NAME[nwStats.CHARS_ALL])
|
||||
trStats2 = trConst(nwLabels.STATS_NAME[nwStats.WORDS_ALL])
|
||||
trStats3 = trConst(nwLabels.STATS_NAME[nwStats.PARAGRAPHS])
|
||||
|
||||
# Details Area
|
||||
self.titleLabel = QLabel(self.tr("Title"), self)
|
||||
self.fileLabel = QLabel(self.tr("Document"), self)
|
||||
@@ -813,9 +840,9 @@ class GuiOutlineDetails(QScrollArea):
|
||||
self.itemValue.setMaximumWidth(maxTitle)
|
||||
|
||||
# Stats Area
|
||||
self.cCLabel = QLabel(self.tr("Characters"), self)
|
||||
self.wCLabel = QLabel(self.tr("Words"), self)
|
||||
self.pCLabel = QLabel(self.tr("Paragraphs"), self)
|
||||
self.cCLabel = QLabel(trStats1, self)
|
||||
self.wCLabel = QLabel(trStats2, self)
|
||||
self.pCLabel = QLabel(trStats3, self)
|
||||
self.cCValue = QLabel("", self)
|
||||
self.wCValue = QLabel("", self)
|
||||
self.pCValue = QLabel("", self)
|
||||
@@ -842,96 +869,25 @@ class GuiOutlineDetails(QScrollArea):
|
||||
self.synopLWrap = QHBoxLayout()
|
||||
self.synopLWrap.addWidget(self.synopValue, 1)
|
||||
|
||||
# Tags
|
||||
self.povKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY]), self)
|
||||
self.focKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY]), self)
|
||||
self.chrKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY]), self)
|
||||
self.pltKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY]), self)
|
||||
self.timKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY]), self)
|
||||
self.wldKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY]), self)
|
||||
self.objKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY]), self)
|
||||
self.entKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY]), self)
|
||||
self.cstKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.CUSTOM_KEY]), self)
|
||||
|
||||
self.povKeyLabel.setFont(bFont)
|
||||
self.focKeyLabel.setFont(bFont)
|
||||
self.chrKeyLabel.setFont(bFont)
|
||||
self.pltKeyLabel.setFont(bFont)
|
||||
self.timKeyLabel.setFont(bFont)
|
||||
self.wldKeyLabel.setFont(bFont)
|
||||
self.objKeyLabel.setFont(bFont)
|
||||
self.entKeyLabel.setFont(bFont)
|
||||
self.cstKeyLabel.setFont(bFont)
|
||||
|
||||
self.povKeyLWrap = QHBoxLayout()
|
||||
self.focKeyLWrap = QHBoxLayout()
|
||||
self.chrKeyLWrap = QHBoxLayout()
|
||||
self.pltKeyLWrap = QHBoxLayout()
|
||||
self.timKeyLWrap = QHBoxLayout()
|
||||
self.wldKeyLWrap = QHBoxLayout()
|
||||
self.objKeyLWrap = QHBoxLayout()
|
||||
self.entKeyLWrap = QHBoxLayout()
|
||||
self.cstKeyLWrap = QHBoxLayout()
|
||||
|
||||
self.povKeyValue = QLabel("", self)
|
||||
self.focKeyValue = QLabel("", self)
|
||||
self.chrKeyValue = QLabel("", self)
|
||||
self.pltKeyValue = QLabel("", self)
|
||||
self.timKeyValue = QLabel("", self)
|
||||
self.wldKeyValue = QLabel("", self)
|
||||
self.objKeyValue = QLabel("", self)
|
||||
self.entKeyValue = QLabel("", self)
|
||||
self.cstKeyValue = QLabel("", self)
|
||||
|
||||
self.povKeyValue.setWordWrap(True)
|
||||
self.focKeyValue.setWordWrap(True)
|
||||
self.chrKeyValue.setWordWrap(True)
|
||||
self.pltKeyValue.setWordWrap(True)
|
||||
self.timKeyValue.setWordWrap(True)
|
||||
self.wldKeyValue.setWordWrap(True)
|
||||
self.objKeyValue.setWordWrap(True)
|
||||
self.entKeyValue.setWordWrap(True)
|
||||
self.cstKeyValue.setWordWrap(True)
|
||||
|
||||
self.povKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
|
||||
self.focKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
|
||||
self.chrKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
|
||||
self.pltKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
|
||||
self.timKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
|
||||
self.wldKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
|
||||
self.objKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
|
||||
self.entKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
|
||||
self.cstKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
|
||||
|
||||
self.povKeyLWrap.addWidget(self.povKeyValue, 1)
|
||||
self.focKeyLWrap.addWidget(self.focKeyValue, 1)
|
||||
self.chrKeyLWrap.addWidget(self.chrKeyValue, 1)
|
||||
self.pltKeyLWrap.addWidget(self.pltKeyValue, 1)
|
||||
self.timKeyLWrap.addWidget(self.timKeyValue, 1)
|
||||
self.wldKeyLWrap.addWidget(self.wldKeyValue, 1)
|
||||
self.objKeyLWrap.addWidget(self.objKeyValue, 1)
|
||||
self.entKeyLWrap.addWidget(self.entKeyValue, 1)
|
||||
self.cstKeyLWrap.addWidget(self.cstKeyValue, 1)
|
||||
|
||||
# Selected Item Details
|
||||
self.mainGroup = QGroupBox(self.tr("Title Details"), self)
|
||||
self.mainForm = QGridLayout()
|
||||
self.mainGroup = QGroupBox(self.tr("Title Details"), self)
|
||||
self.mainGroup.setLayout(self.mainForm)
|
||||
|
||||
self.mainForm.addWidget(self.titleLabel, 0, 0, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.titleValue, 0, 1, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.cCLabel, 0, 2, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.cCValue, 0, 3, 1, 1, QtAlignRightTop)
|
||||
self.mainForm.addWidget(self.fileLabel, 1, 0, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.fileValue, 1, 1, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.wCLabel, 1, 2, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.wCValue, 1, 3, 1, 1, QtAlignRightTop)
|
||||
self.mainForm.addWidget(self.itemLabel, 2, 0, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.itemValue, 2, 1, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.pCLabel, 2, 2, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.pCValue, 2, 3, 1, 1, QtAlignRightTop)
|
||||
self.mainForm.addWidget(self.synopLabel, 3, 0, 1, 4, QtAlignLeftTop)
|
||||
self.mainForm.addLayout(self.synopLWrap, 4, 0, 1, 4, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.titleLabel, 0, 0, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.titleValue, 0, 1, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.cCLabel, 0, 2, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.cCValue, 0, 3, 1, 1, QtAlignRightTop)
|
||||
self.mainForm.addWidget(self.fileLabel, 1, 0, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.fileValue, 1, 1, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.wCLabel, 1, 2, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.wCValue, 1, 3, 1, 1, QtAlignRightTop)
|
||||
self.mainForm.addWidget(self.itemLabel, 2, 0, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.itemValue, 2, 1, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.pCLabel, 2, 2, 1, 1, QtAlignLeftTop)
|
||||
self.mainForm.addWidget(self.pCValue, 2, 3, 1, 1, QtAlignRightTop)
|
||||
self.mainForm.addWidget(self.synopLabel, 3, 0, 1, 4, QtAlignLeftTop)
|
||||
self.mainForm.addLayout(self.synopLWrap, 4, 0, 1, 4, QtAlignLeftTop)
|
||||
|
||||
self.mainForm.setColumnStretch(1, 1)
|
||||
self.mainForm.setRowStretch(4, 1)
|
||||
@@ -939,45 +895,40 @@ class GuiOutlineDetails(QScrollArea):
|
||||
self.mainForm.setVerticalSpacing(vSpace)
|
||||
|
||||
# Selected Item Tags
|
||||
self.tagsGroup = QGroupBox(self.tr("Reference Tags"), self)
|
||||
self.tagsForm = QGridLayout()
|
||||
self.tagsGroup = QGroupBox(self.tr("Reference Tags"), self)
|
||||
self.tagsGroup.setLayout(self.tagsForm)
|
||||
|
||||
self.tagsForm.addWidget(self.povKeyLabel, 0, 0, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addLayout(self.povKeyLWrap, 0, 1, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addWidget(self.focKeyLabel, 1, 0, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addLayout(self.focKeyLWrap, 1, 1, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addWidget(self.chrKeyLabel, 2, 0, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addLayout(self.chrKeyLWrap, 2, 1, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addWidget(self.pltKeyLabel, 3, 0, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addLayout(self.pltKeyLWrap, 3, 1, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addWidget(self.timKeyLabel, 4, 0, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addLayout(self.timKeyLWrap, 4, 1, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addWidget(self.wldKeyLabel, 5, 0, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addLayout(self.wldKeyLWrap, 5, 1, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addWidget(self.objKeyLabel, 6, 0, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addLayout(self.objKeyLWrap, 6, 1, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addWidget(self.entKeyLabel, 7, 0, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addLayout(self.entKeyLWrap, 7, 1, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addWidget(self.cstKeyLabel, 8, 0, 1, 1, QtAlignLeftTop)
|
||||
self.tagsForm.addLayout(self.cstKeyLWrap, 8, 1, 1, 1, QtAlignLeftTop)
|
||||
self.tagValues: dict[str, tuple[QLabel, QLabel]] = {}
|
||||
for key in nwKeyWords.ALL_KEYS:
|
||||
label = QLabel(trConst(nwLabels.KEY_NAME[key]), self)
|
||||
label.setFont(bFont)
|
||||
value = QLabel("", self)
|
||||
value.setWordWrap(True)
|
||||
value.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(value, 1)
|
||||
n = len(self.tagValues)
|
||||
self.tagsForm.addWidget(label, n, 0, QtAlignLeftTop)
|
||||
self.tagsForm.addLayout(layout, n, 1, QtAlignLeftTop)
|
||||
self.tagValues[key] = (label, value)
|
||||
|
||||
self.tagsForm.setColumnStretch(1, 1)
|
||||
self.tagsForm.setRowStretch(8, 1)
|
||||
self.tagsForm.setRowStretch(len(self.tagValues), 1)
|
||||
self.tagsForm.setHorizontalSpacing(hSpace)
|
||||
self.tagsForm.setVerticalSpacing(vSpace)
|
||||
|
||||
# Assemble
|
||||
self.outerWidget = QWidget(self)
|
||||
self.outerBox = QHBoxLayout()
|
||||
self.outerBox.addWidget(self.mainGroup, 0)
|
||||
self.outerBox.addWidget(self.tagsGroup, 1)
|
||||
self.mainSplit = QSplitter(Qt.Orientation.Horizontal)
|
||||
self.mainSplit.addWidget(self.mainGroup)
|
||||
self.mainSplit.addWidget(self.tagsGroup)
|
||||
self.mainSplit.setCollapsible(0, False)
|
||||
self.mainSplit.setCollapsible(1, False)
|
||||
|
||||
self.outerWidget.setLayout(self.outerBox)
|
||||
self.setWidget(self.outerWidget)
|
||||
self.setWidget(self.mainSplit)
|
||||
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
|
||||
self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
|
||||
self.setWidgetResizable(True)
|
||||
self.setFrameStyle(QFrame.Shape.NoFrame)
|
||||
|
||||
@@ -990,35 +941,53 @@ class GuiOutlineDetails(QScrollArea):
|
||||
def initSettings(self) -> None:
|
||||
"""Set or update outline settings."""
|
||||
if CONFIG.hideVScroll:
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
|
||||
else:
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
|
||||
if CONFIG.hideHScroll:
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
|
||||
else:
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
|
||||
self.updateClasses()
|
||||
return
|
||||
|
||||
def loadGuiSettings(self) -> None:
|
||||
"""Run open project tasks."""
|
||||
parent = self.outlineView.parent() # This widget is rendered already
|
||||
width = parent.width() if isinstance(parent, QWidget) else 1000
|
||||
pOptions = SHARED.project.options
|
||||
self.mainSplit.setSizes([
|
||||
CONFIG.pxInt(pOptions.getInt("GuiOutlineDetails", "detailsWidth", width//3)),
|
||||
CONFIG.pxInt(pOptions.getInt("GuiOutlineDetails", "tagsWidth", 2*width//3))
|
||||
])
|
||||
return
|
||||
|
||||
def saveGuiSettings(self) -> None:
|
||||
"""Run close project tasks."""
|
||||
mainSplit = self.mainSplit.sizes()
|
||||
detailsWidth = CONFIG.rpxInt(mainSplit[0])
|
||||
tagsWidth = CONFIG.rpxInt(mainSplit[1])
|
||||
|
||||
logger.debug("Saving State: GuiOutlineDetails")
|
||||
pOptions = SHARED.project.options
|
||||
pOptions.setValue("GuiOutlineDetails", "detailsWidth", detailsWidth)
|
||||
pOptions.setValue("GuiOutlineDetails", "tagsWidth", tagsWidth)
|
||||
return
|
||||
|
||||
def clearDetails(self) -> None:
|
||||
"""Clear all the data labels."""
|
||||
self.titleLabel.setText(self.tr("Title"))
|
||||
self.titleValue.setText("")
|
||||
self.fileValue.setText("")
|
||||
self.itemValue.setText("")
|
||||
self.cCValue.setText("")
|
||||
self.wCValue.setText("")
|
||||
self.pCValue.setText("")
|
||||
self.synopValue.setText("")
|
||||
self.povKeyValue.setText("")
|
||||
self.focKeyValue.setText("")
|
||||
self.chrKeyValue.setText("")
|
||||
self.pltKeyValue.setText("")
|
||||
self.timKeyValue.setText("")
|
||||
self.wldKeyValue.setText("")
|
||||
self.objKeyValue.setText("")
|
||||
self.entKeyValue.setText("")
|
||||
self.cstKeyValue.setText("")
|
||||
self.titleValue.clear()
|
||||
self.fileValue.clear()
|
||||
self.itemValue.clear()
|
||||
self.cCValue.clear()
|
||||
self.wCValue.clear()
|
||||
self.pCValue.clear()
|
||||
self.synopValue.clear()
|
||||
|
||||
for _, value in self.tagValues.values():
|
||||
value.clear()
|
||||
|
||||
self.updateClasses()
|
||||
return
|
||||
|
||||
@@ -1050,15 +1019,8 @@ class GuiOutlineDetails(QScrollArea):
|
||||
|
||||
self.synopValue.setText(novIdx.synopsis)
|
||||
|
||||
self.povKeyValue.setText(self._formatTags(novRefs, nwKeyWords.POV_KEY))
|
||||
self.focKeyValue.setText(self._formatTags(novRefs, nwKeyWords.FOCUS_KEY))
|
||||
self.chrKeyValue.setText(self._formatTags(novRefs, nwKeyWords.CHAR_KEY))
|
||||
self.pltKeyValue.setText(self._formatTags(novRefs, nwKeyWords.PLOT_KEY))
|
||||
self.timKeyValue.setText(self._formatTags(novRefs, nwKeyWords.TIME_KEY))
|
||||
self.wldKeyValue.setText(self._formatTags(novRefs, nwKeyWords.WORLD_KEY))
|
||||
self.objKeyValue.setText(self._formatTags(novRefs, nwKeyWords.OBJECT_KEY))
|
||||
self.entKeyValue.setText(self._formatTags(novRefs, nwKeyWords.ENTITY_KEY))
|
||||
self.cstKeyValue.setText(self._formatTags(novRefs, nwKeyWords.CUSTOM_KEY))
|
||||
for key, (_, value) in self.tagValues.items():
|
||||
value.setText(self._formatTags(novRefs, key))
|
||||
|
||||
return
|
||||
|
||||
@@ -1066,27 +1028,12 @@ class GuiOutlineDetails(QScrollArea):
|
||||
def updateClasses(self) -> None:
|
||||
"""Update the visibility status of class details."""
|
||||
usedClasses = SHARED.project.tree.rootClasses()
|
||||
|
||||
pltVisible = nwItemClass.PLOT in usedClasses
|
||||
timVisible = nwItemClass.TIMELINE in usedClasses
|
||||
wldVisible = nwItemClass.WORLD in usedClasses
|
||||
objVisible = nwItemClass.OBJECT in usedClasses
|
||||
entVisible = nwItemClass.ENTITY in usedClasses
|
||||
cstVisible = nwItemClass.CUSTOM in usedClasses
|
||||
|
||||
self.pltKeyLabel.setVisible(pltVisible)
|
||||
self.pltKeyValue.setVisible(pltVisible)
|
||||
self.timKeyLabel.setVisible(timVisible)
|
||||
self.timKeyValue.setVisible(timVisible)
|
||||
self.wldKeyLabel.setVisible(wldVisible)
|
||||
self.wldKeyValue.setVisible(wldVisible)
|
||||
self.objKeyLabel.setVisible(objVisible)
|
||||
self.objKeyValue.setVisible(objVisible)
|
||||
self.entKeyLabel.setVisible(entVisible)
|
||||
self.entKeyValue.setVisible(entVisible)
|
||||
self.cstKeyLabel.setVisible(cstVisible)
|
||||
self.cstKeyValue.setVisible(cstVisible)
|
||||
|
||||
for key, itemClass in nwKeyWords.KEY_CLASS.items():
|
||||
visible = itemClass in usedClasses
|
||||
if key in self.tagValues:
|
||||
label, value = self.tagValues[key]
|
||||
label.setVisible(visible)
|
||||
value.setVisible(visible)
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -30,15 +30,18 @@ from time import time
|
||||
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtGui import QCursor, QKeyEvent
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QFrame, QHBoxLayout, QHeaderView, QLabel, QLineEdit,
|
||||
QToolBar, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
QApplication, QFrame, QHBoxLayout, QLabel, QLineEdit, QToolBar,
|
||||
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
)
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import checkInt, cssCol
|
||||
from novelwriter.core.coretools import DocSearch
|
||||
from novelwriter.core.item import NWItem
|
||||
from novelwriter.types import QtAlignMiddle, QtAlignRight, QtUserRole
|
||||
from novelwriter.types import (
|
||||
QtAlignMiddle, QtAlignRight, QtHeaderStretch, QtHeaderToContents,
|
||||
QtUserRole
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -120,8 +123,8 @@ class GuiProjectSearch(QWidget):
|
||||
|
||||
treeHeader = self.searchResult.header()
|
||||
treeHeader.setStretchLastSection(False)
|
||||
treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch)
|
||||
treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeMode.ResizeToContents)
|
||||
treeHeader.setSectionResizeMode(self.C_NAME, QtHeaderStretch)
|
||||
treeHeader.setSectionResizeMode(self.C_COUNT, QtHeaderToContents)
|
||||
|
||||
# Assemble
|
||||
self.headerBox = QHBoxLayout()
|
||||
@@ -331,15 +334,11 @@ class GuiProjectSearch(QWidget):
|
||||
"""Populate the result tree."""
|
||||
if results and nwItem:
|
||||
tHandle = nwItem.itemHandle
|
||||
docIcon = SHARED.theme.getItemIcon(
|
||||
nwItem.itemType, nwItem.itemClass,
|
||||
nwItem.itemLayout, nwItem.mainHeading
|
||||
)
|
||||
ext = "+" if capped else ""
|
||||
|
||||
tItem = QTreeWidgetItem()
|
||||
tItem.setText(self.C_NAME, nwItem.itemName)
|
||||
tItem.setIcon(self.C_NAME, docIcon)
|
||||
tItem.setIcon(self.C_NAME, nwItem.getMainIcon())
|
||||
tItem.setData(self.C_NAME, self.D_HANDLE, tHandle)
|
||||
tItem.setText(self.C_COUNT, f"({len(results):n}{ext})")
|
||||
tItem.setTextAlignment(self.C_COUNT, QtAlignRight)
|
||||
|
||||
@@ -32,6 +32,7 @@ from PyQt5.QtGui import QPalette
|
||||
from PyQt5.QtWidgets import QMenu, QVBoxLayout, QWidget
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import qtLambda
|
||||
from novelwriter.enum import nwView
|
||||
from novelwriter.extensions.eventfilters import StatusTipFilter
|
||||
from novelwriter.extensions.modified import NIconToolButton
|
||||
@@ -54,7 +55,7 @@ class GuiSideBar(QWidget):
|
||||
|
||||
self.mainGui = mainGui
|
||||
|
||||
iPx = int(1.2*SHARED.theme.baseButtonHeight)
|
||||
iPx = int(1.25*SHARED.theme.baseButtonHeight)
|
||||
iSz = QSize(iPx, iPx)
|
||||
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -63,19 +64,19 @@ class GuiSideBar(QWidget):
|
||||
# Buttons
|
||||
self.tbProject = NIconToolButton(self, iSz)
|
||||
self.tbProject.setToolTip("{0} [Ctrl+T]".format(self.tr("Project Tree View")))
|
||||
self.tbProject.clicked.connect(lambda: self.requestViewChange.emit(nwView.PROJECT))
|
||||
self.tbProject.clicked.connect(qtLambda(self.requestViewChange.emit, nwView.PROJECT))
|
||||
|
||||
self.tbNovel = NIconToolButton(self, iSz)
|
||||
self.tbNovel.setToolTip("{0} [Ctrl+T]".format(self.tr("Novel Tree View")))
|
||||
self.tbNovel.clicked.connect(lambda: self.requestViewChange.emit(nwView.NOVEL))
|
||||
self.tbNovel.clicked.connect(qtLambda(self.requestViewChange.emit, nwView.NOVEL))
|
||||
|
||||
self.tbSearch = NIconToolButton(self, iSz)
|
||||
self.tbSearch.setToolTip("{0} [Ctrl+Shift+F]".format(self.tr("Project Search")))
|
||||
self.tbSearch.clicked.connect(lambda: self.requestViewChange.emit(nwView.SEARCH))
|
||||
self.tbSearch.clicked.connect(qtLambda(self.requestViewChange.emit, nwView.SEARCH))
|
||||
|
||||
self.tbOutline = NIconToolButton(self, iSz)
|
||||
self.tbOutline.setToolTip("{0} [Ctrl+Shift+T]".format(self.tr("Novel Outline View")))
|
||||
self.tbOutline.clicked.connect(lambda: self.requestViewChange.emit(nwView.OUTLINE))
|
||||
self.tbOutline.clicked.connect(qtLambda(self.requestViewChange.emit, nwView.OUTLINE))
|
||||
|
||||
self.tbBuild = NIconToolButton(self, iSz)
|
||||
self.tbBuild.setToolTip("{0} [F5]".format(self.tr("Build Manuscript")))
|
||||
@@ -113,7 +114,7 @@ class GuiSideBar(QWidget):
|
||||
self.outerBox.addWidget(self.tbStats)
|
||||
self.outerBox.addWidget(self.tbSettings)
|
||||
self.outerBox.setContentsMargins(0, 0, 0, 0)
|
||||
self.outerBox.setSpacing(CONFIG.pxInt(4))
|
||||
self.outerBox.setSpacing(CONFIG.pxInt(6))
|
||||
|
||||
self.setLayout(self.outerBox)
|
||||
self.updateTheme()
|
||||
|
||||
@@ -151,6 +151,9 @@ class GuiTheme:
|
||||
self.guiFont = QApplication.font()
|
||||
self.guiFontB = QApplication.font()
|
||||
self.guiFontB.setBold(True)
|
||||
self.guiFontBU = QApplication.font()
|
||||
self.guiFontBU.setBold(True)
|
||||
self.guiFontBU.setUnderline(True)
|
||||
self.guiFontSmall = QApplication.font()
|
||||
self.guiFontSmall.setPointSizeF(0.9*self.guiFont.pointSizeF())
|
||||
|
||||
@@ -500,13 +503,15 @@ class GuiIcons:
|
||||
|
||||
# Format Icons
|
||||
"fmt_bold", "fmt_bold-md", "fmt_italic", "fmt_italic-md", "fmt_mark", "fmt_strike",
|
||||
"fmt_strike-md", "fmt_subscript", "fmt_superscript", "fmt_underline",
|
||||
"fmt_strike-md", "fmt_subscript", "fmt_superscript", "fmt_underline", "margin_bottom",
|
||||
"margin_left", "margin_right", "margin_top", "size_height", "size_width",
|
||||
|
||||
# General Button Icons
|
||||
"add", "add_document", "backward", "bookmark", "browse", "checked", "close", "cross",
|
||||
"document", "down", "edit", "export", "font", "forward", "import", "list", "maximise",
|
||||
"menu", "minimise", "more", "noncheckable", "open", "panel", "quote", "refresh", "remove",
|
||||
"revert", "search_replace", "search", "settings", "star", "unchecked", "up", "view",
|
||||
"add", "add_document", "backward", "bookmark", "browse", "checked", "close", "copy",
|
||||
"cross", "document", "down", "edit", "export", "font", "forward", "import", "list",
|
||||
"maximise", "menu", "minimise", "more", "noncheckable", "open", "panel", "quote",
|
||||
"refresh", "remove", "revert", "search_replace", "search", "settings", "star", "toolbar",
|
||||
"unchecked", "up", "view",
|
||||
|
||||
# Switches
|
||||
"sticky-on", "sticky-off",
|
||||
|
||||
@@ -62,6 +62,7 @@ from novelwriter.tools.manuscript import GuiManuscript
|
||||
from novelwriter.tools.noveldetails import GuiNovelDetails
|
||||
from novelwriter.tools.welcome import GuiWelcome
|
||||
from novelwriter.tools.writingstats import GuiWritingStats
|
||||
from novelwriter.types import QtModShift
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -189,9 +190,6 @@ class GuiMain(QMainWindow):
|
||||
self.splitView.setVisible(False)
|
||||
self.docEditor.closeSearch()
|
||||
|
||||
# Initialise the Project Tree
|
||||
self.rebuildTrees()
|
||||
|
||||
# Assemble Main Window Elements
|
||||
self.mainBox = QHBoxLayout()
|
||||
self.mainBox.addWidget(self.sideBar)
|
||||
@@ -215,13 +213,19 @@ class GuiMain(QMainWindow):
|
||||
SHARED.indexChangedTags.connect(self.docEditor.updateChangedTags)
|
||||
SHARED.indexChangedTags.connect(self.docViewerPanel.updateChangedTags)
|
||||
SHARED.indexCleared.connect(self.docViewerPanel.indexWasCleared)
|
||||
SHARED.indexScannedText.connect(self.docViewerPanel.projectItemChanged)
|
||||
SHARED.indexScannedText.connect(self.itemDetails.updateViewBox)
|
||||
SHARED.indexScannedText.connect(self.projView.updateItemValues)
|
||||
SHARED.mainClockTick.connect(self._timeTick)
|
||||
SHARED.projectItemChanged.connect(self.docEditor.onProjectItemChanged)
|
||||
SHARED.projectItemChanged.connect(self.docViewer.onProjectItemChanged)
|
||||
SHARED.projectItemChanged.connect(self.docViewerPanel.onProjectItemChanged)
|
||||
SHARED.projectItemChanged.connect(self.itemDetails.onProjectItemChanged)
|
||||
SHARED.projectItemChanged.connect(self.projView.onProjectItemChanged)
|
||||
SHARED.projectStatusChanged.connect(self.mainStatus.updateProjectStatus)
|
||||
SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage)
|
||||
SHARED.rootFolderChanged.connect(self.novelView.updateRootItem)
|
||||
SHARED.rootFolderChanged.connect(self.outlineView.updateRootItem)
|
||||
SHARED.rootFolderChanged.connect(self.projView.updateRootItem)
|
||||
SHARED.spellLanguageChanged.connect(self.mainStatus.setLanguage)
|
||||
SHARED.statusLabelsChanged.connect(self.docViewerPanel.updateStatusLabels)
|
||||
|
||||
self.mainMenu.requestDocAction.connect(self._passDocumentAction)
|
||||
self.mainMenu.requestDocInsert.connect(self._passDocumentInsert)
|
||||
@@ -234,15 +238,7 @@ class GuiMain(QMainWindow):
|
||||
|
||||
self.projView.openDocumentRequest.connect(self._openDocument)
|
||||
self.projView.projectSettingsRequest.connect(self.showProjectSettingsDialog)
|
||||
self.projView.rootFolderChanged.connect(self.novelView.updateRootItem)
|
||||
self.projView.rootFolderChanged.connect(self.outlineView.updateRootItem)
|
||||
self.projView.rootFolderChanged.connect(self.projView.updateRootItem)
|
||||
self.projView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
|
||||
self.projView.treeItemChanged.connect(self.docEditor.updateDocInfo)
|
||||
self.projView.treeItemChanged.connect(self.docViewer.updateDocInfo)
|
||||
self.projView.treeItemChanged.connect(self.docViewerPanel.projectItemChanged)
|
||||
self.projView.treeItemChanged.connect(self.itemDetails.updateViewBox)
|
||||
self.projView.wordCountsChanged.connect(self._updateStatusWordCount)
|
||||
|
||||
self.novelView.openDocumentRequest.connect(self._openDocument)
|
||||
self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
|
||||
@@ -250,25 +246,27 @@ class GuiMain(QMainWindow):
|
||||
self.projSearch.openDocumentSelectRequest.connect(self._openDocumentSelection)
|
||||
self.projSearch.selectedItemChanged.connect(self.itemDetails.updateViewBox)
|
||||
|
||||
self.docEditor.closeDocumentRequest.connect(self.closeDocEditor)
|
||||
self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts)
|
||||
self.docEditor.docCountsChanged.connect(self.projView.updateCounts)
|
||||
self.docEditor.closeEditorRequest.connect(self.closeDocEditor)
|
||||
self.docEditor.docTextChanged.connect(self.projSearch.textChanged)
|
||||
self.docEditor.editedStatusChanged.connect(self.mainStatus.updateDocumentStatus)
|
||||
self.docEditor.itemHandleChanged.connect(self.novelView.setActiveHandle)
|
||||
self.docEditor.itemHandleChanged.connect(self.projView.setActiveHandle)
|
||||
self.docEditor.loadDocumentTagRequest.connect(self._followTag)
|
||||
self.docEditor.novelItemMetaChanged.connect(self.novelView.updateNovelItemMeta)
|
||||
self.docEditor.novelStructureChanged.connect(self.novelView.refreshTree)
|
||||
self.docEditor.requestNewNoteCreation.connect(self.projView.createNewNote)
|
||||
self.docEditor.openDocumentRequest.connect(self._openDocument)
|
||||
self.docEditor.requestNewNoteCreation.connect(SHARED.createNewNote)
|
||||
self.docEditor.requestNextDocument.connect(self.openNextDocument)
|
||||
self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem)
|
||||
self.docEditor.requestProjectItemSelected.connect(self.projView.setSelectedHandle)
|
||||
self.docEditor.spellCheckStateChanged.connect(self.mainMenu.setSpellCheckState)
|
||||
self.docEditor.statusMessage.connect(self.mainStatus.setStatusMessage)
|
||||
self.docEditor.toggleFocusModeRequest.connect(self.toggleFocusMode)
|
||||
self.docEditor.updateStatusMessage.connect(self.mainStatus.setStatusMessage)
|
||||
|
||||
self.docViewer.closeDocumentRequest.connect(self.closeDocViewer)
|
||||
self.docViewer.documentLoaded.connect(self.docViewerPanel.updateHandle)
|
||||
self.docViewer.loadDocumentTagRequest.connect(self._followTag)
|
||||
self.docViewer.openDocumentRequest.connect(self._openDocument)
|
||||
self.docViewer.reloadDocumentRequest.connect(self._reloadViewer)
|
||||
self.docViewer.requestProjectItemSelected.connect(self.projView.setSelectedHandle)
|
||||
self.docViewer.togglePanelVisibility.connect(self._toggleViewerPanelVisibility)
|
||||
@@ -290,21 +288,30 @@ class GuiMain(QMainWindow):
|
||||
self.asDocTimer = QTimer(self)
|
||||
self.asDocTimer.timeout.connect(self._autoSaveDocument)
|
||||
|
||||
# Shortcuts and Actions
|
||||
self._connectMenuActions()
|
||||
|
||||
# Shortcuts
|
||||
self.keyReturn = QShortcut(self)
|
||||
self.keyReturn.setKey(Qt.Key.Key_Return)
|
||||
self.keyReturn.setKey("Return")
|
||||
self.keyReturn.activated.connect(self._keyPressReturn)
|
||||
|
||||
self.keyShiftReturn = QShortcut(self)
|
||||
self.keyShiftReturn.setKey("Shift+Return")
|
||||
self.keyShiftReturn.activated.connect(self._keyPressReturn)
|
||||
|
||||
self.keyEnter = QShortcut(self)
|
||||
self.keyEnter.setKey(Qt.Key.Key_Enter)
|
||||
self.keyEnter.setKey("Enter")
|
||||
self.keyEnter.activated.connect(self._keyPressReturn)
|
||||
|
||||
self.keyShiftEnter = QShortcut(self)
|
||||
self.keyShiftEnter.setKey("Shift+Enter")
|
||||
self.keyShiftEnter.activated.connect(self._keyPressReturn)
|
||||
|
||||
self.keyEscape = QShortcut(self)
|
||||
self.keyEscape.setKey(Qt.Key.Key_Escape)
|
||||
self.keyEscape.setKey("Esc")
|
||||
self.keyEscape.activated.connect(self._keyPressEscape)
|
||||
|
||||
# Internal Variables
|
||||
self._lastTotalCount = 0
|
||||
|
||||
# Initialise Main GUI
|
||||
self.initMain()
|
||||
self.asProjTimer.start()
|
||||
@@ -461,7 +468,6 @@ class GuiMain(QMainWindow):
|
||||
|
||||
# Update GUI
|
||||
self._updateWindowTitle(SHARED.project.data.name)
|
||||
self.rebuildTrees()
|
||||
self.docEditor.toggleSpellCheck(SHARED.project.data.spellCheck)
|
||||
self.mainStatus.setRefTime(SHARED.project.projOpened)
|
||||
self.projView.openProjectTasks()
|
||||
@@ -483,8 +489,7 @@ class GuiMain(QMainWindow):
|
||||
QApplication.processEvents()
|
||||
self.openDocument(lastEdited, doScroll=True)
|
||||
|
||||
lastViewed = SHARED.project.data.getLastHandle("viewer")
|
||||
if lastViewed is not None:
|
||||
if lastViewed := SHARED.project.data.getLastHandle("viewer"):
|
||||
QApplication.processEvents()
|
||||
self.viewDocument(lastViewed)
|
||||
|
||||
@@ -504,17 +509,15 @@ class GuiMain(QMainWindow):
|
||||
|
||||
def saveProject(self, autoSave: bool = False) -> bool:
|
||||
"""Save the current project."""
|
||||
if not SHARED.hasProject:
|
||||
logger.error("No project open")
|
||||
return False
|
||||
self.projView.saveProjectTasks()
|
||||
return SHARED.saveProject(autoSave=autoSave)
|
||||
if SHARED.hasProject:
|
||||
return SHARED.saveProject(autoSave=autoSave)
|
||||
return False
|
||||
|
||||
##
|
||||
# Document Actions
|
||||
##
|
||||
|
||||
def closeDocument(self, beforeOpen: bool = False) -> None:
|
||||
def closeDocument(self) -> None:
|
||||
"""Close the document and clear the editor and title field."""
|
||||
if SHARED.hasProject:
|
||||
# Disable focus mode if it is active
|
||||
@@ -522,38 +525,37 @@ class GuiMain(QMainWindow):
|
||||
SHARED.setFocusMode(False)
|
||||
self.saveDocument()
|
||||
self.docEditor.clearEditor()
|
||||
if not beforeOpen:
|
||||
self.novelView.setActiveHandle(None)
|
||||
return
|
||||
|
||||
def openDocument(self, tHandle: str | None, tLine: int | None = None,
|
||||
changeFocus: bool = True, doScroll: bool = False) -> bool:
|
||||
def openDocument(
|
||||
self,
|
||||
tHandle: str | None,
|
||||
tLine: int | None = None,
|
||||
sTitle: str | None = None,
|
||||
changeFocus: bool = True,
|
||||
doScroll: bool = False
|
||||
) -> bool:
|
||||
"""Open a specific document, optionally at a given line."""
|
||||
if not SHARED.hasProject:
|
||||
logger.error("No project open")
|
||||
if not (SHARED.hasProject and tHandle):
|
||||
logger.error("Nothing to open open")
|
||||
return False
|
||||
|
||||
if not tHandle or not SHARED.project.tree.checkType(tHandle, nwItemType.FILE):
|
||||
logger.debug("Requested item '%s' is not a document", tHandle)
|
||||
return False
|
||||
if sTitle and tLine is None:
|
||||
if hItem := SHARED.project.index.getItemHeading(tHandle, sTitle):
|
||||
tLine = hItem.line
|
||||
|
||||
self._changeView(nwView.EDITOR)
|
||||
cHandle = self.docEditor.docHandle
|
||||
if cHandle == tHandle:
|
||||
if tHandle == self.docEditor.docHandle:
|
||||
self.docEditor.setCursorLine(tLine)
|
||||
if changeFocus:
|
||||
self.docEditor.setFocus()
|
||||
return True
|
||||
|
||||
self.closeDocument(beforeOpen=True)
|
||||
if self.docEditor.loadText(tHandle, tLine):
|
||||
SHARED.project.data.setLastHandle(tHandle, "editor")
|
||||
self.projView.setSelectedHandle(tHandle, doScroll=doScroll)
|
||||
self.novelView.setActiveHandle(tHandle, doScroll=doScroll)
|
||||
if changeFocus:
|
||||
self.docEditor.setFocus()
|
||||
else:
|
||||
return False
|
||||
self.closeDocument()
|
||||
if self.docEditor.loadText(tHandle, tLine):
|
||||
self.projView.setSelectedHandle(tHandle, doScroll=doScroll)
|
||||
else:
|
||||
return False
|
||||
|
||||
if changeFocus:
|
||||
self.docEditor.setFocus()
|
||||
|
||||
return True
|
||||
|
||||
@@ -711,7 +713,6 @@ class GuiMain(QMainWindow):
|
||||
if SHARED.hasProject:
|
||||
tHandle = None
|
||||
sTitle = None
|
||||
tLine = None
|
||||
if self.projView.treeHasFocus():
|
||||
tHandle = self.projView.getSelectedHandle()
|
||||
elif self.novelView.treeHasFocus():
|
||||
@@ -722,19 +723,14 @@ class GuiMain(QMainWindow):
|
||||
logger.warning("No item selected")
|
||||
return
|
||||
|
||||
if tHandle and sTitle:
|
||||
if hItem := SHARED.project.index.getItemHeading(tHandle, sTitle):
|
||||
tLine = hItem.line
|
||||
if tHandle:
|
||||
self.openDocument(tHandle, tLine=tLine, changeFocus=False, doScroll=False)
|
||||
if tHandle and SHARED.project.tree.checkType(tHandle, nwItemType.FILE):
|
||||
if QApplication.keyboardModifiers() == QtModShift:
|
||||
self.viewDocument(tHandle)
|
||||
else:
|
||||
self.openDocument(tHandle, sTitle=sTitle, changeFocus=False, doScroll=False)
|
||||
|
||||
return
|
||||
|
||||
def rebuildTrees(self) -> None:
|
||||
"""Rebuild the project tree."""
|
||||
self.projView.populateTree()
|
||||
return
|
||||
|
||||
def rebuildIndex(self, beQuiet: bool = False) -> None:
|
||||
"""Rebuild the entire index."""
|
||||
if SHARED.hasProject:
|
||||
@@ -742,9 +738,8 @@ class GuiMain(QMainWindow):
|
||||
QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
|
||||
tStart = time()
|
||||
|
||||
self.projView.saveProjectTasks()
|
||||
SHARED.project.index.rebuildIndex()
|
||||
self.projView.populateTree()
|
||||
SHARED.project.index.rebuild()
|
||||
SHARED.project.tree.refreshAllItems()
|
||||
self.novelView.refreshTree()
|
||||
|
||||
tEnd = time()
|
||||
@@ -857,13 +852,11 @@ class GuiMain(QMainWindow):
|
||||
|
||||
def closeMain(self) -> bool:
|
||||
"""Save everything, and close novelWriter."""
|
||||
if SHARED.hasProject:
|
||||
msgYes = SHARED.question("%s<br>%s" % (
|
||||
self.tr("Do you want to exit novelWriter?"),
|
||||
self.tr("Changes are saved automatically.")
|
||||
))
|
||||
if not msgYes:
|
||||
return False
|
||||
if SHARED.hasProject and not SHARED.question("%s<br>%s" % (
|
||||
self.tr("Do you want to exit novelWriter?"),
|
||||
self.tr("Changes are saved automatically.")
|
||||
)):
|
||||
return False
|
||||
|
||||
logger.info("Exiting novelWriter")
|
||||
|
||||
@@ -908,11 +901,6 @@ class GuiMain(QMainWindow):
|
||||
|
||||
return not self.splitView.isVisible()
|
||||
|
||||
def toggleFullScreenMode(self) -> None:
|
||||
"""Toggle full screen mode"""
|
||||
self.setWindowState(self.windowState() ^ Qt.WindowState.WindowFullScreen)
|
||||
return
|
||||
|
||||
##
|
||||
# Events
|
||||
##
|
||||
@@ -928,6 +916,12 @@ class GuiMain(QMainWindow):
|
||||
# Public Slots
|
||||
##
|
||||
|
||||
@pyqtSlot()
|
||||
def toggleFullScreenMode(self) -> None:
|
||||
"""Toggle full screen mode"""
|
||||
self.setWindowState(self.windowState() ^ Qt.WindowState.WindowFullScreen)
|
||||
return
|
||||
|
||||
@pyqtSlot()
|
||||
def closeDocEditor(self) -> None:
|
||||
"""Close the document editor. This does not hide the editor."""
|
||||
@@ -1057,13 +1051,8 @@ class GuiMain(QMainWindow):
|
||||
self.initMain()
|
||||
self.saveDocument()
|
||||
|
||||
if restart:
|
||||
SHARED.info(self.tr(
|
||||
"Some changes will not be applied until novelWriter has been restarted."
|
||||
))
|
||||
|
||||
if tree:
|
||||
self.projView.populateTree()
|
||||
SHARED.project.tree.refreshAllItems()
|
||||
|
||||
if theme:
|
||||
# We are doing this manually instead of connecting to
|
||||
@@ -1089,19 +1078,25 @@ class GuiMain(QMainWindow):
|
||||
self.projView.initSettings()
|
||||
self.novelView.initSettings()
|
||||
self.outlineView.initSettings()
|
||||
|
||||
# Force update of word count
|
||||
self._lastTotalCount = 0
|
||||
self._updateStatusWordCount()
|
||||
|
||||
if restart:
|
||||
SHARED.info(self.tr(
|
||||
"Some changes will not be applied until novelWriter has been restarted."
|
||||
))
|
||||
|
||||
return
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def _processProjectSettingsChanges(self, rebuildTrees: bool) -> None:
|
||||
@pyqtSlot()
|
||||
def _processProjectSettingsChanges(self) -> None:
|
||||
"""Refresh data dependent on project settings."""
|
||||
logger.debug("Applying new project settings")
|
||||
SHARED.updateSpellCheckLanguage()
|
||||
self.itemDetails.refreshDetails()
|
||||
self._updateWindowTitle(SHARED.project.data.name)
|
||||
if rebuildTrees:
|
||||
self.rebuildTrees()
|
||||
return
|
||||
|
||||
@pyqtSlot()
|
||||
@@ -1115,10 +1110,18 @@ class GuiMain(QMainWindow):
|
||||
@pyqtSlot(str, nwDocMode)
|
||||
def _followTag(self, tag: str, mode: nwDocMode) -> None:
|
||||
"""Follow a tag after user interaction with a link."""
|
||||
tHandle, sTitle = self._getTagSource(tag)
|
||||
if tHandle is not None:
|
||||
tHandle, sTitle = SHARED.project.index.getTagSource(tag)
|
||||
if tHandle is None:
|
||||
SHARED.error(self.tr(
|
||||
"Could not find the reference for tag '{0}'. It either doesn't "
|
||||
"exist, or the index is out of date. The index can be updated "
|
||||
"from the Tools menu, or by pressing {1}."
|
||||
).format(
|
||||
tag, "F9"
|
||||
))
|
||||
else:
|
||||
if mode == nwDocMode.EDIT:
|
||||
self.openDocument(tHandle)
|
||||
self.openDocument(tHandle, sTitle=sTitle)
|
||||
elif mode == nwDocMode.VIEW:
|
||||
self.viewDocument(tHandle=tHandle, sTitle=sTitle)
|
||||
return
|
||||
@@ -1137,11 +1140,7 @@ class GuiMain(QMainWindow):
|
||||
"""Handle an open document request."""
|
||||
if tHandle is not None:
|
||||
if mode == nwDocMode.EDIT:
|
||||
tLine = None
|
||||
hItem = SHARED.project.index.getItemHeading(tHandle, sTitle)
|
||||
if hItem is not None:
|
||||
tLine = hItem.line
|
||||
self.openDocument(tHandle, tLine=tLine, changeFocus=setFocus)
|
||||
self.openDocument(tHandle, sTitle=sTitle, changeFocus=setFocus)
|
||||
elif mode == nwDocMode.VIEW:
|
||||
self.viewDocument(tHandle=tHandle, sTitle=sTitle)
|
||||
return
|
||||
@@ -1224,8 +1223,10 @@ class GuiMain(QMainWindow):
|
||||
self.mainStatus.setUserIdle(editIdle or userIdle)
|
||||
SHARED.updateIdleTime(currTime, editIdle or userIdle)
|
||||
self.mainStatus.updateTime(idleTime=SHARED.projectIdleTime)
|
||||
if CONFIG.memInfo and int(currTime) % 5 == 0: # pragma: no cover
|
||||
self.mainStatus.memInfo()
|
||||
if int(currTime) % 5 == 0:
|
||||
self._updateStatusWordCount()
|
||||
if CONFIG.memInfo: # pragma: no cover
|
||||
self.mainStatus.memInfo()
|
||||
return
|
||||
|
||||
@pyqtSlot()
|
||||
@@ -1253,15 +1254,19 @@ class GuiMain(QMainWindow):
|
||||
if not SHARED.hasProject:
|
||||
self.mainStatus.setProjectStats(0, 0)
|
||||
|
||||
SHARED.project.updateWordCounts()
|
||||
if CONFIG.incNotesWCount:
|
||||
iTotal = sum(SHARED.project.data.initCounts)
|
||||
cTotal = sum(SHARED.project.data.currCounts)
|
||||
self.mainStatus.setProjectStats(cTotal, cTotal - iTotal)
|
||||
else:
|
||||
iNovel, _ = SHARED.project.data.initCounts
|
||||
cNovel, _ = SHARED.project.data.currCounts
|
||||
self.mainStatus.setProjectStats(cNovel, cNovel - iNovel)
|
||||
currentTotalCount = SHARED.project.currentTotalCount
|
||||
if self._lastTotalCount != currentTotalCount:
|
||||
self._lastTotalCount = currentTotalCount
|
||||
|
||||
SHARED.project.updateWordCounts()
|
||||
if CONFIG.incNotesWCount:
|
||||
iTotal = sum(SHARED.project.data.initCounts)
|
||||
cTotal = sum(SHARED.project.data.currCounts)
|
||||
self.mainStatus.setProjectStats(cTotal, cTotal - iTotal)
|
||||
else:
|
||||
iNovel, _ = SHARED.project.data.initCounts
|
||||
cNovel, _ = SHARED.project.data.currCounts
|
||||
self.mainStatus.setProjectStats(cNovel, cNovel - iNovel)
|
||||
|
||||
return
|
||||
|
||||
@@ -1307,116 +1312,7 @@ class GuiMain(QMainWindow):
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _connectMenuActions(self) -> None:
|
||||
"""Connect to the main window all menu actions that need to be
|
||||
available also when the main menu is hidden.
|
||||
"""
|
||||
# Project
|
||||
self.addAction(self.mainMenu.aSaveProject)
|
||||
self.addAction(self.mainMenu.aEditItem)
|
||||
self.addAction(self.mainMenu.aExitNW)
|
||||
|
||||
# Document
|
||||
self.addAction(self.mainMenu.aSaveDoc)
|
||||
self.addAction(self.mainMenu.aCloseDoc)
|
||||
|
||||
# Edit
|
||||
self.addAction(self.mainMenu.aEditUndo)
|
||||
self.addAction(self.mainMenu.aEditRedo)
|
||||
self.addAction(self.mainMenu.aEditCut)
|
||||
self.addAction(self.mainMenu.aEditCopy)
|
||||
self.addAction(self.mainMenu.aEditPaste)
|
||||
self.addAction(self.mainMenu.aSelectAll)
|
||||
self.addAction(self.mainMenu.aSelectPar)
|
||||
|
||||
# View
|
||||
self.addAction(self.mainMenu.aFocusMode)
|
||||
self.addAction(self.mainMenu.aFullScreen)
|
||||
|
||||
# Insert
|
||||
self.addAction(self.mainMenu.aInsENDash)
|
||||
self.addAction(self.mainMenu.aInsEMDash)
|
||||
self.addAction(self.mainMenu.aInsHorBar)
|
||||
self.addAction(self.mainMenu.aInsFigDash)
|
||||
self.addAction(self.mainMenu.aInsQuoteLS)
|
||||
self.addAction(self.mainMenu.aInsQuoteRS)
|
||||
self.addAction(self.mainMenu.aInsQuoteLD)
|
||||
self.addAction(self.mainMenu.aInsQuoteRD)
|
||||
self.addAction(self.mainMenu.aInsMSApos)
|
||||
self.addAction(self.mainMenu.aInsEllipsis)
|
||||
self.addAction(self.mainMenu.aInsPrime)
|
||||
self.addAction(self.mainMenu.aInsDPrime)
|
||||
self.addAction(self.mainMenu.aInsNBSpace)
|
||||
self.addAction(self.mainMenu.aInsThinSpace)
|
||||
self.addAction(self.mainMenu.aInsThinNBSpace)
|
||||
self.addAction(self.mainMenu.aInsBullet)
|
||||
self.addAction(self.mainMenu.aInsHyBull)
|
||||
self.addAction(self.mainMenu.aInsFlower)
|
||||
self.addAction(self.mainMenu.aInsPerMille)
|
||||
self.addAction(self.mainMenu.aInsDegree)
|
||||
self.addAction(self.mainMenu.aInsMinus)
|
||||
self.addAction(self.mainMenu.aInsTimes)
|
||||
self.addAction(self.mainMenu.aInsDivide)
|
||||
self.addAction(self.mainMenu.aInsSynopsis)
|
||||
self.addAction(self.mainMenu.aInsShort)
|
||||
|
||||
for mAction, _ in self.mainMenu.mInsKWItems.values():
|
||||
self.addAction(mAction)
|
||||
|
||||
# Search
|
||||
self.addAction(self.mainMenu.aFind)
|
||||
self.addAction(self.mainMenu.aReplace)
|
||||
self.addAction(self.mainMenu.aFindNext)
|
||||
self.addAction(self.mainMenu.aFindPrev)
|
||||
self.addAction(self.mainMenu.aReplaceNext)
|
||||
|
||||
# Format
|
||||
self.addAction(self.mainMenu.aFmtItalic)
|
||||
self.addAction(self.mainMenu.aFmtBold)
|
||||
self.addAction(self.mainMenu.aFmtStrike)
|
||||
self.addAction(self.mainMenu.aFmtDQuote)
|
||||
self.addAction(self.mainMenu.aFmtSQuote)
|
||||
self.addAction(self.mainMenu.aFmtHead1)
|
||||
self.addAction(self.mainMenu.aFmtHead2)
|
||||
self.addAction(self.mainMenu.aFmtHead3)
|
||||
self.addAction(self.mainMenu.aFmtHead4)
|
||||
self.addAction(self.mainMenu.aFmtAlignLeft)
|
||||
self.addAction(self.mainMenu.aFmtAlignCentre)
|
||||
self.addAction(self.mainMenu.aFmtAlignRight)
|
||||
self.addAction(self.mainMenu.aFmtIndentLeft)
|
||||
self.addAction(self.mainMenu.aFmtIndentRight)
|
||||
self.addAction(self.mainMenu.aFmtComment)
|
||||
self.addAction(self.mainMenu.aFmtNoFormat)
|
||||
|
||||
# Tools
|
||||
self.addAction(self.mainMenu.aSpellCheck)
|
||||
self.addAction(self.mainMenu.aReRunSpell)
|
||||
self.addAction(self.mainMenu.aPreferences)
|
||||
|
||||
# Help
|
||||
self.addAction(self.mainMenu.aHelpDocs)
|
||||
if isinstance(CONFIG.pdfDocs, Path):
|
||||
self.addAction(self.mainMenu.aPdfDocs)
|
||||
|
||||
return
|
||||
|
||||
def _updateWindowTitle(self, projName: str | None = None) -> None:
|
||||
"""Set the window title and add the project's name."""
|
||||
self.setWindowTitle(" - ".join(filter(None, [projName, CONFIG.appName])))
|
||||
return
|
||||
|
||||
def _getTagSource(self, tag: str) -> tuple[str | None, str | None]:
|
||||
"""Handle the index lookup of a tag and display an alert if the
|
||||
tag cannot be found.
|
||||
"""
|
||||
tHandle, sTitle = SHARED.project.index.getTagSource(tag)
|
||||
if tHandle is None:
|
||||
SHARED.error(self.tr(
|
||||
"Could not find the reference for tag '{0}'. It either doesn't "
|
||||
"exist, or the index is out of date. The index can be updated "
|
||||
"from the Tools menu, or by pressing {1}."
|
||||
).format(
|
||||
tag, "F9"
|
||||
))
|
||||
return None, None
|
||||
return tHandle, sTitle
|
||||
|
||||
@@ -26,20 +26,23 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
from PyQt5.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal
|
||||
from PyQt5.QtGui import QFont
|
||||
from PyQt5.QtCore import QObject, QRunnable, QThreadPool, QTimer, QUrl, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtGui import QDesktopServices, QFont
|
||||
from PyQt5.QtWidgets import QFileDialog, QFontDialog, QMessageBox, QWidget
|
||||
|
||||
from novelwriter.common import formatFileFilter
|
||||
from novelwriter.constants import nwFiles
|
||||
from novelwriter.core.spellcheck import NWSpellEnchant
|
||||
from novelwriter.enum import nwChange, nwItemClass
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.status import T_StatusKind
|
||||
from novelwriter.gui.theme import GuiTheme
|
||||
from novelwriter.guimain import GuiMain
|
||||
|
||||
@@ -55,15 +58,17 @@ class SharedData(QObject):
|
||||
"_idleTime", "_idleRefTime",
|
||||
)
|
||||
|
||||
focusModeChanged = pyqtSignal(bool)
|
||||
indexAvailable = pyqtSignal()
|
||||
indexChangedTags = pyqtSignal(list, list)
|
||||
indexCleared = pyqtSignal()
|
||||
mainClockTick = pyqtSignal()
|
||||
projectItemChanged = pyqtSignal(str, Enum)
|
||||
rootFolderChanged = pyqtSignal(str, Enum)
|
||||
projectStatusChanged = pyqtSignal(bool)
|
||||
projectStatusMessage = pyqtSignal(str)
|
||||
spellLanguageChanged = pyqtSignal(str, str)
|
||||
focusModeChanged = pyqtSignal(bool)
|
||||
indexScannedText = pyqtSignal(str)
|
||||
indexChangedTags = pyqtSignal(list, list)
|
||||
indexCleared = pyqtSignal()
|
||||
indexAvailable = pyqtSignal()
|
||||
mainClockTick = pyqtSignal()
|
||||
statusLabelsChanged = pyqtSignal(str)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
@@ -172,10 +177,12 @@ class SharedData(QObject):
|
||||
logger.debug("Thread Pool Max Count: %d", QThreadPool.globalInstance().maxThreadCount())
|
||||
return
|
||||
|
||||
def closeEditor(self, tHandle: str | None = None) -> None:
|
||||
def closeDocument(self, tHandle: str | None = None) -> None:
|
||||
"""Close the document editor, optionally a specific document."""
|
||||
if tHandle is None or tHandle == self.mainGui.docEditor.docHandle:
|
||||
self.mainGui.closeDocument()
|
||||
if tHandle is None or tHandle == self.mainGui.docViewer.docHandle:
|
||||
self.mainGui.closeViewerPanel()
|
||||
return
|
||||
|
||||
def saveEditor(self, tHandle: str | None = None) -> None:
|
||||
@@ -292,23 +299,63 @@ class SharedData(QObject):
|
||||
return None
|
||||
|
||||
##
|
||||
# Signal Proxy
|
||||
# Public Slots
|
||||
##
|
||||
|
||||
def indexSignalProxy(self, data: dict) -> None:
|
||||
"""Emit signals on behalf of the index."""
|
||||
event = data.get("event")
|
||||
logger.debug("Received '%s' event from the index", event)
|
||||
if event == "updateTags":
|
||||
self.indexChangedTags.emit(data.get("updated", []), data.get("deleted", []))
|
||||
elif event == "scanText":
|
||||
self.indexScannedText.emit(data.get("handle", ""))
|
||||
elif event == "clearIndex":
|
||||
@pyqtSlot(str)
|
||||
def openWebsite(self, url: str) -> None:
|
||||
"""Open a URL in the system's default browser."""
|
||||
QDesktopServices.openUrl(QUrl(url))
|
||||
return
|
||||
|
||||
@pyqtSlot(str, nwItemClass)
|
||||
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
|
||||
"""Process new note request."""
|
||||
self.project.createNewNote(tag, itemClass)
|
||||
return
|
||||
|
||||
##
|
||||
# Signal Proxies
|
||||
##
|
||||
|
||||
def emitIndexChangedTags(
|
||||
self, project: NWProject, updated: list[str], deleted: list[str]
|
||||
) -> None:
|
||||
"""Emit the indexChangedTags signal."""
|
||||
if self._project and self._project.data.uuid == project.data.uuid:
|
||||
self.indexChangedTags.emit(updated, deleted)
|
||||
return
|
||||
|
||||
def emitIndexCleared(self, project: NWProject) -> None:
|
||||
"""Emit the indexCleared signal."""
|
||||
if self._project and self._project.data.uuid == project.data.uuid:
|
||||
self.indexCleared.emit()
|
||||
elif event == "buildIndex":
|
||||
return
|
||||
|
||||
def emitIndexAvailable(self, project: NWProject) -> None:
|
||||
"""Emit the indexAvailable signal."""
|
||||
if self._project and self._project.data.uuid == project.data.uuid:
|
||||
self.indexAvailable.emit()
|
||||
return
|
||||
|
||||
def emitStatusLabelsChanged(self, project: NWProject, kind: T_StatusKind) -> None:
|
||||
"""Emit the statusLabelsChanged signal."""
|
||||
if self._project and self._project.data.uuid == project.data.uuid:
|
||||
self.statusLabelsChanged.emit(kind)
|
||||
return
|
||||
|
||||
def emitProjectItemChanged(self, project: NWProject, handle: str, change: nwChange) -> None:
|
||||
"""Emit the projectItemChanged signal."""
|
||||
if self._project and self._project.data.uuid == project.data.uuid:
|
||||
self.projectItemChanged.emit(handle, change)
|
||||
return
|
||||
|
||||
def emitRootFolderChanged(self, project: NWProject, handle: str, change: nwChange) -> None:
|
||||
"""Emit the rootFolderChanged signal."""
|
||||
if self._project and self._project.data.uuid == project.data.uuid:
|
||||
self.rootFolderChanged.emit(handle, change)
|
||||
return
|
||||
|
||||
##
|
||||
# Alert Boxes
|
||||
##
|
||||
@@ -367,6 +414,7 @@ class SharedData(QObject):
|
||||
"""Create a new project and spell checking instance."""
|
||||
from novelwriter.core.project import NWProject
|
||||
if isinstance(self._project, NWProject):
|
||||
self._project.clear()
|
||||
del self._project
|
||||
del self._spelling
|
||||
self._project = NWProject()
|
||||
@@ -432,9 +480,9 @@ class _GuiAlert(QMessageBox):
|
||||
Yes/No buttons or just an Ok button.
|
||||
"""
|
||||
if isYesNo:
|
||||
self.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
|
||||
self.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
else:
|
||||
self.setStandardButtons(QMessageBox.Ok)
|
||||
self.setStandardButtons(QMessageBox.StandardButton.Ok)
|
||||
pSz = 2*self._theme.baseIconHeight
|
||||
if level == self.INFO:
|
||||
self.setIconPixmap(self._theme.getPixmap("alert_info", (pSz, pSz)))
|
||||
|
||||
@@ -30,6 +30,7 @@ import re
|
||||
from novelwriter.constants import nwRegEx, nwUnicode
|
||||
|
||||
RX_SC = re.compile(nwRegEx.FMT_SC)
|
||||
RX_SV = re.compile(nwRegEx.FMT_SV)
|
||||
RX_LO = re.compile(r"(?i)(?<!\\)(\[(?:vspace|newpage|new page)(:\d+)?)(?<!\\)(\])")
|
||||
|
||||
|
||||
@@ -64,6 +65,7 @@ def preProcessText(text: str, keepHeaders: bool = True) -> list[str]:
|
||||
# Strip shortcodes and special formatting
|
||||
# RegEx is slow, so we do this only when necessary
|
||||
line = RX_SC.sub("", line)
|
||||
line = RX_SV.sub("", line)
|
||||
line = RX_LO.sub("", line)
|
||||
|
||||
result.append(line)
|
||||
|
||||
@@ -3,7 +3,8 @@ novelWriter – Text Pattern Functions
|
||||
====================================
|
||||
|
||||
File History:
|
||||
Created: 2024-06-01 [2.5ec1]
|
||||
Created: 2024-06-01 [2.5rc1] RegExPatterns
|
||||
Created: 2024-11-04 [2.6b1] DialogParser
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
@@ -23,91 +24,198 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QRegularExpression
|
||||
import re
|
||||
|
||||
from novelwriter import CONFIG
|
||||
from novelwriter.constants import nwRegEx
|
||||
from novelwriter.types import QRegExUnicode
|
||||
from novelwriter.common import compact, uniqueCompact
|
||||
from novelwriter.constants import nwRegEx, nwUnicode
|
||||
|
||||
|
||||
class RegExPatterns:
|
||||
|
||||
AMBIGUOUS = (nwUnicode.U_APOS, nwUnicode.U_RSQUO)
|
||||
|
||||
# Static RegExes
|
||||
_rxUrl = re.compile(nwRegEx.URL, re.ASCII)
|
||||
_rxWords = re.compile(nwRegEx.WORDS, re.UNICODE)
|
||||
_rxBreak = re.compile(nwRegEx.BREAK, re.UNICODE)
|
||||
_rxItalic = re.compile(nwRegEx.FMT_EI, re.UNICODE)
|
||||
_rxBold = re.compile(nwRegEx.FMT_EB, re.UNICODE)
|
||||
_rxStrike = re.compile(nwRegEx.FMT_ST, re.UNICODE)
|
||||
_rxSCPlain = re.compile(nwRegEx.FMT_SC, re.UNICODE)
|
||||
_rxSCValue = re.compile(nwRegEx.FMT_SV, re.UNICODE)
|
||||
|
||||
@property
|
||||
def markdownItalic(self) -> QRegularExpression:
|
||||
def url(self) -> re.Pattern:
|
||||
"""Find URLs."""
|
||||
return self._rxUrl
|
||||
|
||||
@property
|
||||
def wordSplit(self) -> re.Pattern:
|
||||
"""Split text into words."""
|
||||
return self._rxWords
|
||||
|
||||
@property
|
||||
def lineBreak(self) -> re.Pattern:
|
||||
"""Find forced line break."""
|
||||
return self._rxBreak
|
||||
|
||||
@property
|
||||
def markdownItalic(self) -> re.Pattern:
|
||||
"""Markdown italic style."""
|
||||
rxRule = QRegularExpression(nwRegEx.FMT_EI)
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
return rxRule
|
||||
return self._rxItalic
|
||||
|
||||
@property
|
||||
def markdownBold(self) -> QRegularExpression:
|
||||
def markdownBold(self) -> re.Pattern:
|
||||
"""Markdown bold style."""
|
||||
rxRule = QRegularExpression(nwRegEx.FMT_EB)
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
return rxRule
|
||||
return self._rxBold
|
||||
|
||||
@property
|
||||
def markdownStrike(self) -> QRegularExpression:
|
||||
def markdownStrike(self) -> re.Pattern:
|
||||
"""Markdown strikethrough style."""
|
||||
rxRule = QRegularExpression(nwRegEx.FMT_ST)
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
return rxRule
|
||||
return self._rxStrike
|
||||
|
||||
@property
|
||||
def shortcodePlain(self) -> QRegularExpression:
|
||||
def shortcodePlain(self) -> re.Pattern:
|
||||
"""Plain shortcode style."""
|
||||
rxRule = QRegularExpression(nwRegEx.FMT_SC)
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
return rxRule
|
||||
return self._rxSCPlain
|
||||
|
||||
@property
|
||||
def shortcodeValue(self) -> QRegularExpression:
|
||||
def shortcodeValue(self) -> re.Pattern:
|
||||
"""Plain shortcode style."""
|
||||
rxRule = QRegularExpression(nwRegEx.FMT_SV)
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
return rxRule
|
||||
return self._rxSCValue
|
||||
|
||||
@property
|
||||
def dialogStyle(self) -> QRegularExpression:
|
||||
def dialogStyle(self) -> re.Pattern | None:
|
||||
"""Dialogue detection rule based on user settings."""
|
||||
symO = ""
|
||||
symC = ""
|
||||
if CONFIG.dialogStyle in (1, 3):
|
||||
symO += CONFIG.fmtSQuoteOpen
|
||||
symC += CONFIG.fmtSQuoteClose
|
||||
if CONFIG.dialogStyle in (2, 3):
|
||||
symO += CONFIG.fmtDQuoteOpen
|
||||
symC += CONFIG.fmtDQuoteClose
|
||||
|
||||
rxEnd = "|$" if CONFIG.allowOpenDial else ""
|
||||
rxRule = QRegularExpression(f"\\B[{symO}].*?(?:[{symC}]\\B{rxEnd})")
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
return rxRule
|
||||
if CONFIG.dialogStyle > 0:
|
||||
rx = []
|
||||
if CONFIG.dialogStyle in (1, 3):
|
||||
qO = CONFIG.fmtSQuoteOpen.strip()[:1]
|
||||
qC = CONFIG.fmtSQuoteClose.strip()[:1]
|
||||
if qO == qC:
|
||||
rx.append(f"(?:\\B{qO}.+?{qC}\\B)")
|
||||
else:
|
||||
rx.append(f"(?:{qO}[^{qO}]+{qC})")
|
||||
if CONFIG.allowOpenDial:
|
||||
rx.append(f"(?:{qO}.+?$)")
|
||||
if CONFIG.dialogStyle in (2, 3):
|
||||
qO = CONFIG.fmtDQuoteOpen.strip()[:1]
|
||||
qC = CONFIG.fmtDQuoteClose.strip()[:1]
|
||||
if qO == qC:
|
||||
rx.append(f"(?:\\B{qO}.+?{qC}\\B)")
|
||||
else:
|
||||
rx.append(f"(?:{qO}[^{qO}]+{qC})")
|
||||
if CONFIG.allowOpenDial:
|
||||
rx.append(f"(?:{qO}.+?$)")
|
||||
return re.compile("|".join(rx), re.UNICODE)
|
||||
return None
|
||||
|
||||
@property
|
||||
def dialogLine(self) -> QRegularExpression:
|
||||
"""Dialogue line rule based on user settings."""
|
||||
sym = QRegularExpression.escape(CONFIG.dialogLine)
|
||||
rxRule = QRegularExpression(f"^{sym}.*?$")
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
return rxRule
|
||||
|
||||
@property
|
||||
def narratorBreak(self) -> QRegularExpression:
|
||||
"""Dialogue narrator break rule based on user settings."""
|
||||
sym = QRegularExpression.escape(CONFIG.narratorBreak)
|
||||
rxRule = QRegularExpression(f"\\B{sym}\\S.*?\\S{sym}\\B")
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
return rxRule
|
||||
|
||||
@property
|
||||
def altDialogStyle(self) -> QRegularExpression:
|
||||
def altDialogStyle(self) -> re.Pattern | None:
|
||||
"""Dialogue alternative rule based on user settings."""
|
||||
symO = QRegularExpression.escape(CONFIG.altDialogOpen)
|
||||
symC = QRegularExpression.escape(CONFIG.altDialogClose)
|
||||
rxRule = QRegularExpression(f"\\B{symO}.*?{symC}\\B")
|
||||
rxRule.setPatternOptions(QRegExUnicode)
|
||||
return rxRule
|
||||
if CONFIG.altDialogOpen and CONFIG.altDialogClose:
|
||||
qO = re.escape(compact(CONFIG.altDialogOpen))
|
||||
qC = re.escape(compact(CONFIG.altDialogClose))
|
||||
qB = r"\B" if (qO == qC or qC in self.AMBIGUOUS) else ""
|
||||
return re.compile(f"{qO}.*?{qC}{qB}", re.UNICODE)
|
||||
return None
|
||||
|
||||
|
||||
REGEX_PATTERNS = RegExPatterns()
|
||||
|
||||
|
||||
class DialogParser:
|
||||
|
||||
__slots__ = (
|
||||
"_quotes", "_dialog", "_alternate", "_enabled",
|
||||
"_narrator", "_breakD", "_breakQ", "_mode",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._quotes = None
|
||||
self._dialog = ""
|
||||
self._alternate = ""
|
||||
self._enabled = False
|
||||
self._narrator = ""
|
||||
self._breakD = None
|
||||
self._breakQ = None
|
||||
self._mode = ""
|
||||
return
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Return True if there are any settings to parse."""
|
||||
return self._enabled
|
||||
|
||||
def initParser(self) -> None:
|
||||
"""Init parser settings. This method must also be called when
|
||||
the config changes.
|
||||
"""
|
||||
self._quotes = REGEX_PATTERNS.dialogStyle
|
||||
self._dialog = uniqueCompact(CONFIG.dialogLine)
|
||||
self._alternate = CONFIG.narratorDialog.strip()[:1]
|
||||
|
||||
# One of the three modes are needed for the class to have
|
||||
# anything to do
|
||||
self._enabled = bool(self._quotes or self._dialog or self._alternate)
|
||||
|
||||
# Build narrator break RegExes
|
||||
if narrator := CONFIG.narratorBreak.strip()[:1]:
|
||||
punct = re.escape(".,:;!?")
|
||||
self._breakD = re.compile(f"{narrator}.*?(?:{narrator}[{punct}]?|$)", re.UNICODE)
|
||||
self._breakQ = re.compile(f"{narrator}.*?(?:{narrator}[{punct}]?)", re.UNICODE)
|
||||
self._narrator = narrator
|
||||
self._mode = f" {narrator}"
|
||||
|
||||
return
|
||||
|
||||
def __call__(self, text: str) -> list[tuple[int, int]]:
|
||||
"""Caller wrapper for dialogue processing."""
|
||||
temp: list[int] = []
|
||||
result: list[tuple[int, int]] = []
|
||||
if text:
|
||||
plain = True
|
||||
if self._dialog and text[0] in self._dialog:
|
||||
# The whole line is dialogue
|
||||
plain = False
|
||||
temp.append(0)
|
||||
temp.append(len(text))
|
||||
if self._breakD:
|
||||
# Process narrator breaks in the dialogue
|
||||
for res in self._breakD.finditer(text, 1):
|
||||
temp.append(res.start(0))
|
||||
temp.append(res.end(0))
|
||||
elif self._quotes:
|
||||
# Quoted dialogue is enabled, so we look for them
|
||||
for res in self._quotes.finditer(text):
|
||||
plain = False
|
||||
temp.append(res.start(0))
|
||||
temp.append(res.end(0))
|
||||
if self._breakQ:
|
||||
for sub in self._breakQ.finditer(text, res.start(0), res.end(0)):
|
||||
temp.append(sub.start(0))
|
||||
temp.append(sub.end(0))
|
||||
|
||||
if plain and self._alternate:
|
||||
# The main rules found no dialogue, so we check for
|
||||
# alternating dialogue sections, if enabled
|
||||
pos = 0
|
||||
for num, bit in enumerate(text.split(self._alternate)):
|
||||
length = len(bit) + (1 if num > 0 else 0)
|
||||
if num%2:
|
||||
temp.append(pos)
|
||||
temp.append(pos + length)
|
||||
pos += length
|
||||
|
||||
if temp:
|
||||
# Sort unique edges in increasing order, and add them in pairs
|
||||
start = None
|
||||
for pos in sorted(set(temp)):
|
||||
if start is None:
|
||||
start = pos
|
||||
else:
|
||||
result.append((start, pos))
|
||||
start = None
|
||||
|
||||
return result
|
||||
|
||||
@@ -179,14 +179,20 @@ class GuiManuscriptBuild(NDialog):
|
||||
self.buildBox.setVerticalSpacing(sp4)
|
||||
|
||||
# Dialog Buttons
|
||||
self.buttonBox = QDialogButtonBox(self)
|
||||
|
||||
self.btnOpen = QPushButton(SHARED.theme.getIcon("browse"), self.tr("Open Folder"), self)
|
||||
self.btnOpen.setIconSize(bSz)
|
||||
self.btnOpen.setAutoDefault(False)
|
||||
self.buttonBox.addButton(self.btnOpen, QtRoleAction)
|
||||
|
||||
self.btnBuild = QPushButton(SHARED.theme.getIcon("export"), self.tr("&Build"), self)
|
||||
self.btnBuild.setIconSize(bSz)
|
||||
self.btnBuild.setAutoDefault(True)
|
||||
self.buttonBox.addButton(self.btnBuild, QtRoleAction)
|
||||
|
||||
self.dlgButtons = QDialogButtonBox(QtDialogClose, self)
|
||||
self.dlgButtons.addButton(self.btnOpen, QtRoleAction)
|
||||
self.dlgButtons.addButton(self.btnBuild, QtRoleAction)
|
||||
self.btnClose = self.buttonBox.addButton(QtDialogClose)
|
||||
self.btnClose.setAutoDefault(False)
|
||||
|
||||
# Assemble GUI
|
||||
# ============
|
||||
@@ -213,7 +219,7 @@ class GuiManuscriptBuild(NDialog):
|
||||
self.outerBox.addSpacing(sp4)
|
||||
self.outerBox.addLayout(self.buildBox, 0)
|
||||
self.outerBox.addSpacing(sp16)
|
||||
self.outerBox.addWidget(self.dlgButtons, 0)
|
||||
self.outerBox.addWidget(self.buttonBox, 0)
|
||||
self.outerBox.setSpacing(0)
|
||||
|
||||
self.setLayout(self.outerBox)
|
||||
@@ -229,7 +235,7 @@ class GuiManuscriptBuild(NDialog):
|
||||
# Signals
|
||||
self.btnReset.clicked.connect(self._doResetBuildName)
|
||||
self.btnBrowse.clicked.connect(self._doSelectPath)
|
||||
self.dlgButtons.clicked.connect(self._dialogButtonClicked)
|
||||
self.buttonBox.clicked.connect(self._dialogButtonClicked)
|
||||
self.listFormats.itemSelectionChanged.connect(self._resetProgress)
|
||||
|
||||
logger.debug("Ready: GuiManuscriptBuild")
|
||||
@@ -260,7 +266,7 @@ class GuiManuscriptBuild(NDialog):
|
||||
@pyqtSlot("QAbstractButton*")
|
||||
def _dialogButtonClicked(self, button: QAbstractButton) -> None:
|
||||
"""Handle button clicks from the dialog button box."""
|
||||
role = self.dlgButtons.buttonRole(button)
|
||||
role = self.buttonBox.buttonRole(button)
|
||||
if role == QtRoleAction:
|
||||
if button == self.btnBuild:
|
||||
self._runBuild()
|
||||
@@ -333,7 +339,7 @@ class GuiManuscriptBuild(NDialog):
|
||||
docBuild.queueAll()
|
||||
|
||||
self.buildProgress.setMaximum(len(docBuild))
|
||||
for i, _ in docBuild.iterBuild(buildPath, bFormat):
|
||||
for i, _ in docBuild.iterBuildDocument(buildPath, bFormat):
|
||||
self.buildProgress.setValue(i+1)
|
||||
|
||||
self._build.setLastBuildPath(bPath)
|
||||
@@ -388,13 +394,9 @@ class GuiManuscriptBuild(NDialog):
|
||||
if isinstance(rItem, NWItem):
|
||||
rootMap[rHandle] = rItem.itemName
|
||||
|
||||
itemIcon = SHARED.theme.getItemIcon(
|
||||
nwItem.itemType, nwItem.itemClass,
|
||||
nwItem.itemLayout, nwItem.mainHeading
|
||||
)
|
||||
rootName = rootMap.get(rHandle, "??????")
|
||||
item = QListWidgetItem(f"{rootName}: {nwItem.itemName}")
|
||||
item.setIcon(itemIcon)
|
||||
item.setIcon(nwItem.getMainIcon())
|
||||
self.listContent.addItem(item)
|
||||
|
||||
return
|
||||
|
||||