Compare commits

...

372 Commits
v2.5 ... v2.6b2

Author SHA1 Message Date
Veronica Berglyd Olsen
83fca9fd94 Release 2.6 Beta 2 (#2139) 2024-12-23 15:06:51 +01:00
Veronica Berglyd Olsen
4150a9740b Bump version and update changelog 2024-12-23 15:00:01 +01:00
Veronica Berglyd Olsen
f7021559d0 Refactor quoted dialogue detection (#2138) 2024-12-23 14:23:53 +01:00
Veronica Berglyd Olsen
00d4c550f1 Update tests 2024-12-23 14:20:02 +01:00
Veronica Berglyd Olsen
97903993c8 Restructure dialogue detection from quotes 2024-12-23 14:19:47 +01:00
Veronica Berglyd Olsen
3d858ed9ab Remove boundary condition in quote dialogue highlighting in most cases (#2131) 2024-12-09 00:23:48 +01:00
Veronica Berglyd Olsen
cc5cd50065 Improve editor auto-replace (#2132) 2024-12-05 23:41:22 +01:00
Veronica Berglyd Olsen
0c0d909dcb Remove debug code 2024-12-05 23:34:12 +01:00
Veronica Berglyd Olsen
8dafaa13c0 Use slots for some of the often called attributes in the editor 2024-12-05 23:01:17 +01:00
Veronica Berglyd Olsen
05385ba73b Improve the text auto-replace class 2024-12-05 22:57:43 +01:00
Veronica Berglyd Olsen
924a2c3381 Move editor auto-replace to a separate class 2024-12-05 22:40:15 +01:00
Veronica Berglyd Olsen
6502c424e2 Align headers to bottom of page margin for ODT and DocX (#2128) 2024-11-29 12:52:50 +01:00
Veronica Berglyd Olsen
243124a4ba Update tests 2024-11-29 00:41:15 +01:00
Veronica Berglyd Olsen
4a2a14bc05 Make header margins similar in ODT and DocX files (#2120) 2024-11-29 00:40:45 +01:00
Veronica Berglyd Olsen
976328c9dc Fix font style and PDF issues (#2122) 2024-11-27 22:59:20 +01:00
Veronica Berglyd Olsen
3b0086e75e Use a frame for page break marker in manuscript preview 2024-11-26 23:56:31 +01:00
Veronica Berglyd Olsen
7c38a748b6 Merge branch 'main' into font_styles 2024-11-26 22:58:32 +01:00
Veronica Berglyd Olsen
4d98689304 Merge release 2.5.3 2024-11-26 22:57:51 +01:00
Veronica Berglyd Olsen
8307822e20 Release 2.5.3 (#2126) 2024-11-26 22:27:30 +01:00
Veronica Berglyd Olsen
f1548dc8ea Fix some issues in the changelog 2024-11-26 22:23:15 +01:00
Veronica Berglyd Olsen
25b33ab4d3 Bump version to 2.5.3 and update changelog 2024-11-26 22:21:30 +01:00
Veronica Berglyd Olsen
4af98dcab5 Add Russian translation 2024-11-26 22:13:55 +01:00
Veronica Berglyd Olsen
3cf916c845 Update translations 2024-11-26 22:10:43 +01:00
Veronica Berglyd Olsen
bcac05ca20 Only use 1200 DPI for scalable fonts 2024-11-26 21:42:54 +01:00
Veronica Berglyd Olsen
07c61450c7 Try forcing resolution for PDF printer 2024-11-26 18:57:07 +01:00
Veronica Berglyd Olsen
43d95462e0 Optimise a few details and add some tests 2024-11-26 17:07:02 +01:00
Veronica Berglyd Olsen
c091e0b9bb Apply the font matcher closer to config read 2024-11-26 17:06:34 +01:00
Veronica Berglyd Olsen
18c70ffe3e Allow setting resolution for ToQTextDocument converter 2024-11-26 02:22:36 +01:00
Veronica Berglyd Olsen
ff470028be Add a font matcher for text fonts (#2118) 2024-11-26 02:03:09 +01:00
Veronica Berglyd Olsen
8463188336 Fix default font in editor 2024-11-26 01:06:47 +01:00
Veronica Berglyd Olsen
2808c36753 Rename tokenizer setFont to setTextFont 2024-11-26 00:40:09 +01:00
Veronica Berglyd Olsen
05b1fdd7f2 Drop relative font weights in ODT output 2024-11-25 23:43:38 +01:00
Veronica Berglyd Olsen
00017e8065 Reset to default font style after emphasis in QTextDocuments (#2121) 2024-11-25 23:36:35 +01:00
Veronica Berglyd Olsen
ff34112532 Rewrite project tree as a model/view set (#2119) 2024-11-25 00:11:53 +01:00
Veronica Berglyd Olsen
4cadd1597e Add coverage to remaining missed lines 2024-11-25 00:07:32 +01:00
Veronica Berglyd Olsen
e30d2cbbf1 Always use forward slash for ToC file 2024-11-24 19:25:46 +01:00
Veronica Berglyd Olsen
0449cf824d Add test coverage of the gui project tree 2024-11-24 18:39:23 +01:00
Veronica Berglyd Olsen
32d899dbfb Fix windows path 2024-11-24 02:53:54 +01:00
Veronica Berglyd Olsen
9ec9c55c3f Add test coverage of the project tree 2024-11-24 02:48:17 +01:00
Veronica Berglyd Olsen
01b2b53fd5 Add test coverage of the item model 2024-11-24 00:43:01 +01:00
Veronica Berglyd Olsen
be6888f8c8 Fix Python 3.9 incompatible type 2024-11-23 20:29:02 +01:00
Veronica Berglyd Olsen
c1ed0647fe Refactor project signal proxies 2024-11-23 19:56:24 +01:00
Veronica Berglyd Olsen
1bf42ceb55 Update tests other than tree-related 2024-11-23 19:30:29 +01:00
Veronica Berglyd Olsen
ca8bd8d271 Disable project tree tests in core and gui 2024-11-22 17:04:53 +01:00
Veronica Berglyd Olsen
bf23a4920f Merge branch 'main' into project_model 2024-11-22 16:46:50 +01:00
Veronica Berglyd Olsen
cfb048ec19 Update log formatting again (#2117) 2024-11-22 16:46:28 +01:00
Veronica Berglyd Olsen
5284bea0f0 Rewrite log colouring 2024-11-22 16:41:11 +01:00
Veronica Berglyd Olsen
1244a4058c Merge branch 'main' into project_model 2024-11-22 02:22:47 +01:00
Veronica Berglyd Olsen
8c51363651 Colourise log output (#2114) 2024-11-22 02:22:10 +01:00
Veronica Berglyd Olsen
893f18c7ad Add test coverage 2024-11-22 02:18:31 +01:00
Veronica Berglyd Olsen
909c97feda Add ANSI colours to log output 2024-11-22 01:47:21 +01:00
Veronica Berglyd Olsen
fea989c1f8 Update comments 2024-11-22 00:56:33 +01:00
Veronica Berglyd Olsen
a64ed405f3 Add some clear data functions 2024-11-22 00:44:14 +01:00
Veronica Berglyd Olsen
ffe6cd0a65 Update item change signals 2024-11-22 00:15:12 +01:00
Veronica Berglyd Olsen
e0b27317f3 Make sure deleted documents are closed if open 2024-11-21 22:24:34 +01:00
Veronica Berglyd Olsen
7ed619cf2e Restore project item emphasis 2024-11-21 01:33:09 +01:00
Veronica Berglyd Olsen
d55f53be0a Clean up item notification and item main icon 2024-11-21 01:18:28 +01:00
Veronica Berglyd Olsen
55eed706fb Re-implement word counters 2024-11-21 00:14:54 +01:00
Veronica Berglyd Olsen
2f71ec68d8 Re-implement row highlight, empty trash, and fix recursive permanent delete 2024-11-20 23:07:40 +01:00
Veronica Berglyd Olsen
a45ab2f017 Remove tooltip for non-checkable items 2024-11-20 22:36:44 +01:00
Veronica Berglyd Olsen
26b4da9eb4 Fix most tests with minor issues 2024-11-20 19:58:34 +01:00
Veronica Berglyd Olsen
4897f6ad69 Update tests 2024-11-20 18:48:54 +01:00
Veronica Berglyd Olsen
bbe8683d59 Re-implement project item duplication 2024-11-20 18:48:14 +01:00
Veronica Berglyd Olsen
072be78991 Re-enable project tree context menu and clean up split and merge 2024-11-20 16:42:08 +01:00
Veronica Berglyd Olsen
3f41e62e21 Move some variables around 2024-11-20 16:41:26 +01:00
Veronica Berglyd Olsen
ec01336c21 Fix active status icon and tooltip 2024-11-20 13:06:49 +01:00
Veronica Berglyd Olsen
5dcfe5c874 Allow drag and drop to open documents 2024-11-20 02:12:28 +01:00
Veronica Berglyd Olsen
72f1bc2d77 Add model support for drag and drop 2024-11-20 01:31:56 +01:00
Veronica Berglyd Olsen
54685c8184 Reimplement item deletion 2024-11-19 23:26:01 +01:00
Veronica Berglyd Olsen
3532306833 Add move to trash functionality 2024-11-19 17:48:01 +01:00
Veronica Berglyd Olsen
442b065834 Handle item consistency in the nodes tree 2024-11-19 17:47:21 +01:00
Veronica Berglyd Olsen
53e70c118c Add back create new project item functionality 2024-11-19 00:22:59 +01:00
Veronica Berglyd Olsen
a5575992d6 Re-enable project tree move item and navigation 2024-11-18 18:04:25 +01:00
Veronica Berglyd Olsen
df0244095f Rewrite how project items are refreshed on the GUI 2024-11-18 17:51:05 +01:00
Veronica Berglyd Olsen
337f05f60e Re-enable item rename 2024-11-18 00:01:40 +01:00
Veronica Berglyd Olsen
4f8e898440 Switch data tree to new model structure 2024-11-17 23:24:49 +01:00
Veronica Berglyd Olsen
7991b9b27c Re-enable item selection 2024-11-17 22:41:45 +01:00
Veronica Berglyd Olsen
ef42e1956c Merge new and old project tree 2024-11-17 22:07:37 +01:00
Veronica Berglyd Olsen
27b3dde6f6 Complete build initial tree and save tree logic 2024-11-17 21:24:55 +01:00
Veronica Berglyd Olsen
cbe557507f Add a basic project tree model 2024-11-17 19:25:55 +01:00
Veronica Berglyd Olsen
7a9da5f219 Merge branch 'release' 2024-11-16 20:58:09 +01:00
Veronica Berglyd Olsen
d944a70063 Fix secondary issues due to Qt tree widget bug (#2109) 2024-11-16 20:35:14 +01:00
Veronica Berglyd Olsen
c20f970405 Block drops on project tree viewport, and allow non-root items on root to be deleted (#2108) 2024-11-16 20:21:57 +01:00
Veronica Berglyd Olsen
49bb7f3a34 Fix default button issue on Build dialog (#2105) 2024-11-15 21:51:25 +01:00
Veronica Berglyd Olsen
021827ff2d Force the Build button to be default on the build dialog (#2101) 2024-11-15 20:49:45 +01:00
Veronica Berglyd Olsen
97312aecf5 Fix PDF formatting (#2104) 2024-11-15 20:25:29 +01:00
Veronica Berglyd Olsen
f3c9a77989 Revert to QPrinter, but update paint device 2024-11-15 19:31:55 +01:00
Veronica Berglyd Olsen
2c031bc093 Skip anchors when generating PDF 2024-11-15 01:34:02 +01:00
Veronica Berglyd Olsen
69147c2480 Correct pixel units 2024-11-15 01:27:49 +01:00
Veronica Berglyd Olsen
22ca0045bc Correct font scaling on PDF output 2024-11-15 00:40:58 +01:00
Veronica Berglyd Olsen
af431a4766 Merge branch 'main' into pdf_format_fixes 2024-11-14 23:20:12 +01:00
Veronica Berglyd Olsen
5a8045131b Update editor toolbar icon (#2103) 2024-11-14 23:18:43 +01:00
Veronica Berglyd Olsen
7c0a6f1d02 Use correct em calculation in qdoc class 2024-11-14 23:18:22 +01:00
Veronica Berglyd Olsen
4fc69ef7c6 Replace printer with Qt's PDF writer 2024-11-14 23:17:54 +01:00
Veronica Berglyd Olsen
91e502deb6 Add a new icon for the editor toolbar 2024-11-14 21:10:33 +01:00
Veronica Berglyd Olsen
1ee191220f Refactor manuscript format text scaling and text margin settings (#2102) 2024-11-13 23:22:28 +01:00
Veronica Berglyd Olsen
a96ccf8422 Add icons for text margins 2024-11-13 23:16:54 +01:00
Veronica Berglyd Olsen
57641ecd7d Make page number work with Office 365 2024-11-13 20:50:47 +01:00
Veronica Berglyd Olsen
ed431aa30f Update tests 2024-11-13 20:40:44 +01:00
Veronica Berglyd Olsen
7a111bb173 Update default margins 2024-11-13 20:24:10 +01:00
Veronica Berglyd Olsen
220be06631 Make HTML margins scale to em units 2024-11-13 20:05:03 +01:00
Veronica Berglyd Olsen
d8c14f6712 Make ODT margins scale to em units 2024-11-13 20:04:52 +01:00
Veronica Berglyd Olsen
e514b1be4f Fix DocX line height setting 2024-11-13 19:51:36 +01:00
Veronica Berglyd Olsen
5e7ef1d25a Minor improvements for 2.6 beta release (#2095) 2024-11-13 18:43:11 +01:00
Veronica Berglyd Olsen
c2b3eb4ac2 Fix issue of alternative unicode symbols being replaced too early 2024-11-13 00:03:38 +01:00
Veronica Berglyd Olsen
1bd8370864 Update dialogue highlighting settings text in Preferences 2024-11-12 23:10:57 +01:00
Veronica Berglyd Olsen
8c952953e3 Release 2.6 Beta 1 (#2092) 2024-11-12 17:42:59 +01:00
Veronica Berglyd Olsen
c065c9b30f Fix a couple of mistakes in the changelog 2024-11-12 17:35:59 +01:00
Veronica Berglyd Olsen
a18eb1158e Bump version to 2.6b1 and update changelog 2024-11-12 00:38:49 +01:00
Veronica Berglyd Olsen
08ef352288 GUI improvements and fixes (#2089) 2024-11-08 22:02:35 +01:00
Veronica Berglyd Olsen
25297b8b0b Add some coverage settings to the main project file 2024-11-08 17:34:06 +01:00
Veronica Berglyd Olsen
ea591fe707 Fix bug in outline view preserving details panel split position 2024-11-08 17:31:23 +01:00
Veronica Berglyd Olsen
b0bcf8605c Fix closing app with no project bug 2024-11-08 17:30:54 +01:00
Veronica Berglyd Olsen
b28921d6c2 Add an edit button to the viewer (#1963) 2024-11-08 17:30:10 +01:00
Veronica Berglyd Olsen
87ae7585e8 Add status column to project outline (#2088) 2024-11-08 00:37:57 +01:00
Veronica Berglyd Olsen
b39b37fb2f Add timing to outline build 2024-11-08 00:34:21 +01:00
Veronica Berglyd Olsen
cd9e0de9dd Update test 2024-11-07 23:59:52 +01:00
Veronica Berglyd Olsen
5107849909 Add status column to outline 2024-11-07 23:55:59 +01:00
Veronica Berglyd Olsen
5c988960f5 Revert a mistake in page break cleanup 2024-11-07 19:05:49 +01:00
Veronica Berglyd Olsen
44792778aa Simplify collection of raw text (#2087) 2024-11-07 19:03:34 +01:00
Veronica Berglyd Olsen
0a7bd68258 Simplify collection of raw text 2024-11-07 18:56:16 +01:00
Veronica Berglyd Olsen
30118eceaa Make page breaks visible in manuscript preview (#2086) 2024-11-07 18:39:10 +01:00
Veronica Berglyd Olsen
87512eb13b Rename variable 2024-11-07 18:33:53 +01:00
Veronica Berglyd Olsen
9919e1a49e Add new features to new project title page 2024-11-07 18:31:08 +01:00
Veronica Berglyd Olsen
d04a21b70f Improve page break handling in the tokenizer 2024-11-07 17:53:19 +01:00
Veronica Berglyd Olsen
fc6e0a577f Add a switch to enable/disable showing page breaks in the manuscript tool 2024-11-07 17:52:56 +01:00
Veronica Berglyd Olsen
0c659422b7 Add page break markers to the QTextDocument format class 2024-11-07 17:52:01 +01:00
Veronica Berglyd Olsen
2018023dc7 Remove redundant if-checks 2024-11-07 14:34:35 +01:00
Veronica Berglyd Olsen
04645cab71 Merge bugfix for #2080 from 2.5 release branch 2024-11-05 18:46:12 +01:00
Veronica Berglyd Olsen
bec082c0ae Fix HTML formatting issue (#2085) 2024-11-05 18:44:05 +01:00
Veronica Berglyd Olsen
83b244a8a3 Fix head tags order in HTML output (#2080) 2024-11-05 18:39:43 +01:00
Veronica Berglyd Olsen
ae261381aa Add build copy feature (#2084) 2024-11-05 18:38:32 +01:00
Veronica Berglyd Olsen
b088b79ee8 Add test coverage 2024-11-05 18:32:02 +01:00
Veronica Berglyd Olsen
0b59ee8634 Add duplicate build feature 2024-11-05 18:17:43 +01:00
Veronica Berglyd Olsen
f62b755903 Add copy button icon 2024-11-05 18:17:21 +01:00
Veronica Berglyd Olsen
04036ef35c Improve dialogue highlighting after feedback (#2082) 2024-11-05 17:34:04 +01:00
Veronica Berglyd Olsen
e4a2ca2d19 Add horizontal bar to em dash conversion in tokenizer 2024-11-04 17:32:22 +01:00
Veronica Berglyd Olsen
89f452f3fc Restructure and improve dialogue highlighting 2024-11-04 17:28:21 +01:00
Veronica Berglyd Olsen
843f8e0d39 Add missing line in dialogue highlighting check 2024-11-04 00:21:25 +01:00
Veronica Berglyd Olsen
8618306230 Update dialogue handling again (#2081) 2024-11-03 22:40:17 +01:00
Veronica Berglyd Olsen
34a3e17326 Fix quotes regex issue and update tests 2024-11-03 22:09:16 +01:00
Veronica Berglyd Olsen
5af49dfc6f Add a separate setting for alternating dialogue/narration 2024-11-03 21:45:00 +01:00
Veronica Berglyd Olsen
837d3060b8 Separate single and double quoted dialogue 2024-11-03 21:14:36 +01:00
Veronica Berglyd Olsen
2754f78083 Merge Spanish and Portuguese narrator break logic 2024-11-03 21:14:11 +01:00
Veronica Berglyd Olsen
1b509ad60b Add a dialogue parser, and horizontal bar auto-complete (#2079) 2024-11-01 22:23:16 +01:00
Veronica Berglyd Olsen
82e923b181 Remove debug code 2024-11-01 22:11:35 +01:00
Veronica Berglyd Olsen
e92b8400c7 Add dialogue parser test coverage 2024-11-01 22:07:47 +01:00
Veronica Berglyd Olsen
1ad6a80682 Update auto-replace in editor, and update tests 2024-11-01 21:17:46 +01:00
Veronica Berglyd Olsen
f355b82bba Add auto-replace for horizontal bar 2024-11-01 20:49:16 +01:00
Veronica Berglyd Olsen
66567b6d6f Allow up to 4 dialogue line symbols 2024-11-01 20:07:35 +01:00
Veronica Berglyd Olsen
d9582436d4 Update tests 2024-11-01 19:55:56 +01:00
Veronica Berglyd Olsen
31c75ea0b1 Add a dialog parser class 2024-11-01 19:55:35 +01:00
Veronica Berglyd Olsen
057fc10565 Refactor and clean up GUI code (#2078) 2024-10-31 18:42:32 +01:00
Veronica Berglyd Olsen
bbb07c9963 Make some minor GUI tweaks 2024-10-31 18:35:28 +01:00
Veronica Berglyd Olsen
dd09d3af2f Refactor outline details panel code 2024-10-31 18:28:58 +01:00
Veronica Berglyd Olsen
ccb5f8418b Remove redundant function in project tree 2024-10-31 18:28:35 +01:00
Veronica Berglyd Olsen
55d2605222 Use the new qtLambda wrapper where appropriate 2024-10-31 17:31:58 +01:00
Veronica Berglyd Olsen
7f49979bc9 Clean up project tree class 2024-10-31 17:30:49 +01:00
Veronica Berglyd Olsen
b529d5b125 Improve document editing and test coverage (#2077) 2024-10-31 00:21:19 +01:00
Veronica Berglyd Olsen
f2e36d9974 Cover missing lines in error module 2024-10-31 00:18:16 +01:00
Veronica Berglyd Olsen
ca2b509d54 Make the test a little safer 2024-10-31 00:11:32 +01:00
Veronica Berglyd Olsen
0ba0674b9e Some code flow improvements and main GUI test coverage 2024-10-31 00:07:48 +01:00
Veronica Berglyd Olsen
a2b09fdfec Update various features in the doc editor using new helper code 2024-10-30 19:13:13 +01:00
Veronica Berglyd Olsen
fa0bef5500 Extend and refactor current open document highlighting 2024-10-30 19:12:09 +01:00
Veronica Berglyd Olsen
92d148b03d Allow inserting stats in documents (#2073) 2024-10-30 00:14:48 +01:00
Veronica Berglyd Olsen
3d935e29eb Update test coverage 2024-10-30 00:08:31 +01:00
Veronica Berglyd Olsen
3749c9f21d Fix variable formatting for DocX and ODT 2024-10-29 17:50:51 +01:00
Veronica Berglyd Olsen
2a3d3e46c2 Pin tests to set locale to en_GB 2024-10-29 17:50:51 +01:00
Veronica Berglyd Olsen
9bc1561e01 Change to use document locale for integers 2024-10-29 17:50:51 +01:00
Veronica Berglyd Olsen
d987246062 Use Qt for int locale 2024-10-29 17:50:50 +01:00
Veronica Berglyd Olsen
c6a22e2d96 Add stats variables to Lipsum test project 2024-10-29 17:50:50 +01:00
Veronica Berglyd Olsen
42f904f1ea Add field value insertion to all formats 2024-10-29 17:50:49 +01:00
Veronica Berglyd Olsen
f92521c93a Process field values in QTextDocuments 2024-10-29 17:50:49 +01:00
Veronica Berglyd Olsen
041183f914 Add processing of fields in the Tokenizer 2024-10-29 17:50:49 +01:00
Veronica Berglyd Olsen
857b6ddc55 Update main menu 2024-10-29 17:50:48 +01:00
Veronica Berglyd Olsen
f2d2b4c4f2 Generalise the manuscript stats count values 2024-10-29 17:47:32 +01:00
Veronica Berglyd Olsen
8725edaf7e Refactor main menu (#2075) 2024-10-29 17:46:16 +01:00
Veronica Berglyd Olsen
4eace705e3 Add a function wrapper that makes the linter happy when used for Qt slots 2024-10-29 17:37:50 +01:00
Veronica Berglyd Olsen
b2d71acd93 Connect menu actions to main GUI in the menu class 2024-10-29 17:36:54 +01:00
Veronica Berglyd Olsen
e424fcfe53 Add support for referencing tags in novel documents (#2072) 2024-10-28 00:18:46 +01:00
Veronica Berglyd Olsen
eefb40cb1f Update tests 2024-10-28 00:12:06 +01:00
Veronica Berglyd Olsen
785cd3aea9 Add support for story reference, and update mention reference in outline details 2024-10-28 00:08:28 +01:00
Veronica Berglyd Olsen
07c700f666 Rewrite narrator break for dialogue highlighting (#2068) 2024-10-27 13:15:57 +01:00
Veronica Berglyd Olsen
8db0928724 Re-order preferences settings and ensure dialog/narrator symbols are single characters 2024-10-27 13:11:50 +01:00
Veronica Berglyd Olsen
b25ba9de27 Add test coverage of the highlighter 2024-10-27 13:05:24 +01:00
Veronica Berglyd Olsen
bf478a6706 Update tests 2024-10-27 02:22:39 +02:00
Veronica Berglyd Olsen
a1a58f51b1 Add new logic for dialogue narrator break to tokenizer 2024-10-27 02:15:06 +02:00
Veronica Berglyd Olsen
8850161641 Add new logic for dialogue narrator break 2024-10-27 01:55:19 +02:00
Veronica Berglyd Olsen
f931148594 Bump version to 2.6 Alpha 3 2024-10-27 00:35:57 +02:00
Veronica Berglyd Olsen
c31b0bcf17 Add Url recognition and formatting (#2067) 2024-10-27 00:17:11 +02:00
Veronica Berglyd Olsen
ba260ac608 Add test coverage of new features, and some other places 2024-10-27 00:13:18 +02:00
Veronica Berglyd Olsen
f2fc19b168 Add test coverage of new features 2024-10-26 23:28:34 +02:00
Veronica Berglyd Olsen
ceabc666c3 Add font table, fix core.xml bug, clean up code, update tests, for DocX 2024-10-26 17:09:50 +02:00
Veronica Berglyd Olsen
6c217c2450 Fix spell checker issue 2024-10-25 23:53:38 +02:00
Veronica Berglyd Olsen
7ff8921420 Add links to DocX 2024-10-25 23:19:23 +02:00
Veronica Berglyd Olsen
fe56c88309 Separate spell checking and link scanning, and add ODT support for links 2024-10-25 23:19:23 +02:00
Veronica Berglyd Olsen
b13b91c8e8 Add url to Lipsum test project 2024-10-25 23:19:22 +02:00
Veronica Berglyd Olsen
d71435df53 Distinguish between anchor ref and href in tokenizer 2024-10-25 23:19:22 +02:00
Veronica Berglyd Olsen
058758122c Add url support in tokenizer and qdoc preview 2024-10-25 23:18:27 +02:00
Veronica Berglyd Olsen
759cfd77e9 Add url handling in editor and viewer 2024-10-25 23:18:27 +02:00
Veronica Berglyd Olsen
37268b873a Add URL regex pattern 2024-10-25 23:18:22 +02:00
Veronica Berglyd Olsen
439a04c603 Add support for mentions references (#2064) 2024-10-25 23:17:36 +02:00
Veronica Berglyd Olsen
9f642d501a Update documentation 2024-10-25 00:20:17 +02:00
Veronica Berglyd Olsen
7f8e2c66a6 Update tests 2024-10-25 00:13:09 +02:00
Veronica Berglyd Olsen
9962fd4a55 Add menu entry and shortcut 2024-10-25 00:13:03 +02:00
Veronica Berglyd Olsen
c27343534f Add mentions to sample project 2024-10-24 23:58:37 +02:00
Veronica Berglyd Olsen
46ecb2cf70 Add mentions to outline 2024-10-24 23:57:39 +02:00
Veronica Berglyd Olsen
88aa3f38c2 Add autocomplete for mentions 2024-10-24 23:57:10 +02:00
Veronica Berglyd Olsen
9214da3756 Add mention keyword 2024-10-24 23:53:48 +02:00
Veronica Berglyd Olsen
41e5152771 Add forced line breaks feature (#2063) 2024-10-24 17:55:56 +02:00
Veronica Berglyd Olsen
6b01bd7ffe Fix title page break issue and add test coverage 2024-10-24 17:47:14 +02:00
Veronica Berglyd Olsen
76a9f8e3df Fix a minor issue, and add test 2024-10-24 14:32:54 +02:00
Veronica Berglyd Olsen
e15941520f Add working forced line break implementation 2024-10-24 01:25:32 +02:00
Veronica Berglyd Olsen
ff7404d01d Add line break shortcode support 2024-10-24 01:07:03 +02:00
Veronica Berglyd Olsen
560540618c Process heading line breaks in the Tokenizer 2024-10-24 00:02:39 +02:00
Veronica Berglyd Olsen
bfa82f6bb3 Add test coverage of title line breaks, and don't break markdown titles 2024-10-24 00:02:39 +02:00
Veronica Berglyd Olsen
f77cd1c752 Fix counting error (#2062) 2024-10-24 00:01:39 +02:00
Veronica Berglyd Olsen
8461b0c15d Fix wrong character count when there is a footnote 2024-10-23 23:57:42 +02:00
Veronica Berglyd Olsen
2938a0f122 Remove version dependency in DocX tests 2024-10-23 17:44:55 +02:00
Veronica Berglyd Olsen
beccd1a7d1 Bump version to 2.6 Alpha 2 2024-10-23 17:41:28 +02:00
Veronica Berglyd Olsen
c6883908f1 Improve text document formatting (#2061) 2024-10-23 17:39:13 +02:00
Veronica Berglyd Olsen
36d8547f78 Correct misspelled variable 2024-10-23 17:36:12 +02:00
Veronica Berglyd Olsen
b01c91c0a3 Remove more redundant code 2024-10-23 17:20:07 +02:00
Veronica Berglyd Olsen
3712b2e93f Combine the different ways of storing generated text data in formats 2024-10-23 17:12:13 +02:00
Veronica Berglyd Olsen
0ddc1131dc Replace heading field with a general meta data field in the text block tuple 2024-10-23 16:20:50 +02:00
Veronica Berglyd Olsen
f4a941cd93 Fix format tag order for HTML validation 2024-10-23 11:01:06 +02:00
Veronica Berglyd Olsen
db72f2a722 Remove redundant code 2024-10-23 10:31:27 +02:00
Veronica Berglyd Olsen
16023676ea Simplify CSS in HTML format 2024-10-23 01:27:36 +02:00
Veronica Berglyd Olsen
52e85835df Flip if statement in Tokenizer 2024-10-23 01:26:48 +02:00
Veronica Berglyd Olsen
fe63770d21 Refactor and standardise text format modules (#2060) 2024-10-22 21:51:19 +02:00
Veronica Berglyd Olsen
bd5442c9e5 Process tags and references in Tokenizer 2024-10-22 21:37:18 +02:00
Veronica Berglyd Olsen
c7554f2b8d Process keyword flag in Tokenizer 2024-10-22 19:33:05 +02:00
Veronica Berglyd Olsen
63f9cd0bac Tokenize dialogue with colour tag 2024-10-22 18:41:34 +02:00
Veronica Berglyd Olsen
c537d0f396 Process comments in the Tokenizer 2024-10-22 17:37:00 +02:00
Veronica Berglyd Olsen
e759aa8d1b Move colour theme to Tokenizer class 2024-10-22 17:07:57 +02:00
Veronica Berglyd Olsen
2d8664b3ce Clean up wrapped code, and rename test variable 2024-10-22 00:40:54 +02:00
Veronica Berglyd Olsen
16076ebfb6 Move shared formats code to separate file, and rename some variables 2024-10-22 00:25:35 +02:00
Veronica Berglyd Olsen
38ca447c8a Convert format codes to enums 2024-10-21 23:37:27 +02:00
Veronica Berglyd Olsen
f91006631d Rename ODT test files 2024-10-21 18:56:37 +02:00
Veronica Berglyd Olsen
3cc5e4cc7a Handle text justify in Tokenizer instead 2024-10-21 18:54:19 +02:00
Veronica Berglyd Olsen
75178b7874 Merge branch 'release' 2024-10-21 17:14:32 +02:00
Veronica Berglyd Olsen
ce3f6dae20 Add DocX support (#2056) 2024-10-21 17:13:42 +02:00
Veronica Berglyd Olsen
bd82392d71 Add Python 3.13 and fix theme issue and broken link (#2059) 2024-10-21 17:12:09 +02:00
Veronica Berglyd Olsen
836a3d353a Update Enchant link (#2042) 2024-10-21 17:08:35 +02:00
Veronica Berglyd Olsen
a4d2a9ea6f Fix swapped colours in default light theme (#2057) 2024-10-21 17:05:19 +02:00
Veronica Berglyd Olsen
b0fc8d63a6 Add Python 3.13 support 2024-10-21 17:03:52 +02:00
Veronica Berglyd Olsen
ec7781fb9f Generalise ODT variables to DOC 2024-10-21 17:00:44 +02:00
Veronica Berglyd Olsen
787e1a46d1 Add DocX test coverage 2024-10-21 16:59:43 +02:00
Veronica Berglyd Olsen
148d46fcbc Add settings to DocX and compression to ODT and DocX 2024-10-20 23:08:14 +02:00
Veronica Berglyd Olsen
7e1a676e0a Add footnote support to DocX 2024-10-20 22:01:13 +02:00
Veronica Berglyd Olsen
e9220c5e77 Add DocX page settings and header support 2024-10-20 21:02:53 +02:00
Veronica Berglyd Olsen
72d41d4883 Refactor XML generation for DocX 2024-10-20 17:07:33 +02:00
Veronica Berglyd Olsen
de6c3e9b45 Fix issue with global justify, and add indentation to DocX 2024-10-19 20:14:01 +02:00
Veronica Berglyd Olsen
a0ab507ed1 Fix DocX tabs in text, and page break before issues 2024-10-19 19:04:16 +02:00
Veronica Berglyd Olsen
3c76d0e7e3 Fix issue with line spacing in DocX 2024-10-19 17:08:24 +02:00
Veronica Berglyd Olsen
34127d78bf Move custom XML sub element function to common 2024-10-19 17:08:08 +02:00
Veronica Berglyd Olsen
ca33f488f1 Get rid of the DocXRun class 2024-10-19 14:43:06 +02:00
Veronica Berglyd Olsen
717aac9dc8 Add remaining code blocks, and add colours 2024-10-18 21:18:59 +02:00
Veronica Berglyd Olsen
4b07dbadb2 Basic DocX text formatting works 2024-10-18 20:31:21 +02:00
Veronica Berglyd Olsen
9bbebd2085 Add DocX text styles 2024-10-18 18:35:56 +02:00
Veronica Berglyd Olsen
d60b29e0a2 make some improvements to the ODT writer 2024-10-18 16:37:42 +02:00
Veronica Berglyd Olsen
80d2dfd9a4 Add structure for DocX documents 2024-10-18 16:37:04 +02:00
Veronica Berglyd Olsen
464454e7a6 Expose text margin settings in Manuscript Build (#2051) 2024-10-17 22:41:42 +02:00
Veronica Berglyd Olsen
d145b65237 Standardise text sizes and margins 2024-10-17 22:34:46 +02:00
Veronica Berglyd Olsen
912198cc40 Add text margin settings 2024-10-17 21:57:04 +02:00
Veronica Berglyd Olsen
bffab79ba7 Merge build settings page layout options 2024-10-17 21:56:35 +02:00
Veronica Berglyd Olsen
c82f7fd9ca Bump version to 2.6 Alpha 1 2024-10-16 17:42:50 +02:00
Veronica Berglyd Olsen
53afe528e8 Add PDF output for manuscripts (#2048) 2024-10-16 17:41:31 +02:00
Veronica Berglyd Olsen
225befe57e Add test coverage 2024-10-16 17:33:32 +02:00
Veronica Berglyd Olsen
40817e73b4 Apply page layout settings to PDF format 2024-10-16 17:26:10 +02:00
Veronica Berglyd Olsen
17c93c2172 Add PDF format 2024-10-16 17:25:46 +02:00
Veronica Berglyd Olsen
c8d7ccadb7 Refactor document builder (#2047) 2024-10-16 00:26:32 +02:00
Veronica Berglyd Olsen
7bb292404a Update Tokenizer tests 2024-10-16 00:23:18 +02:00
Veronica Berglyd Olsen
d2ac0ed53f Pass the Markdown style to the constructor 2024-10-16 00:15:56 +02:00
Veronica Berglyd Olsen
c633a56bfb Simplify JSON and raw text writing 2024-10-15 23:59:25 +02:00
Veronica Berglyd Olsen
34696ba3a8 Merge doc build functions 2024-10-15 23:29:11 +02:00
Veronica Berglyd Olsen
93c105fe86 Extend heading styling options (#2045) 2024-10-15 21:40:21 +02:00
Veronica Berglyd Olsen
29eac0b4e5 Remove type alias for Python 3.9 2024-10-15 21:36:15 +02:00
Veronica Berglyd Olsen
fc27078954 Standardise the save document call for all document classes 2024-10-15 21:25:36 +02:00
Veronica Berglyd Olsen
759a8e84f0 Fix QTextDocument header formatting 2024-10-15 20:59:10 +02:00
Veronica Berglyd Olsen
b2c7da319e Generalise ODT build settings 2024-10-15 20:34:41 +02:00
Veronica Berglyd Olsen
b56095224a Move document format classes to new folder 2024-10-15 19:29:56 +02:00
Veronica Berglyd Olsen
2f12a8cc93 Add settings to style ODT headers 2024-10-15 19:22:08 +02:00
Veronica Berglyd Olsen
140f85f214 Merge branch 'main' into heading_styling 2024-10-14 00:26:55 +02:00
Veronica Berglyd Olsen
7405c7e20f Add Python 3.13 (#2044) 2024-10-14 00:25:07 +02:00
Veronica Berglyd Olsen
f29b13679a List and check support for Python 3.13 (#2040) 2024-10-14 00:21:22 +02:00
Veronica Berglyd Olsen
9b9ed53cf9 Don't use "match" as a variable (#2043) 2024-10-14 00:18:24 +02:00
Veronica Berglyd Olsen
2447ae5cff Replace usage of "match" as a variable name 2024-10-14 00:14:08 +02:00
Veronica Berglyd Olsen
7379b8b521 Remove plain heading style setting again 2024-10-14 00:10:23 +02:00
Veronica Berglyd Olsen
22473823c9 Improve build settings 2024-10-01 14:01:36 +02:00
Veronica Berglyd Olsen
d8bb9950d3 Add plain style setting for headings 2024-10-01 14:00:57 +02:00
Veronica Berglyd Olsen
279fe2ae18 Add title setting 2024-10-01 12:54:38 +02:00
Veronica Berglyd Olsen
1e812f3f06 Update tests 2024-10-01 12:15:42 +02:00
Veronica Berglyd Olsen
0f1401eb71 Rename title format settings to partition 2024-10-01 12:14:39 +02:00
Veronica Berglyd Olsen
57e739c3ef Add word counts to ODT output (#2035) 2024-10-01 12:00:04 +02:00
Veronica Berglyd Olsen
1b457585d9 Update tests 2024-09-30 22:52:39 +02:00
Veronica Berglyd Olsen
c9eab0b01e Add word counts to the ODT file as user fields (#2033) 2024-09-30 22:52:28 +02:00
Veronica Berglyd Olsen
3da5074d14 Refactor build settings (#2031) 2024-09-28 11:58:58 +02:00
Veronica Berglyd Olsen
e97a476c7f Update tests 2024-09-28 11:55:47 +02:00
Veronica Berglyd Olsen
a716f31b17 Fix some minor issues with spell checking 2024-09-28 11:41:26 +02:00
Veronica Berglyd Olsen
b2e760a284 Merge build settings formatting into a single form 2024-09-28 11:36:01 +02:00
Veronica Berglyd Olsen
7cc48aeb7f Improve spell checking (#2030) 2024-09-28 11:31:49 +02:00
Veronica Berglyd Olsen
a6494b4226 Allow words to be ignored by the spell checker (session only) 2024-09-28 11:21:04 +02:00
Veronica Berglyd Olsen
5ed03e29f3 Change regexes to use the pattern object directly 2024-09-28 11:20:09 +02:00
Veronica Berglyd Olsen
5c07148eb2 Replace Qt RegEx with Python RegEx (#2028) 2024-09-22 18:02:17 +02:00
Veronica Berglyd Olsen
17b4a7be4d Use regex end instead of length in tokenizer format processing function 2024-09-22 17:57:13 +02:00
Veronica Berglyd Olsen
9b08ebbbd5 Use Python regex for document search tool 2024-09-22 17:47:49 +02:00
Veronica Berglyd Olsen
37c469dbc7 Move word split to regex pattern class 2024-09-22 17:37:12 +02:00
Veronica Berglyd Olsen
c08033d153 Use stdlib re for dialogue matching 2024-09-22 17:34:04 +02:00
Veronica Berglyd Olsen
9c63f621a8 Use stdlib re for Shortcode matching 2024-09-22 17:06:57 +02:00
Veronica Berglyd Olsen
c3cb12e5e2 Use stdlib re for Markdown matching 2024-09-22 16:56:21 +02:00
Veronica Berglyd Olsen
d3799bc30a Update plain regex patterns to std lib re 2024-09-22 16:37:45 +02:00
Veronica Berglyd Olsen
cb8933b493 Use Python regex for spell checking 2024-09-22 16:23:35 +02:00
Veronica Berglyd Olsen
1aa58c047f Clean up Qt enums (#2025) 2024-09-18 22:18:15 +02:00
Veronica Berglyd Olsen
52b3084bee Fix type annotation 2024-09-18 22:12:39 +02:00
Veronica Berglyd Olsen
70b05c9e07 Use strings for shortcut keys 2024-09-18 22:11:32 +02:00
Veronica Berglyd Olsen
cafa440aee Update menu role enums 2024-09-18 22:06:23 +02:00
Veronica Berglyd Olsen
681cb7226a Fix bug in test 2024-09-18 22:04:06 +02:00
Veronica Berglyd Olsen
b36e49e43f Update scrollbar enums 2024-09-18 22:02:50 +02:00
Veronica Berglyd Olsen
90a6c2d98d Update keyboard and mouse enums 2024-09-18 21:55:59 +02:00
Veronica Berglyd Olsen
21d21846ca Update cursor and messagebox enums 2024-09-18 21:51:23 +02:00
Veronica Berglyd Olsen
cc8d7d9912 Fix ActionEvent enums 2024-09-18 21:46:44 +02:00
Veronica Berglyd Olsen
10527afbda Remove borders 2024-09-18 15:00:55 +02:00
Veronica Berglyd Olsen
d16ece2a81 Use html table instead 2024-09-18 14:59:38 +02:00
Veronica Berglyd Olsen
e7f7dc8d60 Increase sponsor logo size 2024-09-18 14:55:47 +02:00
Veronica Berglyd Olsen
2f2943452f Use px for logo size 2024-09-18 14:55:06 +02:00
Veronica Berglyd Olsen
08ae2b5351 Merge actions updates from 'release' 2024-09-18 14:52:46 +02:00
Veronica Berglyd Olsen
eda1a1c4dd Add sponsor link to readme 2024-09-18 14:52:24 +02:00
Veronica Berglyd Olsen
238abdb567 Update to release certificate for windows build 2024-09-18 14:33:45 +02:00
Veronica Berglyd Olsen
c3f9ceb03a Add version numbers to all build jobs 2024-09-18 14:02:27 +02:00
Veronica Berglyd Olsen
b95e9fcda3 Update for windows runner 2024-09-18 13:39:17 +02:00
Veronica Berglyd Olsen
b3f8a2c18c Add version parameter to windows build 2024-09-18 13:11:08 +02:00
Veronica Berglyd Olsen
18bbf7d86f Switch to test signing for windows setup 2024-09-18 12:14:37 +02:00
Veronica Berglyd Olsen
8e3ad6d874 Merge release 2.5.2 2024-09-17 16:51:06 +02:00
Veronica Berglyd Olsen
7a63ac83d2 Release 2.5.2 (#2021) 2024-09-17 15:49:04 +02:00
Veronica Berglyd Olsen
8dc28ddbee Add signing request to windows build 2024-09-17 15:45:30 +02:00
Veronica Berglyd Olsen
15803cf0a3 Bump version and update changelog 2024-09-17 15:33:37 +02:00
Veronica Berglyd Olsen
5ac001781c Improve documentation (#2015) 2024-09-09 12:03:22 +02:00
Veronica Berglyd Olsen
66986b88f8 Improve the spell checking info a little 2024-09-01 21:41:26 +02:00
Veronica Berglyd Olsen
f4e12a63a5 Merge branch 'release' into improve_docs 2024-09-01 21:37:53 +02:00
Veronica Berglyd Olsen
09b0f34199 Merge branch 'release' 2024-09-01 17:47:36 +02:00
Veronica Berglyd Olsen
095c005c3f Fix bugs in the document viewer (#2014) 2024-09-01 17:45:44 +02:00
Veronica Berglyd Olsen
e414b6458d Refresh tab stop distance after loading document (#1996) 2024-09-01 17:38:04 +02:00
Veronica Berglyd Olsen
ece73784c2 Only highlight dialogue in novel text (#2011 and #2013) 2024-09-01 17:32:37 +02:00
Veronica Berglyd Olsen
8dd9644c13 Fix issue with overlapping formatting (#2012) 2024-09-01 17:22:54 +02:00
Veronica Berglyd Olsen
556e8db104 Add some text about spell checking to editor docs 2024-08-16 09:33:38 +02:00
Veronica Berglyd Olsen
ce979b6c84 Merge branch 'release' 2024-07-28 14:10:36 +02:00
Veronica Berglyd Olsen
d2f422a441 Update Ubuntu releases (#1995) 2024-07-28 14:09:39 +02:00
Veronica Berglyd Olsen
e4daff8956 Drop Ubuntu 23.10 and add 24.10 2024-07-28 14:04:14 +02:00
Veronica Berglyd Olsen
c146c311c1 Merge branch 'release' 2024-07-28 13:46:53 +02:00
Veronica Berglyd Olsen
2e1b17dcdf Release 2.5.1 (#1989) 2024-07-28 13:32:15 +02:00
Veronica Berglyd Olsen
2ef11f56e5 Update changelog 2024-07-28 13:26:34 +02:00
Veronica Berglyd Olsen
fdaeca78a7 Update translations 2024-07-28 13:21:58 +02:00
Veronica Berglyd Olsen
93e7343fe9 Update changelog with latest fix 2024-07-26 00:22:58 +02:00
Veronica Berglyd Olsen
5167608f7a Merge branch 'release' into release_2.5.1 2024-07-26 00:20:11 +02:00
Veronica Berglyd Olsen
7fe3a8520d Merge branch 'release' 2024-07-26 00:19:25 +02:00
Veronica Berglyd Olsen
d4a0881e77 Fix updating of importance labels (#1993) 2024-07-26 00:18:47 +02:00
Veronica Berglyd Olsen
7f42210f2f Add test coverage 2024-07-26 00:15:14 +02:00
Veronica Berglyd Olsen
33497bc92d Simplify how the project tree is populated 2024-07-25 20:47:14 +02:00
Veronica Berglyd Olsen
06aa2e6c07 Only refresh project tree values when status labels change 2024-07-25 20:39:32 +02:00
Veronica Berglyd Olsen
3bffaa01dc Update viewer panel when importance labels change (#1992) 2024-07-25 19:21:52 +02:00
Veronica Berglyd Olsen
66237c35ea Bump version to 2.5.1 and update changelog 2024-07-21 20:05:54 +02:00
Veronica Berglyd Olsen
f532857345 Merge branch 'release' 2024-07-21 19:54:43 +02:00
Veronica Berglyd Olsen
5936e1a68b Improve test coverage 2024-07-21 19:45:40 +02:00
Veronica Berglyd Olsen
17b3b2b770 Navigate to heading when a tag is clicked for editing (#1983) 2024-07-21 19:45:28 +02:00
Veronica Berglyd Olsen
826e805b06 Make sure free form text boxes in Preferences are safe (#1986) 2024-07-21 19:20:51 +02:00
Veronica Berglyd Olsen
5419d76606 Fix test 2024-07-21 19:15:56 +02:00
Veronica Berglyd Olsen
0d84653f7d Process pad before and after settings in Preferences (#1985) 2024-07-21 19:06:45 +02:00
Veronica Berglyd Olsen
b7da56ac42 Merge branch 'release' 2024-07-14 16:43:43 +02:00
Veronica Berglyd Olsen
7140788b48 Lock project only after successful open (#1978) 2024-07-14 16:33:48 +02:00
Veronica Berglyd Olsen
a43bd5a50e Move project lock until all steps in project open are completed (#1977) 2024-07-14 16:28:09 +02:00
Veronica Berglyd Olsen
81dd98e83b Bump main branch to 2.6 alpha 0 2024-07-10 17:30:14 +02:00
207 changed files with 26012 additions and 14215 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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,

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"

View File

@@ -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
---------------------

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

4991
i18n/nw_ru_RU.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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__)

View File

@@ -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",

View File

@@ -14,6 +14,7 @@
"Objects": "Objects",
"Entities": "Entities",
"Custom": "Custom",
"New Page": "New Page",
"0": "Zero",
"1": "One",
"2": "Two",

View File

@@ -1,7 +1,18 @@
{
"Synopsis": "Сводка",
"Short Description": "Краткое описание",
"Comment": "Комментарий",
"Notes": "Заметки",
"Tag": "Тэг",
"Point of View": "Точка зрения",
"Focus": "Фокус",
"Characters": "Персонажей",
"Plot": "Сюжет",
"Timeline": "Хронология",
"Locations": "Локации",
"Objects": "Объекты",
"Entities": "Сущности",
"Custom": "Другое",
"0": "Ноль",
"1": "Один",
"2": "Два",

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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>

View File

@@ -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

View File

@@ -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
##

View File

@@ -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),

View File

@@ -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
# ================

View File

@@ -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

View File

@@ -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()

View File

@@ -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}'"

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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",
}
}

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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()

View File

@@ -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({"&": "&amp;"})
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("&lt;")
tFormat = [(p + 3 if p > i else p, f, k) for p, f, k in tFormat]
i += 4
elif c == ">":
cText.append("&gt;")
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("<", "&lt;").replace(">", "&gt;")
# 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}>&nbsp;</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", "&#09;").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", "&#09;").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 = "&nbsp;") -> 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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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):

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View 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]

File diff suppressed because it is too large Load Diff

View 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({"&": "&amp;"})
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("&lt;")
tFmt = [(p + 3 if p > i else p, f, k) for p, f, k in tFmt]
i += 4
elif c == ">":
cText.append("&gt;")
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("<", "&lt;").replace(">", "&gt;")
# 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}>&nbsp;</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", "&#09;").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", "&#09;").rstrip(),
))
logger.info("Wrote file: %s", path)
return
def replaceTabs(self, nSpaces: int = 8, spaceChar: str = "&nbsp;") -> 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)

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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
##

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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()

View File

@@ -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",

View File

@@ -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

View File

@@ -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)))

View File

@@ -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)

View File

@@ -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

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More