diff --git a/.codecov.yml b/.codecov.yml index 8a775495..45ebc6c7 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -3,12 +3,14 @@ codecov: coverage: precision: 2 - round: down + round: up range: "70...100" status: - project: yes - patch: yes + project: + default: + threshold: 1% + patch: no changes: no parsers: diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f9a24f..f1a356d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,65 @@ # novelWriter ChangeLog -## Version 0.9rc1 [2020-xx-xx] +## Version 0.10.0 RC1 [2020-xx-xx] + +Note: If the project file is opened by this version of novelWriter, the project file can no longer be read by an earlier version due to the change of how autoReplace settings are stored. + +**User Interface** + +* The Session Log dialog has been redesigned and now has a few more filter options. The filters now also update the filtered time count properly. The dialog now shows a histogram of words added in a given session, or optionally, on a given date. The filtered log data can also be saved as a JSON or CSV file, the latter suitable for importing to a spread sheet. The new dialog tool required a new session log file format, so the new session log has been given a new file name. The old log file will be left untouched in the project's `meta` folder. For projects created prior to this change, the log will record a word count offset that will be subtracted from the first entry such that the first word diff will always be 0 instead of the total word count of the entire project. Such a large word diff would otherwise saturate the histogram. PR #339. + +**Other Changes** + +* The way the auto-replace settings are stored in the project XML file has been changed in order to be more consistent with other features, and to avoid a potential pitfall in defining the tag name from a user-entered string. The project class retains its ability to read the old format of the file, and will save in the new format. PR #344. + + +## Version 0.9.2 [2020-06-26] + +**Bugfixes** + +* The project tree word counts were getting mixed up when a file was moved to the trash folder, or permanently deleted. This has now been fixed, and moving a file should give a zero net change of project word count. Permanently deleting it will result in a negative net change. Issue #333, PR #335. + +**User Interface** + +* There is a feature in the project tree class that ensures that the tree item being acted on is visible in the tree. It is called when you for instance click the header of an open document. It was also activated when opening a document from the tree view with either double-click by mouse, or by using the Enter key. This meant that the tree view would often move, which made it hard to mouse click on items after eachother since you ended up chasing a moving target. This feature is now disabled for document open. In addition, the scroll into view feature has been added to the search/replace call to move into the next document when reaching the end of the current document. This was requested in Issue #332. PR #334. +* The Build Novel Project tool will now display the build time of the document in the preview window in order for the user to know if it is potentially out of date. The timestamp is given, as well as a fuzzy time string, indicating the age of the content. Issue #331, PRs #336 and #337. + +**Documentation** + +* The documentation has been updated to clarify the correct formatting for italic, bold and strikethrough formatting tags. Issue #220, PR #338. + + +## Version 0.9.1 [2020-06-21] + +**Bugfixes** + +* Fixed a serious bug in the replace part of the search/replace function that caused novelWriter to crash when the replace text function was called. Issue #327, PR #328. + + +## Version 0.9 [2020-06-21] **Core Functionality** -* Underline text formatting has been removed. It is not standard HTML5, not Markdown, and was previously implemented using the double underscore notation that in standard Markdown is renderred as bold text. Instead, novelWriter now renders a single `*` or `-` wrapping a piece of text *within* a paragraphs as italicised text, and a double `**` or `__` as bold text. The keyboard shortcuts and automatic features **only** support the `*` notation. A triple set of `***` are treated as both bold and italicised. PR #310. +* Underline text formatting has been removed. It is not standard HTML5, nor Markdown, and was previously implemented using the double underscore notation that in standard Markdown is renderred as bold text. Instead, novelWriter now renders a single `*` or `_` wrapping a piece of text *within* a paragraphs as italicised text, and a double `**` or `__` as bold text. The keyboard shortcuts and automatic features *only* support the `*` notation. A triple set of `***` are treated as both bold and italicised. PR #310. * Strikethrough formatting has been added back into novelWriter using the standard Markdown `~~` wrapping. PR #310. +* Added support for thin spaces and non-breaking thin spaces. PR #319. +* The `Ctrl+Z` key sequence (undo) would not go through the wrapper function for document action for the document editor, but act directly on the document. This caused some of the logic preventing conflict between auto-replace and undo to be bypassed. This has now been resolved by blocking the keypress itself and let the menu action handle the key sequence. Issue #320, PR #321. +* The dialog window size and column width setting for the auto-replace feature in Project Settings are now preserved between closing and opening the dialog. Issue #322, PR #324. **User Interface** * The Open Project dialog will now ask before removing an entry from the recent projects list. PR #309. -* The text emphasis functions, either selected from the menu or via keyboard shortcuts, will now try to respond to the command in a more meaningful way. That is, the text editor will try to toggle the bold or italics features independently of eachother on the selected text. A feature to apply both at the same time has also been added. PR #310. +* The text emphasis functions, either selected from the menu or via keyboard shortcuts, will now try to respond to the command in a more meaningful way. That is, the text editor will try to toggle the bold or italics features independently of eachother on the selected text. A menu entry to apply both at the same time has also been added. PR #310. +* The document editor search tool has been completely rewritten. It now appears as a search box at the top of the document, and has a number of toggle switches added to it. You can modify the search tool to be case sensitive, select only whole words, use regular expression search strings, loop when reaching the end, and continue the search in the next file in the project tree. For the replace feature, you can also select to have the feature try to preserve the case of the replaced word. Issues #84 and #305, PR #314. +* A dialog has been added for selecting quotation mark style. These are now used in the Preferences dialog instead of a plain text edit box. PR #317. +* Added an insert menu for inserting special symbols like dashes, ellipsis, thin and non-breaking spaces, and hard line breaks. PR #319. +* A menu option to replace straight single and double quotes in a selected piece of text has been added. This uses the same logic as the auto-replace feature. Issue #312, PR #321. +* When pressing `Ctrl+R` while the document editor has focus, the edited document will be viewed or refreshed in the document viewer. Previously, the selected document in the project tree had priority. The document is also now saved before loaded in the viewer, ensuring that it shows the very latest changes. Issue #143, PR #323. +* The selection in the project tree should not scroll into view when just opening the document. This can be quite annoying if loading several documents in sequence by double-clicking as the target may move just when you're about to click. PR #325. **Other Changes** -* Added the file's class and layout to the meta data string added as the first line of saved document files. This meta data is only used to restore the file meta information into the project if it was lost from the project file. It is also useful information when reading the file in external tools. PR #308. +* Added the file's class and layout to the meta data string of saved document files. This meta data is only used to restore the file meta information into the project if it was lost from the project file. It is also useful information when reading the file in external tools. PR #308. ## Version 0.8 [2020-06-14] @@ -32,7 +77,7 @@ * Icons have been added to the Title and Document columns in the Outline. The titles get a new icon indicating the header level, while the documents get the already existing document icon. PR #302. * Added a context menu to the project tree for easier access to some of the most use actions on the tree. PR #282. * Improved the support for High DPI screens. Margins and box sizes that were hardcoded should now scale. User settings should also scale back and forth when switching between scale factors. Issue #280, PR #285. -* The total edit time of a project is no displayed on the Details tab of the Project Settings dialog. PR #290. +* The total edit time of a project is now displayed on the Details tab of the Project Settings dialog. PR #290. * The title bar in the document editor now has a full screen button and a close button, and in the document viewer a reload button and a close button. The full screen button toggles the distraction free mode, and the reload button regenerates the document being viewed to update any changes that may have been made to it. PRs #293, #300, #303 and #306. * The References panel below the document viewer has been redesigned. It now sits in a resizeable panel below the document, and its controls sit in a footer bar in the document itself. The functionality of the feature is otherwise unchanged, but the buttons have received new icons. PRs #304 and #306. * The option to render comments and synopsis in the document view panel has been added to Preferences. The toggle option for comments that was previously in the menu has been removed. PR #311. diff --git a/docs/source/conf.py b/docs/source/conf.py index ddaebd44..4e9b6401 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -24,9 +24,9 @@ copyright = "2018-2020, Veronica Berglyd Olsen" author = "Veronica Berglyd Olsen" # The short X.Y version -version = "0.9" +version = "0.10.0" # The full version, including alpha/beta/rc tags -release = "0.9.0rc1" +release = "0.10.0rc1" # -- General configuration --------------------------------------------------- diff --git a/docs/source/interface.rst b/docs/source/interface.rst index 3174a888..5502ae22 100644 --- a/docs/source/interface.rst +++ b/docs/source/interface.rst @@ -58,10 +58,16 @@ The editor also has a minimal set of keywords used for setting tags and referenc "``% Synopsis: text...``", "A synopsis comment. Shows up in the Synopsis column of the Outline View, but is otherwise treated as a comment." "``@keyword: value``", "A keyword argument followed by a value, or a comma separated list of values." +.. note:: + The bold/italic/strikethrough formatting tags do not allow spaces between the words and the tag itself. + That is, ``**text**`` is valid, ``**text **`` is not. + The editor and viewer also supports markdown standard hard line breaks, and preserves non-breaking spaces. A hard line break is achieved by leaving two or more spaces at the end of the line. -Alternatively, the user can press :kbd:`Shift-Enter` to insert this. -A non-breaking space is inserted with :kbd:`Shift-Space`. +Alternatively, the user can press :kbd:`Ctrl-K, Return` to insert this. +A non-breaking space is inserted with :kbd:`Ctrl-K, Space`. + +Thin spaces are also supported, and can be inserted with :kbd:`Ctrl-K, Shift-Space`, and the non-breaking version of it with :kbd:`Ctrl-K, Ctrl-Space`. Both hard line breaks and non-breaking spaces are highlighted by the syntax highlighter as an alternate coloured background, depending on the selected theme. @@ -133,7 +139,6 @@ These are as following: ":kbd:`Ctrl-Q`", "Exit novelWriter." ":kbd:`Ctrl-R`", "If in tree view, open a document for viewing. If editor pane has focus, open current document for viewing." ":kbd:`Ctrl-S`", "Save the current document in the editor." - ":kbd:`Ctrl-U`", "Format selected text, or word under cursor, as underline." ":kbd:`Ctrl-V`", "Paste text from clipboard to cursor position." ":kbd:`Ctrl-W`", "Close the current document in the editor." ":kbd:`Ctrl-X`", "Cut selected text to clipboard." @@ -174,3 +179,17 @@ These are as following: .. note:: On macOS, replace :kbd:`Ctrl` with :kbd:`Cmd`. + +A set of insert features are also available through shortcuts, but they require a double combination of shortcuts. + +.. csv-table:: Keyboard Shortcuts + :header: "Shortcut", "Description" + :widths: 30, 50 + + ":kbd:`Ctrl-K, -`", "Insert a short dash (en dash)." + ":kbd:`Ctrl-K, _`", "Insert a long dash (em dash)." + ":kbd:`Ctrl-K, .`", "Insert ellipsis." + ":kbd:`Ctrl-K, Return`", "Insert a hard line break." + ":kbd:`Ctrl-K, Space`", "Insert a non-breaking space." + ":kbd:`Ctrl-K, Shift-Space`", "Insert a thin space." + ":kbd:`Ctrl-K, Ctrl-Space`", "Insert a thin non-breaking space." diff --git a/lib/textanalyse.py b/lib/textanalyse.py deleted file mode 100644 index 6cfe85b2..00000000 --- a/lib/textanalyse.py +++ /dev/null @@ -1,186 +0,0 @@ -# -*- coding: utf-8 -*- -""" - novelWriter – Text Analysis Class -=================================== - Class for analysing bits of text. - - File History: - Created: 2018-09-22 [0.0.1] - - This file is a part of novelWriter - Copyright 2020, 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 . -""" - -import logging -import nw - -from time import time - -logger = logging.getLogger(__name__) - -class TextAnalysis(): - - def __init__(self, theText, langCode): - self.theText = theText - self.langCode = langCode - return - - def getStats(self): - tStart = time() - wordCount = self._countWords() - tEnd = time()-tStart - logger.verbose("Words: %7d in %8.3f ms" % (wordCount,tEnd*1e3)) - tStart = time() - sentCount = self._countSentences() - tEnd = time()-tStart - logger.verbose("Sentences: %7d in %8.3f ms" % (sentCount,tEnd*1e3)) - tStart = time() - paraCount = self._countParagraphs() - tEnd = time()-tStart - logger.verbose("Paragraphs: %7d in %8.3f ms" % (paraCount,tEnd*1e3)) - return wordCount, sentCount, paraCount - - def getReadabilityScore(self): - """Calculate Flesch--Kincaid Readability Score. - """ - tStart = time() - wordCount = self._countWords() - sentCount = self._countSentences() - if self.langCode[:3] == "en_": - ratSyllWord = self._countSyllablesEN() - else: - ratSyllWord = -1.0 - rScore = 206.835 - 1.015*(wordCount/sentCount) - 84.6*(ratSyllWord) - gLevel = -15.59 + 0.390*(wordCount/sentCount) + 11.8*(ratSyllWord) - tEnd = time()-tStart - logger.verbose("Readability: %7.3f in %8.3f ms" % (rScore,tEnd*1e3)) - logger.verbose("Grade Level: %7.3f in %8.3f ms" % (gLevel,tEnd*1e3)) - logger.verbose("Assessment: %s" % self.getReadabilityText(rScore)) - - return rScore, gLevel - - def getReadabilityText(self, rScore): - if rScore >= 90.0: - return "Very Easy" - elif rScore >= 80.0: - return "Easy" - elif rScore >= 70.0: - return "Fairly Easy" - elif rScore >= 60.0: - return "Average" - elif rScore >= 50.0: - return "Fairly Difficult" - elif rScore >= 30.0: - return "Difficult" - else: - return "Very Difficult" - - # - # Internal Functions - # - - def _countWords(self): - """Counts the number of words in a text by simply splitting on - all white spaces. - """ - return len(self.theText.strip().split()) - - def _countSentences(self): - """Counts the number of non-repeated sentence endings seen in - the text. Note: This will count filenames and urls as multiple - sentences. - """ - nSent = 0 - sawEnd = False - for ch in self.theText.strip(): - if ch in ".!?": - if not sawEnd: - sawEnd = True - nSent += 1 - else: - sawEnd = False - return nSent - - def _countParagraphs(self, pThreshold=2): - """Counts the number of paragraphs by counting repeated line - breaks. - """ - nPara = 1 - sawEnd = 0 - for ch in self.theText.strip(): - if ch == "\r": # Ignore Windows line end chars - continue - if ch == "\n": # Count endlines - sawEnd += 1 - else: # If non-endline is encountered, check condition for paragraph - if sawEnd >= pThreshold: - nPara += 1 - sawEnd = 0 - return nPara - - def _countSyllablesEN(self): - """Attempt to count the syllables in a piece of English language - text. This function tends to slightly over-estimate the number - of syllables as it doesn't handle the complexity of silent - vowels in endings very well. It will count them all. - """ - - cleanText = "" - for ch in self.theText: - if ch in "abcdefghijklmnopqrstuvwxyz'’": - cleanText += ch - else: - cleanText += " " - - asVow = "aeiouy'’" - dExept = ("ei","ie","ua","ia","eo") - theWords = cleanText.lower().split() - allSylls = 0 - for inWord in theWords: - nChar = len(inWord) - nSyll = 0 - wasVow = False - wasY = False - if nChar == 0: - continue - if inWord[0] in asVow: - nSyll += 1 - wasVow = True - wasY = inWord[0] == "y" - for c in range(1,nChar): - isVow = False - if inWord[c] in asVow: - nSyll += 1 - isVow = True - if isVow and wasVow: - nSyll -= 1 - if isVow and wasY: - nSyll -= 1 - if inWord[c:c+2] in dExept: - nSyll += 1 - wasVow = isVow - wasY = inWord[c] == "y" - if inWord.endswith(("e")): - nSyll -= 1 - if inWord.endswith(("le","ea","io")): - nSyll += 1 - if nSyll < 1: - nSyll = 1 - allSylls += nSyll - - return allSylls/len(theWords) - -# END Class TextAnalysis diff --git a/nw/__init__.py b/nw/__init__.py index 6ab06265..c5e497f1 100644 --- a/nw/__init__.py +++ b/nw/__init__.py @@ -40,13 +40,14 @@ __package__ = "novelWriter" __author__ = "Veronica Berglyd Olsen" __copyright__ = "Copyright 2018–2020, Veronica Berglyd Olsen" __license__ = "GPLv3" -__version__ = "0.9.0rc1" -__hexversion__ = "0x000900c1" -__date__ = "2020-06-14" +__version__ = "0.10.0rc1" +__hexversion__ = "0x001000c1" +__date__ = "2020-06-26" __maintainer__ = "Veronica Berglyd Olsen" __email__ = "code@vkbo.net" __status__ = "Pre-Release" __url__ = "https://github.com/vkbo/novelWriter" +__issuesurl__ = "https://github.com/vkbo/novelWriter/issues" __domain__ = "novelwriter.io" __docurl__ = "https://novelwriter.readthedocs.io" __credits__ = [ diff --git a/nw/common.py b/nw/common.py index 0019c38a..872365df 100644 --- a/nw/common.py +++ b/nw/common.py @@ -193,3 +193,37 @@ def transferCase(theSource, theTarget): theResult = theTarget.lower() return theResult + +def fuzzyTime(secDiff): + """Converts a time difference in seconds into a fuzzy time string. + """ + if secDiff < 0: + return "in the future" + elif secDiff < 30: + return "just now" + elif secDiff < 90: + return "a minute ago" + elif secDiff < 3300: # 55 minutes + return "%d minutes ago" % int(round(secDiff/60)) + elif secDiff < 5400: # 90 minutes + return "an hour ago" + elif secDiff < 84600: # 23.5 hours + return "%d hours ago" % int(round(secDiff/3600)) + elif secDiff < 129600: # 1.5 days + return "a day ago" + elif secDiff < 561600: # 6.5 days + return "%d days ago" % int(round(secDiff/86400)) + elif secDiff < 907200: # 10.5 days + return "a week ago" + elif secDiff < 2419200: # 28 days + return "%d weeks ago" % int(round(secDiff/604800)) + elif secDiff < 3888000: # 45 days + return "a month ago" + elif secDiff < 31104000: # 360 days + return "%d months ago" % int(round(secDiff/2592000)) + elif secDiff < 47304000: # 1.5 years + return "a year ago" + else: + return "%d years ago" % int(round(secDiff/31557600)) + + return "beyond time and space" diff --git a/nw/constants/__init__.py b/nw/constants/__init__.py index 57761e42..0e90354a 100644 --- a/nw/constants/__init__.py +++ b/nw/constants/__init__.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- from nw.constants.iso import isoLanguage, isoCountry from nw.constants.constants import ( - nwConst, nwRegEx, nwFiles, nwKeyWords, nwLabels, nwQuotes, nwUnicode + nwConst, nwRegEx, nwFiles, nwKeyWords, nwLabels, nwQuotes, nwUnicode, + nwInsertSymbols ) from nw.constants.enum import ( - nwAlert, nwDocAction, nwItemClass, nwItemLayout, nwItemType, nwOutline + nwAlert, nwDocAction, nwItemClass, nwItemLayout, nwItemType, nwOutline, + nwDocInsert ) __all__ = [ @@ -17,10 +19,12 @@ __all__ = [ "nwLabels", "nwQuotes", "nwUnicode", + "nwInsertSymbols", "nwAlert", "nwDocAction", "nwItemClass", "nwItemLayout", "nwItemType", "nwOutline", + "nwDocInsert", ] diff --git a/nw/constants/constants.py b/nw/constants/constants.py index 9bd47110..18443c1d 100644 --- a/nw/constants/constants.py +++ b/nw/constants/constants.py @@ -25,12 +25,13 @@ along with this program. If not, see . """ -from nw.constants.enum import nwItemClass, nwItemLayout, nwOutline +from nw.constants.enum import nwItemClass, nwItemLayout, nwOutline, nwDocInsert class nwConst(): tStampFmt = "%Y-%m-%d %H:%M:%S" # Default format fStampFmt = "%Y-%m-%d %H.%M.%S" # FileName safe format + dStampFmt = "%Y-%m-%d" # Date only format # END Class nwConst @@ -50,7 +51,7 @@ class nwFiles(): PROJ_LOCK = "nwProject.lock" TOC_TXT = "ToC.txt" TOC_JSON = "ToC.json" - SESS_INFO = "sessionInfo.log" + SESS_STATS = "sessionStats.log" INDEX_FILE = "tagsIndex.json" OPTS_FILE = "guiOptions.json" RECENT_FILE = "recentProjects.json" @@ -169,32 +170,35 @@ class nwQuotes(): Source: https://en.wikipedia.org/wiki/Quotation_mark """ - SYMBOLS = [ - "\u0022", # Quotation mark - "\u0027", # Apostrophe - "\u00ab", # Left-pointing double angle quotation mark - "\u00bb", # Right-pointing double angle quotation mark - "\u2018", # Left single quotation mark - "\u2019", # Right single quotation mark - "\u201a", # Single low-9 quotation mark - "\u201b", # Single high-reversed-9 quotation mark - "\u201c", # Left double quotation mark - "\u201d", # Right double quotation mark - "\u201e", # Double low-9 quotation mark - "\u201f", # Double high-reversed-9 quotation mark - "\u2039", # Single left-pointing angle quotation mark - "\u203a", # Single right-pointing angle quotation mark - "\u2e42", # Double low-reversed-9 quotation mark - "\u300c", # Left corner bracket - "\u300d", # Right corner bracket - "\u300e", # Left white corner bracket - "\u300f", # Right white corner bracket - ] + SYMBOLS = { + "\u0027" : "Straight single quotation mark", + "\u0022" : "Straight double quotation mark", + + "\u2018" : "Left single quotation mark", + "\u2019" : "Right single quotation mark", + "\u201a" : "Single low-9 quotation mark", + "\u201b" : "Single high-reversed-9 quotation mark", + "\u201c" : "Left double quotation mark", + "\u201d" : "Right double quotation mark", + "\u201e" : "Double low-9 quotation mark", + "\u201f" : "Double high-reversed-9 quotation mark", + "\u2e42" : "Double low-reversed-9 quotation mark", + + "\u2039" : "Single left-pointing angle quotation mark", + "\u203a" : "Single right-pointing angle quotation mark", + "\u00ab" : "Left-pointing double angle quotation mark", + "\u00bb" : "Right-pointing double angle quotation mark", + + "\u300c" : "Left corner bracket", + "\u300d" : "Right corner bracket", + "\u300e" : "Left white corner bracket", + "\u300f" : "Right white corner bracket", + } # END Class nwQuotes class nwUnicode: - """Suppoted unicode character constants and translation maps for HTML. + """Supported unicode character constants and translation maps for HTML. """ # Unicode Constants @@ -226,8 +230,14 @@ class nwUnicode: U_EMDASH = "\u2014" # Long dash U_HELLIP = "\u2026" # Ellipsis - ## Other + ## Spaces and Lines U_NBSP = "\u00a0" # Non-breaking space + U_THNSP = "\u2009" # Thin space + U_THNBSP = "\u202f" # Thin non-breaking space + U_LSEP = "\u2028" # Line separator + U_PSEP = "\u2029" # Paragraph separator + + ## Symbols U_CHECK = "\u2714" # Heavy check mark U_MULT = "\u2715" # Multiplication x @@ -270,8 +280,12 @@ class nwUnicode: H_EMDASH = "—" H_HELLIP = "…" - ## Other + ## Spaces H_NBSP = " " + H_THNSP = " " + H_THNBSP = " " + + ## Symbols H_CHECK = "✔" H_MULT = "✕" @@ -286,3 +300,18 @@ class nwUnicode: H_LTRIS = "◂" # END Class nwUnicode + +class nwInsertSymbols(): + + SYMBOLS = { + nwDocInsert.NO_INSERT : "", + nwDocInsert.HARD_BREAK : " \n", + nwDocInsert.NB_SPACE : nwUnicode.U_NBSP, + nwDocInsert.THIN_SPACE : nwUnicode.U_THNSP, + nwDocInsert.THIN_NB_SPACE : nwUnicode.U_THNBSP, + nwDocInsert.SHORT_DASH : nwUnicode.U_ENDASH, + nwDocInsert.LONG_DASH : nwUnicode.U_EMDASH, + nwDocInsert.ELLIPSIS : nwUnicode.U_HELLIP, + } + +# END Enum nwDocInsert diff --git a/nw/constants/enum.py b/nw/constants/enum.py index 1f90a928..7177223c 100644 --- a/nw/constants/enum.py +++ b/nw/constants/enum.py @@ -93,9 +93,24 @@ class nwDocAction(Enum): BLOCK_H4 = 22 BLOCK_COM = 23 BLOCK_TXT = 24 + REPL_SNG = 25 + REPL_DBL = 26 # END Enum nwDocAction +class nwDocInsert(Enum): + + NO_INSERT = 0 + HARD_BREAK = 1 + NB_SPACE = 2 + THIN_SPACE = 3 + THIN_NB_SPACE = 4 + SHORT_DASH = 5 + LONG_DASH = 6 + ELLIPSIS = 7 + +# END Enum nwDocInsert + class nwAlert(Enum): INFO = 0 diff --git a/nw/core/options.py b/nw/core/options.py index 25bf1fb4..a9a06e0d 100644 --- a/nw/core/options.py +++ b/nw/core/options.py @@ -45,14 +45,19 @@ class OptionState(): self.theState = {} self.validMap = { - "GuiSession": { + "GuiSessionLog": { + "winWidth", + "winHeight", "widthCol0", "widthCol1", "widthCol2", "sortCol", "sortOrder", + "incNovel", + "incNotes", "hideZeros", "hideNegative", + "groupByDay", }, "GuiDocSplit": { "spLevel", @@ -78,6 +83,11 @@ class OptionState(): "columnWidth", "columnHidden", }, + "GuiProjectSettings": { + "winWidth", + "winHeight", + "replaceColW", + } } return diff --git a/nw/core/project.py b/nw/core/project.py index 3db93bf2..5e5400c9 100644 --- a/nw/core/project.py +++ b/nw/core/project.py @@ -93,6 +93,8 @@ class NWProject(): self.lastViewed = None # The handle of the last file to be viewed self.lastWCount = 0 # The project word count from last session self.currWCount = 0 # The project word count in current session + self.novelWCount = 0 # Total number of words in novel files + self.notesWCount = 0 # Total number of words in note files self.doBackup = True # Run project backup on exit # Set Defaults @@ -231,6 +233,8 @@ class NWProject(): self.lastViewed = None self.lastWCount = 0 self.currWCount = 0 + self.novelWCount = 0 + self.notesWCount = 0 return @@ -352,6 +356,15 @@ class NWProject(): # Check Project Storage Version # ============================= + # Changes: + # 1.0 : Original file format. + # 1.1 : Changes the way documents are structure in the project + # folder from data_X, where X is the first hex value of + # the handle, to a single content folder. + # 1.2 : Changes the way autoReplace entries are stored. The 1.1 + # parser will lose the autoReplace settings if allowed to + # read the file. Introduced in version 0.10. + if fileVersion == "1.0": msgBox = QMessageBox() msgRes = msgBox.question(self.theParent, "Old Project Version", ( @@ -365,12 +378,14 @@ class NWProject(): if msgRes != QMessageBox.Yes: return False - elif fileVersion != "1.1": + elif fileVersion != "1.1" and fileVersion != "1.2": self.makeAlert(( - "Unknown or unsupported %s project format. " - "The project cannot be opened by this version of %s." - ) % ( - nw.__package__, nw.__package__ + "Unknown or unsupported {nw:s} project file format. " + "The project cannot be opened by this version of {nw:s}. " + "The file was saved with {nw:s} version {vers:s}." + ).format( + nw = nw.__package__, + vers = appVersion, ), nwAlert.ERROR) return False @@ -435,13 +450,25 @@ class NWProject(): self.lastViewed = checkString(xItem.text, None, True) elif xItem.tag == "lastWordCount": self.lastWCount = checkInt(xItem.text, 0, False) + elif xItem.tag == "novelWordCount": + self.novelWCount = checkInt(xItem.text, 0, False) + elif xItem.tag == "notesWordCount": + self.notesWCount = checkInt(xItem.text, 0, False) elif xItem.tag == "status": self.statusItems.unpackEntries(xItem) elif xItem.tag == "importance": self.importItems.unpackEntries(xItem) elif xItem.tag == "autoReplace": for xEntry in xItem: - self.autoReplace[xEntry.tag] = checkString(xEntry.text, None, False) + if xEntry.tag == "entry": + if "key" in xEntry.attrib: + self.autoReplace[xEntry.attrib["key"]] = checkString( + xEntry.text, None, False + ) + else: # Old format + self.autoReplace[xEntry.tag] = checkString( + xEntry.text, None, False + ) elif xItem.tag == "titleFormat": titleFormat = self.titleFormat.copy() for xEntry in xItem: @@ -493,14 +520,18 @@ class NWProject(): # Root element and project details logger.debug("Writing project meta") - nwXML = etree.Element("novelWriterXML",attrib={ + nwXML = etree.Element("novelWriterXML", attrib={ "appVersion" : str(nw.__version__), "hexVersion" : str(nw.__hexversion__), - "fileVersion" : "1.1", + "fileVersion" : "1.2", "timeStamp" : formatTimeStamp(saveTime), }) editTime = int(self.editTime + saveTime - self.projOpened) + wcNovel, wcNotes = self.projTree.sumWords() + self.novelWCount = wcNovel + self.notesWCount = wcNotes + self.setProjectWordCount(wcNovel + wcNotes) # Save Project Meta xProject = etree.SubElement(nwXML, "project") @@ -519,11 +550,9 @@ class NWProject(): self._packProjectValue(xSettings, "lastEdited", self.lastEdited) self._packProjectValue(xSettings, "lastViewed", self.lastViewed) self._packProjectValue(xSettings, "lastWordCount", self.currWCount) - - xAutoRep = etree.SubElement(xSettings, "autoReplace") - for aKey, aValue in self.autoReplace.items(): - if len(aKey) > 0: - self._packProjectValue(xAutoRep, aKey, aValue) + self._packProjectValue(xSettings, "novelWordCount", wcNovel) + self._packProjectValue(xSettings, "notesWordCount", wcNotes) + self._packProjectKeyValue(xSettings, "autoReplace", self.autoReplace) xTitleFmt = etree.SubElement(xSettings, "titleFormat") for aKey, aValue in self.titleFormat.items(): @@ -1027,10 +1056,20 @@ class NWProject(): if not isinstance(aValue, str): aValue = str(aValue) if aValue == "" and not allowNone: continue - xItem = etree.SubElement(xParent,theName) + xItem = etree.SubElement(xParent, theName) xItem.text = aValue return + def _packProjectKeyValue(self, xParent, theName, theDict): + """Pack the entries in the auto-replace dictionary. + """ + xAutoRep = etree.SubElement(xParent, theName) + for aKey, aValue in theDict.items(): + if len(aKey) > 0: + xEntry = etree.SubElement(xAutoRep, "entry", attrib={"key": aKey}) + xEntry.text = aValue + return + def _scanProjectFolder(self): """Scan the project folder and check that the files in it are also in the project XML file. If they aren't, import them as @@ -1101,18 +1140,24 @@ class NWProject(): if not self.ensureFolderStructure(): return False - sessionFile = path.join(self.projMeta, nwFiles.SESS_INFO) + sessionFile = path.join(self.projMeta, nwFiles.SESS_STATS) + isFile = path.isfile(sessionFile) with open(sessionFile, mode="a+", encoding="utf8") as outFile: - print(( - "Start: {opened:s} " - "End: {closed:s} " - "Words: {words:8d}" - ).format( - opened = formatTimeStamp(self.projOpened), - closed = formatTimeStamp(time()), - words = self.getSessionWordCount(), - ), file=outFile) + if not isFile: + # It's a new file, so add a header + if self.lastWCount > 0: + outFile.write("# Offset %d\n" % self.lastWCount) + outFile.write("# %-17s %-19s %8s %8s\n" % ( + "Start Time", "End Time", "Novel", "Notes" + )) + + outFile.write("%-19s %-19s %8d %8d\n" % ( + formatTimeStamp(self.projOpened), + formatTimeStamp(time()), + self.novelWCount, + self.notesWCount, + )) return True diff --git a/nw/core/tohtml.py b/nw/core/tohtml.py index 6123ce21..1fd6f00d 100644 --- a/nw/core/tohtml.py +++ b/nw/core/tohtml.py @@ -54,6 +54,8 @@ class ToHtml(Tokenizer): nwUnicode.U_EMDASH : nwUnicode.H_EMDASH, nwUnicode.U_HELLIP : nwUnicode.H_HELLIP, nwUnicode.U_NBSP : nwUnicode.H_NBSP, + nwUnicode.U_THNSP : nwUnicode.H_THNSP, + nwUnicode.U_THNBSP : nwUnicode.H_THNBSP, } self.revDict = {} self.reReplace = [] diff --git a/nw/core/tree.py b/nw/core/tree.py index 7f1482fe..beb5d2e3 100644 --- a/nw/core/tree.py +++ b/nw/core/tree.py @@ -36,7 +36,7 @@ from time import time from nw.core.item import NWItem from nw.common import checkString -from nw.constants import nwFiles, nwItemType, nwItemClass +from nw.constants import nwFiles, nwItemType, nwItemClass, nwItemLayout logger = logging.getLogger(__name__) @@ -182,6 +182,23 @@ class NWTree(): return + def sumWords(self): + """Loops over all entries and adds up the word counts. + """ + noteWords = 0 + novelWords = 0 + for tHandle in self._treeOrder: + 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 + return novelWords, noteWords + ## # Tree Structure Methods ## diff --git a/nw/gui/__init__.py b/nw/gui/__init__.py index 4e96b4ee..3a57b557 100644 --- a/nw/gui/__init__.py +++ b/nw/gui/__init__.py @@ -15,7 +15,7 @@ from nw.gui.preferences import GuiPreferences from nw.gui.projload import GuiProjectLoad from nw.gui.projsettings import GuiProjectSettings from nw.gui.projtree import GuiProjectTree -from nw.gui.sessionlog import GuiSessionLogView +from nw.gui.sessionlog import GuiSessionLog from nw.gui.statusbar import GuiMainStatus from nw.gui.theme import GuiIcons, GuiTheme @@ -36,7 +36,7 @@ __all__ = [ "GuiProjectLoad", "GuiProjectSettings", "GuiProjectTree", - "GuiSessionLogView", + "GuiSessionLog", "GuiMainStatus", "GuiIcons", "GuiTheme", diff --git a/nw/gui/build.py b/nw/gui/build.py index 51a9dd68..dab7360e 100644 --- a/nw/gui/build.py +++ b/nw/gui/build.py @@ -31,8 +31,9 @@ import nw from os import path from time import time +from datetime import datetime -from PyQt5.QtCore import Qt, QByteArray +from PyQt5.QtCore import Qt, QByteArray, QTimer from PyQt5.QtPrintSupport import QPrinter, QPrintPreviewDialog from PyQt5.QtGui import ( QPalette, QColor, QTextDocumentWriter, QFont @@ -43,6 +44,7 @@ from PyQt5.QtWidgets import ( QFileDialog, QFontDialog, QSpinBox ) +from nw.common import fuzzyTime from nw.gui.custom import QSwitch from nw.core import ToHtml from nw.constants import ( @@ -76,6 +78,7 @@ class GuiBuildNovel(QDialog): self.htmlText = [] # List of html document self.htmlStyle = [] # List of html styles self.nwdText = [] # List of markdown documents + self.buildTime = 0 # The timestamp of the last build self.setWindowTitle("Build Novel Project") self.setMinimumWidth(self.mainConf.pxInt(900)) @@ -411,11 +414,12 @@ class GuiBuildNovel(QDialog): self.docView.clearStyleSheet() else: self.docView.setStyleSheet(self.htmlStyle) - self.docView.setContent(self.htmlText) + self.docView.setContent(self.htmlText, self.buildTime) else: self.htmlText = [] self.htmlStyle = [] self.nwdText = [] + self.buildTime = 0 return @@ -509,6 +513,7 @@ class GuiBuildNovel(QDialog): tEnd = time() logger.debug("Built project in %.3f ms" % (1000*(tEnd-tStart))) self.htmlStyle = makeHtml.getStyleSheet() + self.buildTime = tEnd # Load the preview document with the html data self.docView.setTextFont(textFont, textSize) @@ -517,7 +522,7 @@ class GuiBuildNovel(QDialog): self.docView.clearStyleSheet() else: self.docView.setStyleSheet(self.htmlStyle) - self.docView.setContent(self.htmlText) + self.docView.setContent(self.htmlText, self.buildTime) self._saveCache() @@ -563,9 +568,6 @@ class GuiBuildNovel(QDialog): def _saveDocument(self, theFormat): """Save the document to various formats. """ - - # FMT_PDF - byteFmt = QByteArray() fileExt = "" textFmt = "" @@ -694,6 +696,7 @@ class GuiBuildNovel(QDialog): "workingTitle" : self.theProject.projName, "novelTitle" : self.theProject.bookTitle, "authors" : self.theProject.bookAuthors, + "buildTime" : self.buildTime, } } @@ -808,6 +811,8 @@ class GuiBuildNovel(QDialog): if "nwdText" in theData.keys(): self.nwdText = theData["nwdText"] dataCount += 1 + if "buildTime" in theData.keys(): + self.buildTime = theData["buildTime"] return dataCount == 3 @@ -828,6 +833,7 @@ class GuiBuildNovel(QDialog): "htmlText" : self.htmlText, "htmlStyle" : self.htmlStyle, "nwdText" : self.nwdText, + "buildTime" : self.buildTime, }, indent=nIndent)) except Exception as e: logger.error("Failed to save build cache") @@ -871,8 +877,8 @@ class GuiBuildNovel(QDialog): "section" : self.fmtSection.text().strip(), }) - winWidth = self.mainConf.pxInt(self.width()) - winHeight = self.mainConf.pxInt(self.height()) + winWidth = self.mainConf.rpxInt(self.width()) + winHeight = self.mainConf.rpxInt(self.height()) justifyText = self.justifyText.isChecked() noStyling = self.noStyling.isChecked() textFont = self.textFont.text() @@ -924,12 +930,14 @@ class GuiBuildNovelDocView(QTextBrowser): self.mainConf = nw.CONFIG self.theProject = theProject self.theParent = theParent + self.theTheme = theParent.theTheme + self.buildTime = 0 self.setMinimumWidth(40*self.theParent.theTheme.textNWidth) self.setOpenExternalLinks(False) self.qDocument = self.document() - self.qDocument.setDocumentMargin(self.mainConf.textMargin) + self.qDocument.setDocumentMargin(self.mainConf.getTextMargin()) self.setPlaceholderText( "This area will show the content of the document to be " "exported or printed. Press the \"Build Novel Project\" " @@ -946,12 +954,35 @@ class GuiBuildNovelDocView(QTextBrowser): docPalette = self.palette() docPalette.setColor(QPalette.Base, QColor(255, 255, 255)) - docPalette.setColor(QPalette.Text, QColor( 0, 0, 0)) + docPalette.setColor(QPalette.Text, QColor(0, 0, 0)) self.setPalette(docPalette) + lblPalette = self.palette() + lblPalette.setColor(QPalette.Background, lblPalette.toolTipBase().color()) + lblPalette.setColor(QPalette.Foreground, lblPalette.toolTipText().color()) + + lblFont = self.font() + lblFont.setPointSizeF(0.9*self.theTheme.fontPointSize) + + fPx = int(1.1*self.theTheme.fontPixelSize) + mPx = self.mainConf.pxInt(4) + + self.theTitle = QLabel("Build Time: Unknown", self) + self.theTitle.setIndent(0) + self.theTitle.setAutoFillBackground(True) + self.theTitle.setAlignment(Qt.AlignCenter) + self.theTitle.setFixedHeight(fPx) + self.theTitle.setPalette(lblPalette) + self.theTitle.setFont(lblFont) + + self._updateDocMargins() self.setStyleSheet() - self.show() + # Age Timer + self.ageTimer = QTimer() + self.ageTimer.setInterval(10000) + self.ageTimer.timeout.connect(self._updateBuildAge) + self.ageTimer.start() logger.debug("GuiBuildNovelDocView initialisation complete") @@ -977,15 +1008,20 @@ class GuiBuildNovelDocView(QTextBrowser): self.setFont(theFont) return - def setContent(self, theText): + def setContent(self, theText, timeStamp): """Set the content, either from text or list of text. """ if isinstance(theText, list): theText = "".join(theText) + + self.buildTime = timeStamp + theText = theText.replace(" ", " "*4) theText = theText.replace("", "") theText = theText.replace("", "") self.setHtml(theText) + self._updateBuildAge() + return def setStyleSheet(self, theStyles=[]): @@ -1007,4 +1043,51 @@ class GuiBuildNovelDocView(QTextBrowser): self.qDocument.setDefaultStyleSheet("") return + ## + # Events + ## + + def resizeEvent(self, theEvent): + """Make sure the document title is the same width as the window. + """ + QTextBrowser.resizeEvent(self, theEvent) + self._updateDocMargins() + return + + ## + # Internal Functions + ## + + def _updateBuildAge(self): + """Update the build time and the fuzzy age. + """ + if self.buildTime > 0: + strBuildTime = "%s (%s)" % ( + datetime.fromtimestamp(self.buildTime).strftime("%x %X"), + fuzzyTime(time() - self.buildTime) + ) + else: + strBuildTime = "Unknown" + self.theTitle.setText("Build Time: %s" % strBuildTime) + + + def _updateDocMargins(self): + """Automatically adjust the header to fill the top of the + document within the viewport. + """ + vBar = self.verticalScrollBar() + if vBar.isVisible(): + sW = vBar.width() + else: + sW = 0 + + tB = self.frameWidth() + tW = self.width() - 2*tB - sW + tH = self.theTitle.height() + + self.theTitle.setGeometry(tB, tB, tW, tH) + self.setViewportMargins(0, tH, 0, 0) + + return + # END Class GuiBuildNovelDocView diff --git a/nw/gui/custom.py b/nw/gui/custom.py index 5c3dfbd6..051fa1f3 100644 --- a/nw/gui/custom.py +++ b/nw/gui/custom.py @@ -30,17 +30,17 @@ import logging import nw -from PyQt5.QtGui import QColor, QPalette, QPainter +from PyQt5.QtGui import QColor, QPalette, QPainter, QFontMetrics from PyQt5.QtCore import ( Qt, QRect, QPoint, QSize, QRectF, QPropertyAnimation, pyqtProperty ) from PyQt5.QtWidgets import ( QGridLayout, QLabel, QWidget, QVBoxLayout, QHBoxLayout, QSizePolicy, - QAbstractButton, QDialog, QTabWidget, QTabBar, QStyle, - QStylePainter, QStyleOptionTab + QAbstractButton, QDialog, QTabWidget, QTabBar, QStyle, QDialogButtonBox, + QStylePainter, QStyleOptionTab, QListWidget, QListWidgetItem, QFrame ) -from nw.constants import nwUnicode +from nw.constants import nwUnicode, nwQuotes logger = logging.getLogger(__name__) @@ -436,3 +436,99 @@ class VerticalTabBar(QTabBar): return # END Class VerticalTabBar + +# =============================================================================================== # +# Quotes Dialog +# =============================================================================================== # + +class QuotesDialog(QDialog): + + def __init__(self, theParent=None, currentQuote="\""): + QDialog.__init__(self, parent=theParent) + + self.mainConf = nw.CONFIG + + self.outerBox = QVBoxLayout() + self.innerBox = QHBoxLayout() + self.labelBox = QVBoxLayout() + + self.selectedQuote = currentQuote + + qMetrics = QFontMetrics(self.font()) + pxW = 7*qMetrics.boundingRectChar("M").width() + pxH = 7*qMetrics.boundingRectChar("M").height() + pxH = 7*qMetrics.boundingRectChar("M").height() + + lblFont = self.font() + lblFont.setPointSizeF(4*lblFont.pointSizeF()) + + # Preview Label + self.previewLabel = QLabel(currentQuote) + self.previewLabel.setFont(lblFont) + self.previewLabel.setFixedSize(QSize(pxW, pxH)) + self.previewLabel.setAlignment(Qt.AlignCenter) + self.previewLabel.setFrameStyle(QFrame.Box | QFrame.Plain) + + # Quote Symbols + self.listBox = QListWidget() + self.listBox.itemSelectionChanged.connect(self._selectedSymbol) + + minSize = 100 + for sKey, sLabel in nwQuotes.SYMBOLS.items(): + theText = "[ %s ] %s" % (sKey, sLabel) + minSize = max(minSize, qMetrics.boundingRect(theText).width()) + qtItem = QListWidgetItem(theText) + qtItem.setData(Qt.UserRole, sKey) + self.listBox.addItem(qtItem) + if sKey == currentQuote: + self.listBox.setCurrentItem(qtItem) + + self.listBox.setMinimumWidth(minSize + self.mainConf.pxInt(40)) + self.listBox.setMinimumHeight(self.mainConf.pxInt(150)) + + # Buttons + self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.buttonBox.accepted.connect(self._doAccept) + self.buttonBox.rejected.connect(self._doReject) + + # Assemble + self.labelBox.addWidget(self.previewLabel, 0, Qt.AlignTop) + self.labelBox.addStretch(1) + + self.innerBox.addLayout(self.labelBox) + self.innerBox.addWidget(self.listBox) + + self.outerBox.addLayout(self.innerBox) + self.outerBox.addWidget(self.buttonBox) + + self.setLayout(self.outerBox) + + return + + ## + # Slots + ## + + def _selectedSymbol(self): + """Update the preview label and the selected quote style. + """ + selItems = self.listBox.selectedItems() + if selItems: + theSymbol = selItems[0].data(Qt.UserRole) + self.previewLabel.setText(theSymbol) + self.selectedQuote = theSymbol + return + + def _doAccept(self): + """Ok button clicked. + """ + self.accept() + return + + def _doReject(self): + """Cancel button clicked. + """ + self.reject() + return + +# END Class QuotesDialog diff --git a/nw/gui/doceditor.py b/nw/gui/doceditor.py index 8bfd51c0..28a1ad2b 100644 --- a/nw/gui/doceditor.py +++ b/nw/gui/doceditor.py @@ -45,11 +45,12 @@ from PyQt5.QtWidgets import ( QFrame ) -from nw.core import NWDoc +from nw.core import NWDoc, NWSpellSimple, countWords from nw.gui.dochighlight import GuiDocHighlighter -from nw.core import NWSpellSimple, countWords -from nw.constants import nwUnicode, nwDocAction, nwItemClass from nw.common import transferCase +from nw.constants import ( + nwAlert, nwUnicode, nwDocAction, nwDocInsert, nwInsertSymbols +) logger = logging.getLogger(__name__) @@ -76,6 +77,7 @@ class GuiDocEditor(QTextEdit): self.paraCount = 0 self.lastEdit = 0 self.bigDoc = False + self.doReplace = False self.nonWord = "\"'" # Typography @@ -86,7 +88,6 @@ class GuiDocEditor(QTextEdit): # Core Elements self.qDocument = self.document() - self.qDocument.setDocumentMargin(self.mainConf.getTextMargin()) self.qDocument.contentsChange.connect(self._docChange) # Document Title @@ -101,8 +102,7 @@ class GuiDocEditor(QTextEdit): self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._openContextMenu) - # Editor State - self.hasSelection = False + # Editor Settings self.setMinimumWidth(self.mainConf.pxInt(300)) self.setAutoFillBackground(True) self.setAcceptRichText(False) @@ -157,8 +157,7 @@ class GuiDocEditor(QTextEdit): self.paraCount = 0 self.lastEdit = 0 self.bigDoc = False - - self.hasSelection = False + self.doReplace = False self.setDocumentChanged(False) self.docHeader.setTitleFromHandle(self.theHandle) @@ -261,7 +260,9 @@ class GuiDocEditor(QTextEdit): self.hLight.spellCheck = False bfTime = time() + self._allowAutoReplace(False) self.setPlainText(theDoc) + self._allowAutoReplace(True) afTime = time() logger.debug("Document highlighted in %.3f milliseconds" % (1000*(afTime-bfTime))) @@ -356,7 +357,11 @@ class GuiDocEditor(QTextEdit): else: rH = 0 +<<<<<<< HEAD self.setViewportMargins(tM, max(cM, tH, rH), tM, max(cM, fH)) +======= + self.setViewportMargins(tM, max(cM, tH, rH), tM, cM) +>>>>>>> master return @@ -392,8 +397,8 @@ class GuiDocEditor(QTextEdit): """ if self.mainConf.verQtValue >= 50900: theText = self.qDocument.toRawText() - theText = theText.replace("\u2028", "\n") # Line separators - theText = theText.replace("\u2029", "\n") # Paragraph separators + theText = theText.replace(nwUnicode.U_LSEP, "\n") # Line separators + theText = theText.replace(nwUnicode.U_PSEP, "\n") # Paragraph separators else: theText = self.toPlainText() return theText @@ -500,6 +505,7 @@ class GuiDocEditor(QTextEdit): if not self.theParent.hasProject: logger.error("No project open") return False + self._allowAutoReplace(False) if theAction == nwDocAction.UNDO: self.undo() elif theAction == nwDocAction.REDO: @@ -548,9 +554,15 @@ class GuiDocEditor(QTextEdit): self._formatBlock(nwDocAction.BLOCK_COM) elif theAction == nwDocAction.BLOCK_TXT: self._formatBlock(nwDocAction.BLOCK_TXT) + elif theAction == nwDocAction.REPL_SNG: + self._replaceQuotes("'", self.typSQOpen, self.typSQClose) + elif theAction == nwDocAction.REPL_DBL: + self._replaceQuotes("\"", self.typDQOpen, self.typDQClose) else: logger.debug("Unknown or unsupported document action %s" % str(theAction)) + self._allowAutoReplace(True) return False + self._allowAutoReplace(True) return True def isEmpty(self): @@ -574,6 +586,21 @@ class GuiDocEditor(QTextEdit): )) return + def insertText(self, theInsert): + """Insert a specific type of text at the cursor position. + """ + if isinstance(theInsert, str): + theText = theInsert + elif theInsert in nwInsertSymbols.SYMBOLS: + theText = nwInsertSymbols.SYMBOLS[theInsert] + else: + return False + theCursor = self.textCursor() + theCursor.beginEditBlock() + theCursor.insertText(theText) + theCursor.endEditBlock() + return True + def closeSearch(self): """Close the search box. """ @@ -586,34 +613,23 @@ class GuiDocEditor(QTextEdit): def keyPressEvent(self, keyEvent): """Intercept key press events. - We need to intercept key presses briefly to record the state of - selection. This is in order to know whether we had a selection - prior to triggering the _docChange slot, as we do not want to - trigger autoreplace on selections. Autoreplace on selections - messes with undo/redo history. - We also need to intercept the Shift key modifier for certain key - combinations that modifies standard keys like enter and space. - However, we don't want to spend a lot of time in this function - as it is triggered on every keypress when typing. + We need to intercept a few key sequences: + * The return key redirects here even if the search box has + focus. Since we need the return key to continue search, we + block any further interaction here while it's in focus. + * The undo sequence bypasses the doAction pathway from the + menu, so we redirect it back from here. + * The default redo sequence is Ctrl+Shift+Z, which we don't + use, so we block it. """ - self.hasSelection = self.textCursor().hasSelection() if self.docSearch.searchBox.hasFocus(): - # Block the event when the focus is on the search bar. return - - if keyEvent.modifiers() == Qt.ShiftModifier: - theKey = keyEvent.key() - if theKey == Qt.Key_Return: - self._insertHardBreak() - elif theKey == Qt.Key_Enter: - self._insertHardBreak() - elif theKey == Qt.Key_Space: - self._insertNonBreakingSpace() - else: - QTextEdit.keyPressEvent(self, keyEvent) + elif keyEvent == QKeySequence.Redo: + return + elif keyEvent == QKeySequence.Undo: + self.docAction(nwDocAction.UNDO) else: QTextEdit.keyPressEvent(self, keyEvent) - return def mouseReleaseEvent(self, mEvent): @@ -649,7 +665,7 @@ class GuiDocEditor(QTextEdit): self.setDocumentChanged(True) if not self.wcTimer.isActive(): self.wcTimer.start() - if self.mainConf.doReplace and not self.hasSelection: + if self.doReplace and charsAdded == 1: self._docAutoReplace(self.qDocument.findBlock(thePos)) return @@ -800,24 +816,6 @@ class GuiDocEditor(QTextEdit): return True - def _insertHardBreak(self): - """Inserts a hard line break at the cursor position. - """ - theCursor = self.textCursor() - theCursor.beginEditBlock() - theCursor.insertText(" \n") - theCursor.endEditBlock() - return - - def _insertNonBreakingSpace(self): - """Inserts a non-breaking space at the cursor position. - """ - theCursor = self.textCursor() - theCursor.beginEditBlock() - theCursor.insertText(nwUnicode.U_NBSP) - theCursor.endEditBlock() - return - def _openSpellContext(self): """Opens the spell check context menu at the current point of the cursor. @@ -879,6 +877,58 @@ class GuiDocEditor(QTextEdit): return + def _replaceQuotes(self, sQuote, oQuote, cQuote): + """Replace all straight quotes in the selected text. + """ + theCursor = self.textCursor() + if theCursor.hasSelection(): + posS = theCursor.selectionStart() + posE = theCursor.selectionEnd() + closeCheck = ( + " ", "\n", nwUnicode.U_LSEP, nwUnicode.U_PSEP + ) + + self._allowAutoReplace(False) + for posC in range(posS, posE+1): + theCursor.setPosition(posC) + theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 2) + selText = theCursor.selectedText() + + nS = len(selText) + if nS == 2: + pC = selText[0] + cC = selText[1] + elif nS == 1: + pC = " " + cC = selText[0] + else: + continue + + if cC != sQuote: + continue + + theCursor.clearSelection() + theCursor.setPosition(posC) + if pC in closeCheck: + theCursor.beginEditBlock() + theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1) + theCursor.insertText(oQuote) + theCursor.endEditBlock() + else: + theCursor.beginEditBlock() + theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1) + theCursor.insertText(cQuote) + theCursor.endEditBlock() + + self._allowAutoReplace(True) + + else: + self.theParent.makeAlert( + "Please selection some text before calling replace quotes.", nwAlert.ERROR + ) + + return + def _checkDocSize(self, theSize): """Check if document size crosses the big document limit set in config. If so, we will set the big document flag to True. @@ -1196,10 +1246,10 @@ class GuiDocEditor(QTextEdit): self._beginSearch() return + theCursor = self.textCursor() if not theCursor.hasSelection(): return - theCursor = self.textCursor() searchFor = self.docSearch.getSearchText() replWith = self.docSearch.getReplaceText() selText = theCursor.selectedText() @@ -1242,6 +1292,15 @@ class GuiDocEditor(QTextEdit): return + def _allowAutoReplace(self, theState): + """used to enable/disable the auto-replace feature temporarily. + """ + if theState: + self.doReplace = self.mainConf.doReplace + else: + self.doReplace = False + return + # END Class GuiDocEditor # =============================================================================================== # @@ -1770,7 +1829,7 @@ class GuiDocEditHeader(QWidget): """Capture a click on the title and ensure that the item is selected in the project tree. """ - self.theParent.treeView.setSelectedHandle(self.theHandle) + self.theParent.treeView.setSelectedHandle(self.theHandle, doScroll=True) return # END Class GuiDocEditHeader diff --git a/nw/gui/dochighlight.py b/nw/gui/dochighlight.py index a76c9646..4acc0e7a 100644 --- a/nw/gui/dochighlight.py +++ b/nw/gui/dochighlight.py @@ -132,7 +132,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): # Non-Breaking Spaces self.hRules.append(( - "[%s]+" % nwUnicode.U_NBSP, { + "[%s%s]+" % (nwUnicode.U_NBSP, nwUnicode.U_THNBSP), { 0 : self.hStyles["nobreak"], } )) diff --git a/nw/gui/docmerge.py b/nw/gui/docmerge.py index 1294501f..6b16102d 100644 --- a/nw/gui/docmerge.py +++ b/nw/gui/docmerge.py @@ -120,7 +120,7 @@ class GuiDocMerge(QDialog): self.theParent.treeView.revealTreeItem(nHandle) theDoc.openDocument(nHandle, False) theDoc.saveDocument(theText) - self.theParent.openDocument(nHandle) + self.theParent.openDocument(nHandle, doScroll=True) self.close() diff --git a/nw/gui/docviewer.py b/nw/gui/docviewer.py index 8c2ea236..17a7c079 100644 --- a/nw/gui/docviewer.py +++ b/nw/gui/docviewer.py @@ -60,6 +60,7 @@ class GuiDocViewer(QTextBrowser): self.qDocument = self.document() self.setMinimumWidth(self.mainConf.pxInt(300)) + self.setAutoFillBackground(True) self.setOpenExternalLinks(False) self.initViewer() @@ -104,11 +105,12 @@ class GuiDocViewer(QTextBrowser): self.setFont(theFont) docPalette = self.palette() + docPalette.setColor(QPalette.Window, QColor(*self.theTheme.colBack)) docPalette.setColor(QPalette.Base, QColor(*self.theTheme.colBack)) docPalette.setColor(QPalette.Text, QColor(*self.theTheme.colText)) self.setPalette(docPalette) - self.qDocument.setDocumentMargin(self.mainConf.getTextMargin()) + self.qDocument.setDocumentMargin(0) theOpt = QTextOption() if self.mainConf.doJustify: theOpt.setAlignment(Qt.AlignJustify) @@ -242,19 +244,10 @@ class GuiDocViewer(QTextBrowser): tH = self.docHeader.height() fH = self.docFooter.height() fY = self.height() - fH - tB - tT = cM - tH - bT = cM - fH + self.docHeader.setGeometry(tB, tB, tW, tH) self.docFooter.setGeometry(tB, fY, tW, fH) - self.setViewportMargins(0, tH, 0, fH) - - docFormat = self.qDocument.rootFrame().frameFormat() - docFormat.setTopMargin(max(0, tT)) - docFormat.setBottomMargin(max(0, bT)) - - self.qDocument.blockSignals(True) - self.qDocument.rootFrame().setFrameFormat(docFormat) - self.qDocument.blockSignals(False) + self.setViewportMargins(cM, max(cM, tH), cM, max(cM, fH)) return @@ -536,6 +529,8 @@ class GuiDocViewHeader(QWidget): def _refreshDocument(self): """Reload the content of the document. """ + if self.docViewer.theHandle == self.theParent.docEditor.theHandle: + self.theParent.saveDocument() self.docViewer.reloadText() return @@ -547,7 +542,7 @@ class GuiDocViewHeader(QWidget): """Capture a click on the title and ensure that the item is selected in the project tree. """ - self.theParent.treeView.setSelectedHandle(self.theHandle) + self.theParent.treeView.setSelectedHandle(self.theHandle, doScroll=True) return # END Class GuiDocViewHeader diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 720eefae..1f78212d 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -33,7 +33,7 @@ from PyQt5.QtGui import QDesktopServices from PyQt5.QtWidgets import QMenuBar, QAction, QMessageBox from nw.gui.about import GuiAbout -from nw.constants import nwItemType, nwItemClass, nwDocAction +from nw.constants import nwItemType, nwItemClass, nwDocAction, nwDocInsert logger = logging.getLogger(__name__) @@ -50,8 +50,9 @@ class GuiMainMenu(QMenuBar): self._buildProjectMenu() self._buildDocumentMenu() self._buildEditMenu() - self._buildViewMenu() + self._buildInsertMenu() self._buildFormatMenu() + self._buildViewMenu() self._buildToolsMenu() self._buildHelpMenu() @@ -59,6 +60,7 @@ class GuiMainMenu(QMenuBar): self._docAction = self.theParent.passDocumentAction self._moveTreeItem = self.theParent.treeView.moveTreeItem self._newTreeItem = self.theParent.treeView.newTreeItem + self._docInsert = self.theParent.docEditor.insertText logger.debug("GuiMainMenu initialisation complete") @@ -97,6 +99,8 @@ class GuiMainMenu(QMenuBar): ## def _menuExit(self): + """Exit novelWriter. + """ self.theParent.closeMain() return @@ -123,19 +127,33 @@ class GuiMainMenu(QMenuBar): return True def _showAboutQt(self): + """Show Qt's own About dialog. + """ msgBox = QMessageBox() msgBox.aboutQt(self.theParent,"About Qt") return True def _openHelp(self): + """Open the documentation URL in the system's default browser. + """ QDesktopServices.openUrl(QUrl(nw.__docurl__)) return True + def _openIssue(self): + """Open the issue tracker URL in the system's default browser. + """ + QDesktopServices.openUrl(QUrl(nw.__issuesurl__)) + return True + def _showDocumentLocation(self): + """Open the dialog showing the location of the editor document. + """ self.theParent.docEditor.revealLocation() return True def _doBackup(self): + """Call the backup function for the project. + """ self.theProject.zipIt(True) return True @@ -228,14 +246,14 @@ class GuiMainMenu(QMenuBar): self.projMenu.addSeparator() # Project > Edit - self.aEditItem = QAction("&Edit Project Item", self) + self.aEditItem = QAction("Edit Project Item", self) self.aEditItem.setStatusTip("Change item settings") self.aEditItem.setShortcuts(["Ctrl+E", "F2"]) self.aEditItem.triggered.connect(self.theParent.editItem) self.projMenu.addAction(self.aEditItem) # Project > Delete - self.aDeleteItem = QAction("&Delete Project Item", self) + self.aDeleteItem = QAction("Delete Project Item", self) self.aDeleteItem.setStatusTip("Delete selected item") self.aDeleteItem.setShortcut("Ctrl+Del") self.aDeleteItem.triggered.connect(lambda : self.theParent.treeView.deleteItem(None)) @@ -265,21 +283,21 @@ class GuiMainMenu(QMenuBar): self.docuMenu = self.addMenu("&Document") # Document > New - self.aNewDoc = QAction("&New Document", self) + self.aNewDoc = QAction("New Document", self) self.aNewDoc.setStatusTip("Create new document") self.aNewDoc.setShortcut("Ctrl+N") self.aNewDoc.triggered.connect(lambda : self._newTreeItem(nwItemType.FILE, None)) self.docuMenu.addAction(self.aNewDoc) # Document > Open - self.aOpenDoc = QAction("&Open Document", self) + self.aOpenDoc = QAction("Open Document", self) self.aOpenDoc.setStatusTip("Open selected document") self.aOpenDoc.setShortcut("Ctrl+O") self.aOpenDoc.triggered.connect(self.theParent.openSelectedItem) self.docuMenu.addAction(self.aOpenDoc) # Document > Save - self.aSaveDoc = QAction("&Save Document", self) + self.aSaveDoc = QAction("Save Document", self) self.aSaveDoc.setStatusTip("Save current document") self.aSaveDoc.setShortcut("Ctrl+S") self.aSaveDoc.triggered.connect(self.theParent.saveDocument) @@ -341,53 +359,6 @@ class GuiMainMenu(QMenuBar): return - def _buildViewMenu(self): - - # View - self.viewMenu = self.addMenu("&View") - - # View > TreeView - self.aFocusTree = QAction("Focus Project Tree", self) - self.aFocusTree.setStatusTip("Move focus to project tree") - self.aFocusTree.setShortcut("Alt+1") - self.aFocusTree.triggered.connect(lambda : self.theParent.setFocus(1)) - self.viewMenu.addAction(self.aFocusTree) - - # View > Document Pane 1 - self.aFocusEditor = QAction("Focus Document Editor", self) - self.aFocusEditor.setStatusTip("Move focus to left document pane") - self.aFocusEditor.setShortcut("Alt+2") - self.aFocusEditor.triggered.connect(lambda : self.theParent.setFocus(2)) - self.viewMenu.addAction(self.aFocusEditor) - - # View > Document Pane 2 - self.aFocusView = QAction("Focus Document Viewer", self) - self.aFocusView.setStatusTip("Move focus to right document pane") - self.aFocusView.setShortcut("Alt+3") - self.aFocusView.triggered.connect(lambda : self.theParent.setFocus(3)) - self.viewMenu.addAction(self.aFocusView) - - # View > Separator - self.viewMenu.addSeparator() - - # View > Toggle Distraction Free Mode - self.aZenMode = QAction("Zen Mode", self) - self.aZenMode.setStatusTip("Toggles distraction free mode, only showing text editor") - self.aZenMode.setShortcut("F8") - self.aZenMode.setCheckable(True) - self.aZenMode.setChecked(self.theParent.isZenMode) - self.aZenMode.toggled.connect(self.theParent.toggleZenMode) - self.viewMenu.addAction(self.aZenMode) - - # View > Toggle Full Screen - self.aFullScreen = QAction("Full Screen Mode", self) - self.aFullScreen.setStatusTip("Maximises the main window") - self.aFullScreen.setShortcut("F11") - self.aFullScreen.triggered.connect(self.theParent.toggleFullScreenMode) - self.viewMenu.addAction(self.aFullScreen) - - return - def _buildEditMenu(self): # Edit @@ -497,6 +468,65 @@ class GuiMainMenu(QMenuBar): return + def _buildInsertMenu(self): + + # Insert + self.insertMenu = self.addMenu("&Insert") + + # Insert > Short Dash + self.aInsENDash = QAction("Short Dash", self) + self.aInsENDash.setStatusTip("Insert short dash") + self.aInsENDash.setShortcut("Ctrl+K, -") + self.aInsENDash.triggered.connect(lambda: self._docInsert(nwDocInsert.SHORT_DASH)) + self.insertMenu.addAction(self.aInsENDash) + + # Insert > Long Dash + self.aInsEMDash = QAction("Long Dash", self) + self.aInsEMDash.setStatusTip("Insert long dash") + self.aInsEMDash.setShortcut("Ctrl+K, _") + self.aInsEMDash.triggered.connect(lambda: self._docInsert(nwDocInsert.LONG_DASH)) + self.insertMenu.addAction(self.aInsEMDash) + + # Insert > Ellipsis + self.aInsEllipsis = QAction("Ellipsis", self) + self.aInsEllipsis.setStatusTip("Insert ellipsis") + self.aInsEllipsis.setShortcut("Ctrl+K, .") + self.aInsEllipsis.triggered.connect(lambda: self._docInsert(nwDocInsert.ELLIPSIS)) + self.insertMenu.addAction(self.aInsEllipsis) + + # Insert > Separator + self.insertMenu.addSeparator() + + # Insert > Hard Line Break + self.aInsHardBreak = QAction("Hard Line Break", self) + self.aInsHardBreak.setStatusTip("Insert a hard line break") + self.aInsHardBreak.setShortcut("Ctrl+K, Return") + self.aInsHardBreak.triggered.connect(lambda: self._docInsert(nwDocInsert.HARD_BREAK)) + self.insertMenu.addAction(self.aInsHardBreak) + + # Insert > Non-Breaking Space + self.aInsNBSpace = QAction("Non-Breaking Space", self) + self.aInsNBSpace.setStatusTip("Insert a non-breaking space") + self.aInsNBSpace.setShortcut("Ctrl+K, Space") + self.aInsNBSpace.triggered.connect(lambda: self._docInsert(nwDocInsert.NB_SPACE)) + self.insertMenu.addAction(self.aInsNBSpace) + + # Insert > Thin Space + self.aInsThinSpace = QAction("Thin Space", self) + self.aInsThinSpace.setStatusTip("Insert a thin space") + self.aInsThinSpace.setShortcut("Ctrl+K, Shift+Space") + self.aInsThinSpace.triggered.connect(lambda: self._docInsert(nwDocInsert.THIN_SPACE)) + self.insertMenu.addAction(self.aInsThinSpace) + + # Insert > Thin Non-Breaking Space + self.aInsThinNBSpace = QAction("Thin Non-Breaking Space", self) + self.aInsThinNBSpace.setStatusTip("Insert a thin non-breaking space") + self.aInsThinNBSpace.setShortcut("Ctrl+K, Ctrl+Space") + self.aInsThinNBSpace.triggered.connect(lambda: self._docInsert(nwDocInsert.THIN_NB_SPACE)) + self.insertMenu.addAction(self.aInsThinNBSpace) + + return + def _buildFormatMenu(self): # Format @@ -547,7 +577,7 @@ class GuiMainMenu(QMenuBar): self.aFmtSQuote.triggered.connect(lambda: self._docAction(nwDocAction.S_QUOTE)) self.fmtMenu.addAction(self.aFmtSQuote) - # Edit > Separator + # Format > Separator self.fmtMenu.addSeparator() # Format > Header 1 @@ -592,6 +622,68 @@ class GuiMainMenu(QMenuBar): self.aFmtNoFormat.triggered.connect(lambda: self._docAction(nwDocAction.BLOCK_TXT)) self.fmtMenu.addAction(self.aFmtNoFormat) + # Format > Separator + self.fmtMenu.addSeparator() + + # Format > Replace Single Quotes + self.aFmtReplSng = QAction("Replace Single Quotes", self) + self.aFmtReplSng.setStatusTip("Replace all straight single quotes in selected text") + self.aFmtReplSng.triggered.connect(lambda: self._docAction(nwDocAction.REPL_SNG)) + self.fmtMenu.addAction(self.aFmtReplSng) + + # Format > Replace Double Quotes + self.aFmtReplDbl = QAction("Replace Double Quotes", self) + self.aFmtReplDbl.setStatusTip("Replace all straight double quotes in selected text") + self.aFmtReplDbl.triggered.connect(lambda: self._docAction(nwDocAction.REPL_DBL)) + self.fmtMenu.addAction(self.aFmtReplDbl) + + return + + def _buildViewMenu(self): + + # View + self.viewMenu = self.addMenu("&View") + + # View > TreeView + self.aFocusTree = QAction("Focus Project Tree", self) + self.aFocusTree.setStatusTip("Move focus to project tree") + self.aFocusTree.setShortcut("Alt+1") + self.aFocusTree.triggered.connect(lambda : self.theParent.setFocus(1)) + self.viewMenu.addAction(self.aFocusTree) + + # View > Document Pane 1 + self.aFocusEditor = QAction("Focus Document Editor", self) + self.aFocusEditor.setStatusTip("Move focus to left document pane") + self.aFocusEditor.setShortcut("Alt+2") + self.aFocusEditor.triggered.connect(lambda : self.theParent.setFocus(2)) + self.viewMenu.addAction(self.aFocusEditor) + + # View > Document Pane 2 + self.aFocusView = QAction("Focus Document Viewer", self) + self.aFocusView.setStatusTip("Move focus to right document pane") + self.aFocusView.setShortcut("Alt+3") + self.aFocusView.triggered.connect(lambda : self.theParent.setFocus(3)) + self.viewMenu.addAction(self.aFocusView) + + # View > Separator + self.viewMenu.addSeparator() + + # View > Toggle Distraction Free Mode + self.aZenMode = QAction("Zen Mode", self) + self.aZenMode.setStatusTip("Toggles distraction free mode, only showing text editor") + self.aZenMode.setShortcut("F8") + self.aZenMode.setCheckable(True) + self.aZenMode.setChecked(self.theParent.isZenMode) + self.aZenMode.toggled.connect(self.theParent.toggleZenMode) + self.viewMenu.addAction(self.aZenMode) + + # View > Toggle Full Screen + self.aFullScreen = QAction("Full Screen Mode", self) + self.aFullScreen.setStatusTip("Maximises the main window") + self.aFullScreen.setShortcut("F11") + self.aFullScreen.triggered.connect(self.theParent.toggleFullScreenMode) + self.viewMenu.addAction(self.aFullScreen) + return def _buildToolsMenu(self): @@ -703,6 +795,12 @@ class GuiMainMenu(QMenuBar): self.aHelp.triggered.connect(self._openHelp) self.helpMenu.addAction(self.aHelp) + # Document > Report Issue + self.aIssue = QAction("Report an Issue", self) + self.aIssue.setStatusTip("View online documentation") + self.aIssue.triggered.connect(self._openIssue) + self.helpMenu.addAction(self.aIssue) + return # END Class GuiMainMenu diff --git a/nw/gui/outline.py b/nw/gui/outline.py index 1b1aa11c..cd4ff718 100644 --- a/nw/gui/outline.py +++ b/nw/gui/outline.py @@ -196,7 +196,7 @@ class GuiOutline(QTreeWidget): except: tLine = 1 logger.verbose("User selected entry with handle %s on line %s" % (tHandle, tLine)) - self.theParent.openDocument(tHandle, tLine - 1) + self.theParent.openDocument(tHandle, tLine=tLine-1, doScroll=True) return def _itemSelected(self): diff --git a/nw/gui/preferences.py b/nw/gui/preferences.py index 5c0ef235..56905307 100644 --- a/nw/gui/preferences.py +++ b/nw/gui/preferences.py @@ -37,9 +37,9 @@ from PyQt5.QtWidgets import ( QDialogButtonBox, QFileDialog, QFontDialog ) -from nw.gui.custom import QSwitch, QConfigLayout, PagedDialog +from nw.gui.custom import QSwitch, QConfigLayout, PagedDialog, QuotesDialog from nw.core import NWSpellCheck, NWSpellSimple, NWSpellEnchant -from nw.constants import nwAlert, nwQuotes +from nw.constants import nwQuotes logger = logging.getLogger(__name__) @@ -816,51 +816,72 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addGroupLabel("Quotation Style") qWidth = self.mainConf.pxInt(40) + bWidth = int(2.5*self.theTheme.getTextWidth("...")) ## Single Quote Style self.quoteSingleStyleO = QLineEdit() self.quoteSingleStyleO.setMaxLength(1) + self.quoteSingleStyleO.setReadOnly(True) self.quoteSingleStyleO.setFixedWidth(qWidth) self.quoteSingleStyleO.setAlignment(Qt.AlignCenter) self.quoteSingleStyleO.setText(self.mainConf.fmtSingleQuotes[0]) + self.btnSingleStyleO = QPushButton("...") + self.btnSingleStyleO.setMaximumWidth(bWidth) + self.btnSingleStyleO.clicked.connect(self._getSingleOpen) self.mainForm.addRow( "Single quote open style", self.quoteSingleStyleO, - "Auto-replaces apostrophe before words." + "Auto-replaces apostrophe before words.", + theButton=self.btnSingleStyleO ) self.quoteSingleStyleC = QLineEdit() self.quoteSingleStyleC.setMaxLength(1) + self.quoteSingleStyleC.setReadOnly(True) self.quoteSingleStyleC.setFixedWidth(qWidth) self.quoteSingleStyleC.setAlignment(Qt.AlignCenter) self.quoteSingleStyleC.setText(self.mainConf.fmtSingleQuotes[1]) + self.btnSingleStyleC = QPushButton("...") + self.btnSingleStyleC.setMaximumWidth(bWidth) + self.btnSingleStyleC.clicked.connect(self._getSingleClose) self.mainForm.addRow( "Single quote close style", self.quoteSingleStyleC, - "Auto-replaces apostrophe after words." + "Auto-replaces apostrophe after words.", + theButton=self.btnSingleStyleC ) ## Double Quote Style self.quoteDoubleStyleO = QLineEdit() self.quoteDoubleStyleO.setMaxLength(1) + self.quoteDoubleStyleO.setReadOnly(True) self.quoteDoubleStyleO.setFixedWidth(qWidth) self.quoteDoubleStyleO.setAlignment(Qt.AlignCenter) self.quoteDoubleStyleO.setText(self.mainConf.fmtDoubleQuotes[0]) + self.btnDoubleStyleO = QPushButton("...") + self.btnDoubleStyleO.setMaximumWidth(bWidth) + self.btnDoubleStyleO.clicked.connect(self._getDoubleOpen) self.mainForm.addRow( "Double quote open style", self.quoteDoubleStyleO, - "Auto-replaces straight quotes before words." + "Auto-replaces straight quotes before words.", + theButton=self.btnDoubleStyleO ) self.quoteDoubleStyleC = QLineEdit() self.quoteDoubleStyleC.setMaxLength(1) + self.quoteDoubleStyleC.setReadOnly(True) self.quoteDoubleStyleC.setFixedWidth(qWidth) self.quoteDoubleStyleC.setAlignment(Qt.AlignCenter) self.quoteDoubleStyleC.setText(self.mainConf.fmtDoubleQuotes[1]) + self.btnDoubleStyleC = QPushButton("...") + self.btnDoubleStyleC.setMaximumWidth(bWidth) + self.btnDoubleStyleC.clicked.connect(self._getDoubleClose) self.mainForm.addRow( "Double quote close style", self.quoteDoubleStyleC, - "Auto-replaces straight quotes after words." + "Auto-replaces straight quotes after words.", + theButton=self.btnDoubleStyleC ) return @@ -889,37 +910,10 @@ class GuiConfigEditAutoReplaceTab(QWidget): fmtDoubleQuotesO = self.quoteDoubleStyleO.text() fmtDoubleQuotesC = self.quoteDoubleStyleC.text() - if self._checkQuoteSymbol(fmtSingleQuotesO): - self.mainConf.fmtSingleQuotes[0] = fmtSingleQuotesO - else: - self.theParent.makeAlert( - "Invalid quote symbol: %s" % fmtSingleQuotesO, nwAlert.ERROR - ) - validEntries = False - - if self._checkQuoteSymbol(fmtSingleQuotesC): - self.mainConf.fmtSingleQuotes[1] = fmtSingleQuotesC - else: - self.theParent.makeAlert( - "Invalid quote symbol: %s" % fmtSingleQuotesC, nwAlert.ERROR - ) - validEntries = False - - if self._checkQuoteSymbol(fmtDoubleQuotesO): - self.mainConf.fmtDoubleQuotes[0] = fmtDoubleQuotesO - else: - self.theParent.makeAlert( - "Invalid quote symbol: %s" % fmtDoubleQuotesO, nwAlert.ERROR - ) - validEntries = False - - if self._checkQuoteSymbol(fmtDoubleQuotesC): - self.mainConf.fmtDoubleQuotes[1] = fmtDoubleQuotesC - else: - self.theParent.makeAlert( - "Invalid quote symbol: %s" % fmtDoubleQuotesC, nwAlert.ERROR - ) - validEntries = False + self.mainConf.fmtSingleQuotes[0] = fmtSingleQuotesO + self.mainConf.fmtSingleQuotes[1] = fmtSingleQuotesC + self.mainConf.fmtDoubleQuotes[0] = fmtDoubleQuotesO + self.mainConf.fmtDoubleQuotes[1] = fmtDoubleQuotesC self.mainConf.confChanged = True @@ -939,6 +933,38 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.autoReplaceDots.setEnabled(theState) return + def _getSingleOpen(self): + """Dialog for single quote open. + """ + qtBox = QuotesDialog(self, currentQuote=self.quoteSingleStyleO.text()) + if qtBox.exec_() == QDialog.Accepted: + self.quoteSingleStyleO.setText(qtBox.selectedQuote) + return + + def _getSingleClose(self): + """Dialog for single quote close. + """ + qtBox = QuotesDialog(self, currentQuote=self.quoteSingleStyleC.text()) + if qtBox.exec_() == QDialog.Accepted: + self.quoteSingleStyleC.setText(qtBox.selectedQuote) + return + + def _getDoubleOpen(self): + """Dialog for double quote open. + """ + qtBox = QuotesDialog(self, currentQuote=self.quoteDoubleStyleO.text()) + if qtBox.exec_() == QDialog.Accepted: + self.quoteDoubleStyleO.setText(qtBox.selectedQuote) + return + + def _getDoubleClose(self): + """Dialog for double quote close. + """ + qtBox = QuotesDialog(self, currentQuote=self.quoteDoubleStyleC.text()) + if qtBox.exec_() == QDialog.Accepted: + self.quoteDoubleStyleC.setText(qtBox.selectedQuote) + return + ## # Internal Functions ## diff --git a/nw/gui/projsettings.py b/nw/gui/projsettings.py index 0afaefbf..6f8f32ba 100644 --- a/nw/gui/projsettings.py +++ b/nw/gui/projsettings.py @@ -31,10 +31,9 @@ import nw from PyQt5.QtCore import Qt from PyQt5.QtGui import QIcon, QPixmap, QColor, QBrush from PyQt5.QtWidgets import ( - QDialog, QHBoxLayout, QVBoxLayout, QGridLayout, QLineEdit, QPlainTextEdit, - QLabel, QWidget, QTabWidget, QDialogButtonBox, QListWidget, QPushButton, - QListWidgetItem, QColorDialog, QAbstractItemView, QTreeWidget, QCheckBox, - QTreeWidgetItem + QHBoxLayout, QVBoxLayout, QGridLayout, QLineEdit, QPlainTextEdit, QLabel, + QWidget, QDialogButtonBox, QListWidget, QPushButton, QListWidgetItem, + QColorDialog, QAbstractItemView, QTreeWidget, QTreeWidgetItem ) from nw.constants import nwAlert @@ -52,9 +51,17 @@ class GuiProjectSettings(PagedDialog): self.mainConf = nw.CONFIG self.theParent = theParent self.theProject = theProject + self.optState = theProject.optState self.theProject.countStatus() self.setWindowTitle("Project Settings") + self.setMinimumWidth(self.mainConf.pxInt(570)) + self.setMinimumHeight(self.mainConf.pxInt(355)) + + self.resize( + self.mainConf.pxInt(self.optState.getInt("GuiProjectSettings", "winWidth", 570)), + self.mainConf.pxInt(self.optState.getInt("GuiProjectSettings", "winHeight", 355)) + ) self.tabMain = GuiProjectEditMain(self.theParent, self.theProject) self.tabMeta = GuiProjectEditMeta(self.theParent, self.theProject) @@ -73,13 +80,17 @@ class GuiProjectSettings(PagedDialog): self.buttonBox.rejected.connect(self._doClose) self.addControls(self.buttonBox) - self.show() - logger.debug("GuiProjectSettings initialisation complete") return + ## + # Slots + ## + def _doSave(self): + """Save settings and close dialog. + """ logger.verbose("GuiProjectSettings save button clicked") projName = self.tabMain.editName.text() @@ -103,13 +114,23 @@ class GuiProjectSettings(PagedDialog): newList = self.tabReplace.getNewList() self.theProject.setAutoReplace(newList) - self.close() + self._doClose() return def _doClose(self): - logger.verbose("GuiProjectSettings close button clicked") + """Close the dialog. + """ + winWidth = self.mainConf.rpxInt(self.width()) + winHeight = self.mainConf.rpxInt(self.height()) + replaceColW = self.mainConf.rpxInt(self.tabReplace.listBox.columnWidth(0)) + + self.optState.setValue("GuiProjectSettings", "winWidth", winWidth) + self.optState.setValue("GuiProjectSettings", "winHeight", winHeight) + self.optState.setValue("GuiProjectSettings", "replaceColW", replaceColW) + self.close() + return # END Class GuiProjectSettings @@ -455,16 +476,23 @@ class GuiProjectEditReplace(QWidget): def __init__(self, theParent, theProject): QWidget.__init__(self, theParent) + self.mainConf = nw.CONFIG self.theParent = theParent self.theTheme = theParent.theTheme self.theProject = theProject + self.optState = theProject.optState self.arChanged = False - self.outerBox = QVBoxLayout() - self.bottomBox = QHBoxLayout() - self.listBox = QTreeWidget() + self.outerBox = QVBoxLayout() + self.bottomBox = QHBoxLayout() + + wCol0 = self.mainConf.pxInt( + self.optState.getInt("GuiProjectSettings", "replaceColW", 100) + ) + self.listBox = QTreeWidget() self.listBox.setHeaderLabels(["Keyword","Replace With"]) self.listBox.itemSelectionChanged.connect(self._selectedItem) + self.listBox.setColumnWidth(0, wCol0) self.listBox.setIndentation(0) for aKey, aVal in self.theProject.autoReplace.items(): diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 236b9f90..d6eb8cdf 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -54,6 +54,7 @@ class GuiProjectTree(QTreeWidget): QTreeWidget.__init__(self, theParent) logger.debug("Initialising GuiProjectTree ...") + self.mainConf = nw.CONFIG self.theParent = theParent self.theTheme = theParent.theTheme @@ -237,8 +238,8 @@ class GuiProjectTree(QTreeWidget): """ if qApp.focusWidget() == self and self.theParent.hasProject: tHandle = self.getSelectedHandle() - tItem = self._getTreeItem(tHandle) - pItem = tItem.parent() + tItem = self._getTreeItem(tHandle) + pItem = tItem.parent() if pItem is None: tIndex = self.indexOfTopLevelItem(tItem) nChild = self.topLevelItemCount() @@ -367,6 +368,7 @@ class GuiProjectTree(QTreeWidget): if nwItemS is None: return False + wCount = int(trItemS.text(self.C_COUNT)) if nwItemS.itemType == nwItemType.FILE: logger.debug("User requested file %s moved to trash" % tHandle) trItemP = trItemS.parent() @@ -393,7 +395,8 @@ class GuiProjectTree(QTreeWidget): if doPermanent: logger.debug("Permanently deleting file with handle %s" % tHandle) - tIndex = trItemP.indexOfChild(trItemS) + self.propagateCount(tHandle, 0) + tIndex = trItemP.indexOfChild(trItemS) trItemC = trItemP.takeChild(tIndex) if self.theParent.docEditor.theHandle == tHandle: @@ -422,10 +425,12 @@ class GuiProjectTree(QTreeWidget): if pHandle is None: logger.warning("File has no parent item") + self.propagateCount(tHandle, 0) tIndex = trItemP.indexOfChild(trItemS) trItemC = trItemP.takeChild(tIndex) trItemT.addChild(trItemC) nwItemS.setParent(self.theProject.projTree.trashRoot()) + self.propagateCount(tHandle, wCount) self._setTreeChanged(True) self.theParent.theIndex.deleteHandle(tHandle) @@ -504,7 +509,7 @@ class GuiProjectTree(QTreeWidget): """ tItem = self._getTreeItem(tHandle) if tItem is not None: - tItem.setText(self.C_COUNT,str(theCount)) + tItem.setText(self.C_COUNT, str(theCount)) pItem = tItem.parent() if pItem is not None: pCount = 0 @@ -520,7 +525,7 @@ class GuiProjectTree(QTreeWidget): relevant values in the project and on the status bar. This call is a fast way of getting this number, and depends on the propagateCount function being called when it should to maintain - the correct count. + the correct count. Orphan folder is not included in the total. """ nWords = 0 for n in range(self.topLevelItemCount()): @@ -569,14 +574,14 @@ class GuiProjectTree(QTreeWidget): selHandles.append(selItems[n].data(self.C_NAME, Qt.UserRole)) return selHandles - def setSelectedHandle(self, tHandle): + def setSelectedHandle(self, tHandle, doScroll=False): """Set a specific handle as the selected item. """ if tHandle in self.theMap: self.clearSelection() self.theMap[tHandle].setSelected(True) selItems = self.selectedIndexes() - if selItems: + if selItems and doScroll: self.scrollTo( selItems[0], QAbstractItemView.PositionAtCenter ) @@ -629,23 +634,26 @@ class GuiProjectTree(QTreeWidget): logger.error("Invalid drop index") return - dItem = self.itemFromIndex(dIndex) + sItem = self._getTreeItem(sHandle) + dItem = self.itemFromIndex(dIndex) dHandle = dItem.data(self.C_NAME, Qt.UserRole) - snItem = self.theProject.projTree[sHandle] - dnItem = self.theProject.projTree[dHandle] + snItem = self.theProject.projTree[sHandle] + dnItem = self.theProject.projTree[dHandle] if dnItem is None: self.makeAlert("The item cannot be moved to that location.", nwAlert.ERROR) return - isSame = snItem.itemClass == dnItem.itemClass - isNone = snItem.itemClass == nwItemClass.NO_CLASS - isNote = snItem.itemLayout == nwItemLayout.NOTE - onFile = dnItem.itemType == nwItemType.FILE - isRoot = snItem.itemType == nwItemType.ROOT - onRoot = dnItem.itemType == nwItemType.ROOT + wCount = int(sItem.text(self.C_COUNT)) + isSame = snItem.itemClass == dnItem.itemClass + isNone = snItem.itemClass == nwItemClass.NO_CLASS + isNote = snItem.itemLayout == nwItemLayout.NOTE + onFile = dnItem.itemType == nwItemType.FILE + isRoot = snItem.itemType == nwItemType.ROOT + onRoot = dnItem.itemType == nwItemType.ROOT isOnTop = self.dropIndicatorPosition() == QAbstractItemView.OnItem if (isSame or isNone or isNote) and not (onFile and isOnTop) and not isRoot: logger.debug("Drag'n'drop of item %s accepted" % sHandle) + self.propagateCount(sHandle, 0) QTreeWidget.dropEvent(self, theEvent) if isNone: self._moveOrphanedItem(sHandle, dHandle) @@ -660,6 +668,7 @@ class GuiProjectTree(QTreeWidget): )) snItem.setClass(dnItem.itemClass) self.setTreeItemValues(sHandle) + self.propagateCount(sHandle, wCount) else: logger.debug("Drag'n'drop of item %s not accepted" % sHandle) self.makeAlert("The item cannot be moved to that location.", nwAlert.ERROR) @@ -794,8 +803,7 @@ class GuiProjectTree(QTreeWidget): def _updateItemParent(self, tHandle): """Update the parent handle of an item so that the information - in the project is consistent with the treeView. Also move the - word count over to the new parent tree. + in the project is consistent with the treeView. """ trItemS = self._getTreeItem(tHandle) nwItemS = self.theProject.projTree[tHandle] @@ -805,10 +813,7 @@ class GuiProjectTree(QTreeWidget): return False pHandle = trItemP.data(self.C_NAME, Qt.UserRole) - wC = int(trItemS.text(self.C_COUNT)) - self.propagateCount(tHandle, -wC) nwItemS.setParent(pHandle) - self.propagateCount(tHandle, wC) self.setTreeItemValues(tHandle) self._setTreeChanged(True) @@ -929,7 +934,7 @@ class GuiProjectTreeMenu(QMenu): """Forward the open document call to the main GUI window. """ if self.theItem is not None: - self.theTree.theParent.openDocument(self.theItem.itemHandle) + self.theTree.theParent.openDocument(self.theItem.itemHandle, doScroll=False) return def _doViewItem(self): diff --git a/nw/gui/sessionlog.py b/nw/gui/sessionlog.py index 5f3c61f0..e00feb21 100644 --- a/nw/gui/sessionlog.py +++ b/nw/gui/sessionlog.py @@ -26,78 +26,105 @@ """ import logging +import json import nw from os import path from datetime import datetime from PyQt5.QtCore import Qt -from PyQt5.QtGui import QFont +from PyQt5.QtGui import QFont, QPixmap from PyQt5.QtWidgets import ( - QDialog, QHBoxLayout, QTreeWidget, QTreeWidgetItem, QDialogButtonBox, - QGridLayout, QLabel, QGroupBox, QCheckBox + qApp, QDialog, QTreeWidget, QTreeWidgetItem, QDialogButtonBox, QGridLayout, + QLabel, QGroupBox, QMenu, QAction, QFileDialog ) from nw.constants import nwConst, nwFiles, nwAlert +from nw.gui.custom import QSwitch logger = logging.getLogger(__name__) -class GuiSessionLogView(QDialog): +class GuiSessionLog(QDialog): + + C_TIME = 0 + C_LENGTH = 1 + C_COUNT = 2 + C_BAR = 3 + + FMT_JSON = 0 + FMT_CSV = 1 def __init__(self, theParent, theProject): QDialog.__init__(self, theParent) - logger.debug("Initialising SessionLogView ...") + logger.debug("Initialising GuiSessionLog ...") self.mainConf = nw.CONFIG - self.theProject = theProject self.theParent = theParent - self.optState = self.theProject.optState + self.theProject = theProject + self.theTheme = theParent.theTheme + self.optState = theProject.optState + self.logData = [] + self.filterData = [] self.timeFilter = 0.0 - self.timeTotal = 0.0 - - self.outerBox = QGridLayout() - self.bottomBox = QHBoxLayout() + self.wordOffset = 0 self.setWindowTitle("Session Log") self.setMinimumWidth(self.mainConf.pxInt(420)) self.setMinimumHeight(self.mainConf.pxInt(400)) + self.resize( + self.mainConf.pxInt(self.optState.getInt("GuiSessionLog", "winWidth", 550)), + self.mainConf.pxInt(self.optState.getInt("GuiSessionLog", "winHeight", 500)) + ) - wCol0 = self.mainConf.pxInt(self.optState.getInt("GuiSession", "widthCol0", 180)) - wCol1 = self.mainConf.pxInt(self.optState.getInt("GuiSession", "widthCol1", 80)) - wCol2 = self.mainConf.pxInt(self.optState.getInt("GuiSession", "widthCol2", 80)) + # List Box + wCol0 = self.mainConf.pxInt( + self.optState.getInt("GuiSessionLog", "widthCol0", 180) + ) + wCol1 = self.mainConf.pxInt( + self.optState.getInt("GuiSessionLog", "widthCol1", 80) + ) + wCol2 = self.mainConf.pxInt( + self.optState.getInt("GuiSessionLog", "widthCol2", 80) + ) self.listBox = QTreeWidget() - self.listBox.setHeaderLabels(["Session Start","Length","Words",""]) + self.listBox.setHeaderLabels(["Session Start","Length","Words","Histogram"]) self.listBox.setIndentation(0) - self.listBox.setColumnWidth(0, wCol0) - self.listBox.setColumnWidth(1, wCol1) - self.listBox.setColumnWidth(2, wCol2) - self.listBox.setColumnWidth(3, 0) + self.listBox.setColumnWidth(self.C_TIME, wCol0) + self.listBox.setColumnWidth(self.C_LENGTH, wCol1) + self.listBox.setColumnWidth(self.C_COUNT, wCol2) hHeader = self.listBox.headerItem() - hHeader.setTextAlignment(1,Qt.AlignRight) - hHeader.setTextAlignment(2,Qt.AlignRight) + hHeader.setTextAlignment(self.C_LENGTH, Qt.AlignRight) + hHeader.setTextAlignment(self.C_COUNT, Qt.AlignRight) - self.monoFont = QFont("Monospace", 10) + self.monoFont = QFont() + self.monoFont.setPointSizeF(0.9*self.theTheme.fontPointSize) + self.monoFont.setFamily(self.theTheme.guiFontFixed.family()) sortValid = (Qt.AscendingOrder, Qt.DescendingOrder) sortCol = self.optState.validIntRange( - self.optState.getInt("GuiSession", "sortCol", 0), 0, 2, 0 + self.optState.getInt("GuiSessionLog", "sortCol", 0), 0, 2, 0 ) sortOrder = self.optState.validIntTuple( - self.optState.getInt("GuiSession", "sortOrder", Qt.DescendingOrder), + self.optState.getInt("GuiSessionLog", "sortOrder", Qt.DescendingOrder), sortValid, Qt.DescendingOrder ) - self.listBox.sortByColumn(sortCol, sortOrder) self.listBox.setSortingEnabled(True) + # Word Bar + self.barHeight = int(round(0.5*self.theTheme.fontPixelSize)) + self.barWidth = self.mainConf.pxInt(200) + self.barImage = QPixmap(self.barHeight, self.barHeight) + self.barImage.fill(self.palette().highlight().color()) + # Session Info - self.infoBox = QGroupBox("Sum Total Time", self) - self.infoBoxForm = QGridLayout(self) - self.infoBox.setLayout(self.infoBoxForm) + self.infoBox = QGroupBox("Sum Totals", self) + self.infoForm = QGridLayout(self) + self.infoBox.setLayout(self.infoForm) self.labelTotal = QLabel(self._formatTime(0)) self.labelTotal.setFont(self.monoFont) @@ -107,105 +134,296 @@ class GuiSessionLogView(QDialog): self.labelFilter.setFont(self.monoFont) self.labelFilter.setAlignment(Qt.AlignVCenter | Qt.AlignRight) - self.infoBoxForm.addWidget(QLabel("All:"), 0, 0) - self.infoBoxForm.addWidget(self.labelTotal, 0, 1) - self.infoBoxForm.addWidget(QLabel("Filtered:"), 1, 0) - self.infoBoxForm.addWidget(self.labelFilter, 1, 1) + self.novelWords = QLabel("0") + self.novelWords.setFont(self.monoFont) + self.novelWords.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + + self.notesWords = QLabel("0") + self.notesWords.setFont(self.monoFont) + self.notesWords.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + + self.totalWords = QLabel("0") + self.totalWords.setFont(self.monoFont) + self.totalWords.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + + self.infoForm.addWidget(QLabel("Total Time:"), 0, 0) + self.infoForm.addWidget(QLabel("Filtered Time:"), 1, 0) + self.infoForm.addWidget(QLabel("Novel Word Count:"), 2, 0) + self.infoForm.addWidget(QLabel("Notes Word Count:"), 3, 0) + self.infoForm.addWidget(QLabel("Total Word Count:"), 4, 0) + self.infoForm.addWidget(self.labelTotal, 0, 1) + self.infoForm.addWidget(self.labelFilter, 1, 1) + self.infoForm.addWidget(self.novelWords, 2, 1) + self.infoForm.addWidget(self.notesWords, 3, 1) + self.infoForm.addWidget(self.totalWords, 4, 1) + self.infoForm.setRowStretch(5, 1) # Filter Options - self.filterBox = QGroupBox("Filters", self) - self.filterBoxForm = QGridLayout(self) - self.filterBox.setLayout(self.filterBoxForm) + sPx = self.theTheme.baseIconSize - self.hideZeros = QCheckBox("Hide zero word count", self) + self.filterBox = QGroupBox("Filters", self) + self.filterForm = QGridLayout(self) + self.filterBox.setLayout(self.filterForm) + + self.incNovel = QSwitch(width=2*sPx, height=sPx) + self.incNovel.setChecked( + self.optState.getBool("GuiSessionLog", "incNovel", True) + ) + self.incNovel.clicked.connect(self._updateListBox) + + self.incNotes = QSwitch(width=2*sPx, height=sPx) + self.incNotes.setChecked( + self.optState.getBool("GuiSessionLog", "incNotes", True) + ) + self.incNotes.clicked.connect(self._updateListBox) + + self.hideZeros = QSwitch(width=2*sPx, height=sPx) self.hideZeros.setChecked( - self.optState.getBool("GuiSession", "hideZeros", True) + self.optState.getBool("GuiSessionLog", "hideZeros", True) ) - self.hideZeros.stateChanged.connect(self._doHideZeros) + self.hideZeros.clicked.connect(self._updateListBox) - self.hideNegative = QCheckBox("Hide negative word count", self) + self.hideNegative = QSwitch(width=2*sPx, height=sPx) self.hideNegative.setChecked( - self.optState.getBool("GuiSession", "hideNegative", False) + self.optState.getBool("GuiSessionLog", "hideNegative", False) ) - self.hideNegative.stateChanged.connect(self._doHideNegative) + self.hideNegative.clicked.connect(self._updateListBox) - self.filterBoxForm.addWidget(self.hideZeros, 0, 0) - self.filterBoxForm.addWidget(self.hideNegative, 1, 0) + self.groupByDay = QSwitch(width=2*sPx, height=sPx) + self.groupByDay.setChecked( + self.optState.getBool("GuiSessionLog", "groupByDay", False) + ) + self.groupByDay.clicked.connect(self._updateListBox) + + self.filterForm.addWidget(QLabel("Count novel files"), 0, 0) + self.filterForm.addWidget(QLabel("Count note files"), 1, 0) + self.filterForm.addWidget(QLabel("Hide zero word count"), 2, 0) + self.filterForm.addWidget(QLabel("Hide negative word count"), 3, 0) + self.filterForm.addWidget(QLabel("Group entries by day"), 4, 0) + self.filterForm.addWidget(self.incNovel, 0, 1) + self.filterForm.addWidget(self.incNotes, 1, 1) + self.filterForm.addWidget(self.hideZeros, 2, 1) + self.filterForm.addWidget(self.hideNegative, 3, 1) + self.filterForm.addWidget(self.groupByDay, 4, 1) + self.filterForm.setRowStretch(5, 1) # Buttons self.buttonBox = QDialogButtonBox(QDialogButtonBox.Close) self.buttonBox.rejected.connect(self._doClose) + self.btnSave = self.buttonBox.addButton("Save As", QDialogButtonBox.ActionRole) + self.saveMenu = QMenu(self) + self.btnSave.setMenu(self.saveMenu) + + self.saveJSON = QAction("JSON Data File (.json)", self) + self.saveJSON.triggered.connect(lambda: self._saveData(self.FMT_JSON)) + self.saveMenu.addAction(self.saveJSON) + + self.saveCSV = QAction("CSV Data File (.csv)", self) + self.saveCSV.triggered.connect(lambda: self._saveData(self.FMT_CSV)) + self.saveMenu.addAction(self.saveCSV) + # Assemble + self.outerBox = QGridLayout() self.outerBox.addWidget(self.listBox, 0, 0, 1, 2) self.outerBox.addWidget(self.infoBox, 1, 0) self.outerBox.addWidget(self.filterBox, 1, 1) self.outerBox.addWidget(self.buttonBox, 2, 0, 1, 2) + self.outerBox.setRowStretch(0, 1) self.setLayout(self.outerBox) - self.show() + logger.debug("GuiSessionLog initialisation complete") - logger.debug("SessionLogView initialisation complete") - - self._loadSessionLog() + qApp.processEvents() + self._loadLogFile() + self._updateListBox() return - def _loadSessionLog(self): + ## + # Slots + ## - logFile = path.join(self.theProject.projMeta, nwFiles.SESS_INFO) - if not path.isfile(logFile): - logger.warning("No session log file found for this project.") - return False - - self.listBox.clear() - - self.timeFilter = 0.0 - self.timeTotal = 0.0 + def _doClose(self): + """Save the state of the window, clear cache, end close. + """ + self.logData = [] + winWidth = self.mainConf.rpxInt(self.width()) + winHeight = self.mainConf.rpxInt(self.height()) + widthCol0 = self.mainConf.rpxInt(self.listBox.columnWidth(0)) + widthCol1 = self.mainConf.rpxInt(self.listBox.columnWidth(1)) + widthCol2 = self.mainConf.rpxInt(self.listBox.columnWidth(2)) + sortCol = self.listBox.sortColumn() + sortOrder = self.listBox.header().sortIndicatorOrder() + incNovel = self.incNovel.isChecked() + incNotes = self.incNotes.isChecked() hideZeros = self.hideZeros.isChecked() hideNegative = self.hideNegative.isChecked() + groupByDay = self.groupByDay.isChecked() + + self.optState.setValue("GuiSessionLog", "winWidth", winWidth) + self.optState.setValue("GuiSessionLog", "winHeight", winHeight) + self.optState.setValue("GuiSessionLog", "widthCol0", widthCol0) + self.optState.setValue("GuiSessionLog", "widthCol1", widthCol1) + self.optState.setValue("GuiSessionLog", "widthCol2", widthCol2) + self.optState.setValue("GuiSessionLog", "sortCol", sortCol) + self.optState.setValue("GuiSessionLog", "sortOrder", sortOrder) + self.optState.setValue("GuiSessionLog", "incNovel", incNovel) + self.optState.setValue("GuiSessionLog", "incNotes", incNotes) + self.optState.setValue("GuiSessionLog", "hideZeros", hideZeros) + self.optState.setValue("GuiSessionLog", "hideNegative", hideNegative) + self.optState.setValue("GuiSessionLog", "groupByDay", groupByDay) + + self.optState.saveSettings() + self.close() + + return + + def _saveData(self, dataFmt): + """Save the content of the list box to a file. + """ + fileExt = "" + textFmt = "" + + if dataFmt == self.FMT_JSON: + fileExt = "json" + textFmt = "JSON Data File" + + elif dataFmt == self.FMT_CSV: + fileExt = "csv" + textFmt = "CSV Data File" + + else: + return False + + # Generate the file name + if fileExt: + fileName = "sessionStats.%s" % fileExt + saveDir = self.mainConf.lastPath + savePath = path.join(saveDir, fileName) + if not path.isdir(saveDir): + saveDir = self.mainConf.homePath + + if self.mainConf.showGUI: + dlgOpt = QFileDialog.Options() + dlgOpt |= QFileDialog.DontUseNativeDialog + saveTo = QFileDialog.getSaveFileName( + self, "Save Document As", savePath, options=dlgOpt + ) + if saveTo[0]: + savePath = saveTo[0] + else: + return False + + self.mainConf.setLastPath(savePath) + + else: + return False + + # Do the actual writing + wSuccess = False + errMsg = "" - logger.debug("Loading session log file") try: - with open(logFile,mode="r",encoding="utf8") as inFile: + with open(savePath, mode="w", encoding="utf8") as outFile: + if dataFmt == self.FMT_JSON: + jsonData = [] + for _, sD, tT, wD, wA, wB in self.filterData: + jsonData.append({ + "date": sD, + "length": tT, + "newWords": wD, + "novelWords": wA, + "noteWords": wB, + }) + outFile.write(json.dumps(jsonData, indent=2)) + wSuccess = True + + elif dataFmt == self.FMT_CSV: + outFile.write( + "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"\n" % ( + "Date", "Length (sec)", "Words Changed", "Novel Words", "Note Words" + ) + ) + for _, sD, tT, wD, wA, wB in self.filterData: + outFile.write( + "\"%s\",%d,%d,%d,%d\n" % (sD, tT, wD, wA, wB) + ) + wSuccess = True + + else: + errMsg = "Unknown format" + + except Exception as e: + errMsg = str(e) + + # Report to user + if self.mainConf.showGUI: + if wSuccess: + self.theParent.makeAlert( + "%s file successfully written to:
%s" % ( + textFmt, savePath + ), nwAlert.INFO + ) + else: + self.theParent.makeAlert( + "Failed to write %s file. %s" % ( + textFmt, errMsg + ), nwAlert.ERROR + ) + + return True + + ## + # Internal Functions + ## + + def _loadLogFile(self): + """Load the content of the log file into a buffer. + """ + logger.debug("Loading session log file") + + self.logData = [] + + ttNovel = 0 + ttNotes = 0 + ttTime = 0 + + try: + logFile = path.join(self.theProject.projMeta, nwFiles.SESS_STATS) + with open(logFile, mode="r", encoding="utf8") as inFile: for inLine in inFile: - inData = inLine.split() - if len(inData) != 8: + if inLine.startswith("#"): + if inLine.startswith("# Offset"): + self.wordOffset = int(inLine[9:].strip()) + logger.verbose( + "Initial word count when log was started is %d" % self.wordOffset + ) continue + + inData = inLine.split() + if len(inData) != 6: + continue + dStart = datetime.strptime( - "%s %s" % (inData[1],inData[2]), nwConst.tStampFmt + "%s %s" % (inData[0], inData[1]), nwConst.tStampFmt ) dEnd = datetime.strptime( - "%s %s" % (inData[4],inData[5]), nwConst.tStampFmt + "%s %s" % (inData[2], inData[3]), nwConst.tStampFmt ) - nWords = int(inData[7]) + tDiff = dEnd - dStart sDiff = tDiff.total_seconds() + ttTime += sDiff - self.timeTotal += sDiff - if abs(nWords) > 0: - self.timeFilter += sDiff + wcNovel = int(inData[4]) + wcNotes = int(inData[5]) + ttNovel = wcNovel + ttNotes = wcNotes - if hideZeros and nWords == 0: - continue - - if hideNegative and nWords < 0: - continue - - newItem = QTreeWidgetItem( - [str(dStart), self._formatTime(sDiff), str(nWords), ""] - ) - - newItem.setTextAlignment(1,Qt.AlignRight) - newItem.setTextAlignment(2,Qt.AlignRight) - - newItem.setFont(0,self.monoFont) - newItem.setFont(1,self.monoFont) - newItem.setFont(2,self.monoFont) - - self.listBox.addTopLevelItem(newItem) + self.logData.append((dStart, sDiff, wcNovel, wcNotes)) except Exception as e: self.theParent.makeAlert( @@ -213,47 +431,126 @@ class GuiSessionLogView(QDialog): ) return False - self.labelFilter.setText(self._formatTime(self.timeFilter)) - self.labelTotal.setText(self._formatTime(self.timeTotal)) + self.labelTotal.setText(self._formatTime(ttTime)) + self.novelWords.setText("{:n}".format(ttNovel)) + self.notesWords.setText("{:n}".format(ttNotes)) + self.totalWords.setText("{:n}".format(ttNovel + ttNotes)) return True - def _doClose(self): + def _updateListBox(self): + """Load/reload the content of the list box. + """ + self.listBox.clear() + self.timeFilter = 0.0 - widthCol0 = self.mainConf.rpxInt(self.listBox.columnWidth(0)) - widthCol1 = self.mainConf.rpxInt(self.listBox.columnWidth(1)) - widthCol2 = self.mainConf.rpxInt(self.listBox.columnWidth(2)) - sortCol = self.listBox.sortColumn() - sortOrder = self.listBox.header().sortIndicatorOrder() + incNovel = self.incNovel.isChecked() + incNotes = self.incNotes.isChecked() hideZeros = self.hideZeros.isChecked() hideNegative = self.hideNegative.isChecked() + groupByDay = self.groupByDay.isChecked() - self.optState.setValue("GuiSession", "widthCol0", widthCol0) - self.optState.setValue("GuiSession", "widthCol1", widthCol1) - self.optState.setValue("GuiSession", "widthCol2", widthCol2) - self.optState.setValue("GuiSession", "sortCol", sortCol) - self.optState.setValue("GuiSession", "sortOrder", sortOrder) - self.optState.setValue("GuiSession", "hideZeros", hideZeros) - self.optState.setValue("GuiSession", "hideNegative", hideNegative) + # Group the data + if groupByDay: + tempData = [] + sessDate = None + sessTime = 0 + lstNovel = 0 + lstNotes = 0 - self.optState.saveSettings() - self.close() + for n, (dStart, sDiff, wcNovel, wcNotes) in enumerate(self.logData): + if n == 0: + sessDate = dStart.date() + if sessDate != dStart.date(): + tempData.append((sessDate, sessTime, lstNovel, lstNotes)) + sessDate = dStart.date() + sessTime = sDiff + lstNovel = wcNovel + lstNotes = wcNotes + else: + sessTime += sDiff + lstNovel = wcNovel + lstNotes = wcNotes - return + if sessDate is not None: + tempData.append((sessDate, sessTime, lstNovel, lstNotes)) - def _doHideZeros(self, newState): - self._loadSessionLog() - return + else: + tempData = self.logData - def _doHideNegative(self, newState): - self._loadSessionLog() - return + # Calculate Word Diff + self.filterData = [] + pcTotal = 0 + listMax = 0 + isFirst = True + for dStart, sDiff, wcNovel, wcNotes in tempData: + + wcTotal = 0 + if incNovel: + wcTotal += wcNovel + if incNotes: + wcTotal += wcNotes + + dwTotal = wcTotal - pcTotal + if hideZeros and dwTotal == 0: + continue + if hideNegative and dwTotal < 0: + continue + + if isFirst: + # Subtract the offset from the first list entry + dwTotal -= self.wordOffset + dwTotal = max(dwTotal, 1) # Don't go zero or negative + isFirst = False + + if groupByDay: + sStart = dStart.strftime(nwConst.dStampFmt) + else: + sStart = dStart.strftime(nwConst.tStampFmt) + + self.filterData.append((dStart, sStart, sDiff, dwTotal, wcNovel, wcNotes)) + listMax = max(listMax, dwTotal) + pcTotal = wcTotal + + # Populate the list + for _, sStart, sDiff, nWords, _, _ in self.filterData: + + newItem = QTreeWidgetItem() + newItem.setText(self.C_TIME, sStart) + newItem.setText(self.C_LENGTH, self._formatTime(sDiff)) + newItem.setText(self.C_COUNT, "{:n}".format(nWords)) + + if nWords > 0 and listMax > 0: + theBar = self.barImage.scaled( + int(200*nWords/listMax), + self.barHeight, + Qt.IgnoreAspectRatio, + Qt.FastTransformation + ) + newItem.setData(self.C_BAR, Qt.DecorationRole, theBar) + + newItem.setTextAlignment(self.C_LENGTH, Qt.AlignRight) + newItem.setTextAlignment(self.C_COUNT, Qt.AlignRight) + newItem.setTextAlignment(self.C_BAR, Qt.AlignLeft | Qt.AlignVCenter) + + newItem.setFont(self.C_TIME, self.monoFont) + newItem.setFont(self.C_LENGTH, self.monoFont) + newItem.setFont(self.C_COUNT, self.monoFont) + + self.listBox.addTopLevelItem(newItem) + self.timeFilter += sDiff + + self.labelFilter.setText(self._formatTime(self.timeFilter)) + + return True def _formatTime(self, tS): + """Format the time spent in 00:00:00 format. + """ tM = int(tS/60) tH = int(tM/60) tM = tM - tH*60 tS = tS - tM*60 - tH*3600 return "%02d:%02d:%02d" % (tH,tM,tS) -# END Class GuiSessionLogView +# END Class GuiSessionLog diff --git a/nw/gui/theme.py b/nw/gui/theme.py index 7df0c560..0265df62 100644 --- a/nw/gui/theme.py +++ b/nw/gui/theme.py @@ -139,6 +139,8 @@ class GuiTheme: logger.verbose("GUI Scale: %.2f" % self.guiScale) self.guiFont = qApp.font() + self.guiFontFixed = QFontDatabase.systemFont(QFontDatabase.FixedFont) + qMetric = QFontMetrics(self.guiFont) self.fontPointSize = self.guiFont.pointSizeF() self.fontPixelSize = int(round(qMetric.height())) diff --git a/nw/guimain.py b/nw/guimain.py index 6a7cbd9c..35abab3b 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -43,7 +43,7 @@ from nw.gui import ( GuiBuildNovel, GuiDocEditor, GuiDocMerge, GuiDocSplit, GuiDocViewDetails, GuiDocViewer, GuiItemDetails, GuiItemEditor, GuiMainMenu, GuiMainStatus, GuiOutline, GuiOutlineDetails, GuiPreferences, GuiProjectLoad, GuiTheme, - GuiProjectSettings, GuiProjectTree, GuiSessionLogView + GuiProjectSettings, GuiProjectTree, GuiSessionLog ) from nw.core import NWProject, NWDoc, NWIndex from nw.constants import nwFiles, nwItemType, nwAlert @@ -419,7 +419,7 @@ class GuiMain(QMainWindow): # Restore previously open documents, if any if self.theProject.lastEdited is not None: - self.openDocument(self.theProject.lastEdited) + self.openDocument(self.theProject.lastEdited, doScroll=True) if self.theProject.lastViewed is not None: self.viewDocument(self.theProject.lastViewed) @@ -463,7 +463,7 @@ class GuiMain(QMainWindow): self.docEditor.clearEditor() return True - def openDocument(self, tHandle, tLine=None, changeFocus=True): + def openDocument(self, tHandle, tLine=None, changeFocus=True, doScroll=False): """Open a specific document, optionally at a given line. """ if self.hasProject: @@ -473,7 +473,7 @@ class GuiMain(QMainWindow): if changeFocus: self.docEditor.setFocus() self.theProject.setLastEdited(tHandle) - self.treeView.setSelectedHandle(tHandle) + self.treeView.setSelectedHandle(tHandle, doScroll=doScroll) else: return False return True @@ -501,10 +501,10 @@ class GuiMain(QMainWindow): break if nHandle is not None: - self.openDocument(nHandle, tLine=0) + self.openDocument(nHandle, tLine=0, doScroll=True) return True elif wrapAround: - self.openDocument(fHandle, tLine=0) + self.openDocument(fHandle, tLine=0, doScroll=True) return False return False @@ -520,20 +520,30 @@ class GuiMain(QMainWindow): """Load a document for viewing in the view panel. """ if tHandle is None: - tHandle = self.treeView.getSelectedHandle() - if tHandle is None: - logger.debug("No document selected, trying editor document") - tHandle = self.docEditor.theHandle - if tHandle is None: - logger.debug("No document selected, trying last viewed") - tHandle = self.theProject.lastViewed - if tHandle is None: - logger.debug("No document selected, giving up") - return False + logger.debug("Viewing document, but no handle provided") + + if self.docEditor.hasFocus(): + logger.verbose("Trying editor document") + tHandle = self.docEditor.theHandle + + if tHandle is not None: + self.saveDocument() + else: + logger.verbose("Trying selected document") + tHandle = self.treeView.getSelectedHandle() + + if tHandle is None: + logger.verbose("Trying last viewed document") + tHandle = self.theProject.lastViewed + + if tHandle is None: + logger.verbose("No document to view, giving up") + return False # Make sure main tab is in Editor view self.tabWidget.setCurrentWidget(self.splitDocs) + logger.debug("Viewing document with handle %s" % tHandle) if self.docViewer.loadText(tHandle): if not self.splitView.isVisible(): bPos = self.splitMain.sizes() @@ -648,7 +658,7 @@ class GuiMain(QMainWindow): nwItem = self.theProject.projTree[tHandle] if nwItem.itemType == nwItemType.FILE: logger.verbose("Requested item %s is a file" % tHandle) - self.openDocument(tHandle) + self.openDocument(tHandle, doScroll=False) else: logger.verbose("Requested item %s is not a file" % tHandle) @@ -802,7 +812,7 @@ class GuiMain(QMainWindow): """Open the session log dialog. """ if self.hasProject: - dlgTLine = GuiSessionLogView(self, self.theProject) + dlgTLine = GuiSessionLog(self, self.theProject) dlgTLine.exec_() return True @@ -968,12 +978,15 @@ class GuiMain(QMainWindow): """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.aExitNW) + + # Document self.addAction(self.mainMenu.aSaveDoc) self.addAction(self.mainMenu.aFileDetails) - self.addAction(self.mainMenu.aZenMode) - self.addAction(self.mainMenu.aFullScreen) + + # Edit self.addAction(self.mainMenu.aEditUndo) self.addAction(self.mainMenu.aEditRedo) self.addAction(self.mainMenu.aEditCut) @@ -981,6 +994,17 @@ class GuiMain(QMainWindow): self.addAction(self.mainMenu.aEditPaste) self.addAction(self.mainMenu.aSelectAll) self.addAction(self.mainMenu.aSelectPar) + + # Insert + self.addAction(self.mainMenu.aInsENDash) + self.addAction(self.mainMenu.aInsEMDash) + self.addAction(self.mainMenu.aInsEllipsis) + self.addAction(self.mainMenu.aInsHardBreak) + self.addAction(self.mainMenu.aInsNBSpace) + self.addAction(self.mainMenu.aInsThinSpace) + self.addAction(self.mainMenu.aInsThinNBSpace) + + # Format self.addAction(self.mainMenu.aFmtItalic) self.addAction(self.mainMenu.aFmtBold) self.addAction(self.mainMenu.aFmtBoldIt) @@ -992,10 +1016,19 @@ class GuiMain(QMainWindow): self.addAction(self.mainMenu.aFmtHead4) self.addAction(self.mainMenu.aFmtComment) self.addAction(self.mainMenu.aFmtNoFormat) + + # View + self.addAction(self.mainMenu.aZenMode) + self.addAction(self.mainMenu.aFullScreen) + + # Tools self.addAction(self.mainMenu.aSpellCheck) self.addAction(self.mainMenu.aReRunSpell) self.addAction(self.mainMenu.aPreferences) + + # Help self.addAction(self.mainMenu.aHelp) + return True def _setWindowTitle(self, projName=None): @@ -1070,7 +1103,7 @@ class GuiMain(QMainWindow): if nwItem is not None: if nwItem.itemType == nwItemType.FILE: logger.verbose("Requested item %s is a file" % tHandle) - self.openDocument(tHandle, changeFocus=False) + self.openDocument(tHandle, changeFocus=False, doScroll=False) else: logger.verbose("Requested item %s is a folder" % tHandle) return @@ -1086,7 +1119,7 @@ class GuiMain(QMainWindow): if nwItem is not None: if nwItem.itemType == nwItemType.FILE: logger.verbose("Requested item %s is a file" % tHandle) - self.openDocument(tHandle, changeFocus=False) + self.openDocument(tHandle, changeFocus=False, doScroll=False) else: logger.verbose("Requested item %s is a folder" % tHandle) return diff --git a/sample/content/636b6aa9b697b.nwd b/sample/content/636b6aa9b697b.nwd index 4916b753..0ab145f1 100644 --- a/sample/content/636b6aa9b697b.nwd +++ b/sample/content/636b6aa9b697b.nwd @@ -13,10 +13,12 @@ In addition, the editor supports automatic formatting of “quotes”, both doub If you have the need for it, you can also add text that can be automatically replaced by other text when you generate a preview or export the project. Now, let’s auto-replace this A with , and this C with . While is just . Press Ctrl+R to see what this looks like in the view pane. -The editor also supports non breaking spaces, and the spell checker accepts long dashes—like this—as valid word separators. +The editor also supports non breaking spaces, and the spell checker accepts long dashes—like this—as valid word separators. Regular dashes are also supported – and can be automatically inserted when typing two hyphens. + +Thin spaces and thin non-breaking spaces are also supported from the Insert menu, and can be used to separate numbers from their units, like: 25 kg. #### Some Section Here If you need to split a scene file up into further pieces, you can do so with the level four heading, like above. This is referred to as a section. -Both scene and section titles can be left out of the final exported document. The formatting of titles can be selected from the export dialog. +Both scene and section titles can be left out of the final exported document. The formatting of titles can be selected from the Build Novel Project dialog. You can also have them replaced with scene separators like “* * *”. diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index aeba369e..c5a194a5 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,25 +1,27 @@ - + Sample Project Sample Project Jane Smith Jay Doh - 332 - 56 - 10692 + 593 + 103 + 24563 False True True 636b6aa9b697b - 636b6aa9b697b - 927 + b3e74dbc1f584 + 982 + 606 + 376 - B - E - D + B + E + D %title% @@ -114,10 +116,10 @@ 1st Draft True SCENE - 1240 - 223 - 7 - 403 + 1564 + 278 + 8 + 1633 Another Scene diff --git a/setup.py b/setup.py index b8246fd5..e93777ab 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ with open("README.md", "r") as inFile: setuptools.setup( name = "novelWriter", - version = "0.9rc1", + version = "0.10.0", author = "Veronica Berglyd Olsen", author_email = "code@vkbo.net", description = "A markdown-like document editor for writing novels", diff --git a/tests/reference/gui/0_nwProject.nwx b/tests/reference/gui/0_nwProject.nwx index 63535bfa..d1a794e7 100644 --- a/tests/reference/gui/0_nwProject.nwx +++ b/tests/reference/gui/0_nwProject.nwx @@ -14,6 +14,8 @@ None None 0 + 0 + 0 %title% diff --git a/tests/reference/gui/1_nwProject.nwx b/tests/reference/gui/1_nwProject.nwx index 17180919..e8a705b1 100644 --- a/tests/reference/gui/1_nwProject.nwx +++ b/tests/reference/gui/1_nwProject.nwx @@ -14,6 +14,8 @@ 31489056e0916 None 86 + 59 + 27 %title% diff --git a/tests/reference/gui/2_nwProject.nwx b/tests/reference/gui/2_nwProject.nwx index 316e30c1..0815f9ad 100644 --- a/tests/reference/gui/2_nwProject.nwx +++ b/tests/reference/gui/2_nwProject.nwx @@ -1,5 +1,5 @@ - + Project Name Project Title @@ -16,8 +16,10 @@ None None 0 + 0 + 0 - With This Stuff + With This Stuff %title% diff --git a/tests/reference/gui/3_nwProject.nwx b/tests/reference/gui/3_nwProject.nwx index 7ae01352..8eeb4dca 100644 --- a/tests/reference/gui/3_nwProject.nwx +++ b/tests/reference/gui/3_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project @@ -11,9 +11,11 @@ True False True - None + 31489056e0916 None - 0 + 59 + 59 + 0 %title% @@ -57,9 +59,9 @@ Note False PAGE - 0 - 0 - 0 + 331 + 59 + 2 0 diff --git a/tests/reference/proj/1_nwProject.nwx b/tests/reference/proj/1_nwProject.nwx index 8516baae..12697dbe 100644 --- a/tests/reference/proj/1_nwProject.nwx +++ b/tests/reference/proj/1_nwProject.nwx @@ -14,6 +14,8 @@ None None 0 + 0 + 0 %title% diff --git a/tests/reference/proj/2_nwProject.nwx b/tests/reference/proj/2_nwProject.nwx index da82f956..ed6bbbe4 100644 --- a/tests/reference/proj/2_nwProject.nwx +++ b/tests/reference/proj/2_nwProject.nwx @@ -14,6 +14,8 @@ None None 0 + 0 + 0 %title% diff --git a/tests/reference/proj/3_nwProject.nwx b/tests/reference/proj/3_nwProject.nwx index 8adfadb8..58bef95d 100644 --- a/tests/reference/proj/3_nwProject.nwx +++ b/tests/reference/proj/3_nwProject.nwx @@ -14,6 +14,8 @@ None None 0 + 0 + 0 %title% diff --git a/tests/test_gui.py b/tests/test_gui.py index cd375cd8..d52c4250 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -2,7 +2,7 @@ """novelWriter Main GUI Class Tester """ -import nw, pytest, sys +import nw, pytest, sys, json from nwtools import * from os import path, unlink @@ -10,7 +10,7 @@ from PyQt5.QtCore import Qt from nw.gui import ( GuiProjectSettings, GuiItemEditor, GuiAbout, GuiBuildNovel, - GuiDocMerge, GuiDocSplit + GuiDocMerge, GuiDocSplit, GuiSessionLog ) from nw.constants import * @@ -127,6 +127,11 @@ def testMainWindows(qtbot, nwTempGUI, nwRef, nwTemp): nwGUI.treeView.newTreeItem(nwItemType.FILE, None) assert nwGUI.openSelectedItem() + # Add Some Text + nwGUI.docEditor.replaceText("Hello World!") + assert nwGUI.docEditor.getText() == "Hello World!" + nwGUI.docEditor.replaceText("") + # Type something into the document nwGUI.setFocus(2) for c in "# Main Location": @@ -273,6 +278,7 @@ def testProjectEditor(qtbot, nwTempGUI, nwRef, nwTemp): nwGUI.mainConf.backupPath = nwTempGUI projEdit = GuiProjectSettings(nwGUI, nwGUI.theProject) + projEdit.show() qtbot.addWidget(projEdit) projEdit.tabMain.editName.setText("") @@ -358,6 +364,7 @@ def testItemEditor(qtbot, nwTempGUI, nwRef, nwTemp): # Create new, save, open project nwGUI.theProject.projTree.setSeed(42) assert nwGUI.newProject(nwTempGUI, True) + assert nwGUI.openDocument("31489056e0916") itemEdit = GuiItemEditor(nwGUI, nwGUI.theProject, "31489056e0916") qtbot.addWidget(itemEdit) @@ -374,7 +381,6 @@ def testItemEditor(qtbot, nwTempGUI, nwRef, nwTemp): itemEdit.editExport.setChecked(False) assert not itemEdit.editExport.isChecked() - itemEdit._doSave() itemEdit = GuiItemEditor(nwGUI, nwGUI.theProject, "31489056e0916") @@ -382,9 +388,16 @@ def testItemEditor(qtbot, nwTempGUI, nwRef, nwTemp): assert itemEdit.editName.text() == "Just a Page" assert itemEdit.editStatus.currentData() == "Note" assert itemEdit.editLayout.currentData() == nwItemLayout.PAGE - itemEdit._doClose() + # Check that the header is updated + nwGUI.docEditor.updateDocTitle("31489056e0916") + assert nwGUI.docEditor.docHeader.theTitle.text() == "Novel › New Chapter › Just a Page" + assert not nwGUI.docEditor.setCursorLine("where?") + assert nwGUI.docEditor.setCursorLine(2) + qtbot.wait(stepDelay) + assert nwGUI.docEditor.getCursorPosition() == 9 + qtbot.wait(stepDelay) assert nwGUI.saveProject() qtbot.wait(stepDelay) @@ -396,6 +409,115 @@ def testItemEditor(qtbot, nwTempGUI, nwRef, nwTemp): nwGUI.closeMain() # qtbot.stopForInteraction() +@pytest.mark.gui +def testSessionLogExport(qtbot, nwTempGUI, nwRef, nwTemp): + nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI, "--data=%s" % nwTemp]) + qtbot.addWidget(nwGUI) + nwGUI.show() + qtbot.waitForWindowShown(nwGUI) + qtbot.wait(stepDelay) + + assert nwGUI.openProject(nwTempGUI) + qtbot.wait(stepDelay) + + nwGUI.mainConf.lastPath = nwTempGUI + sessLog = GuiSessionLog(nwGUI, nwGUI.theProject) + sessLog.show() + qtbot.wait(stepDelay) + + assert sessLog._saveData(sessLog.FMT_CSV) + qtbot.wait(stepDelay) + assert sessLog._saveData(sessLog.FMT_JSON) + qtbot.wait(stepDelay) + + jsonStats = path.join(nwTempGUI, "sessionStats.json") + with open(jsonStats, mode="r", encoding="utf-8") as inFile: + jsonData = json.loads(inFile.read()) + + assert len(jsonData) == 3 + assert jsonData[0]["length"] > 0 + assert jsonData[0]["newWords"] == 86 + assert jsonData[0]["novelWords"] == 59 + assert jsonData[0]["noteWords"] == 27 + + # No Novel Files + qtbot.mouseClick(sessLog.incNovel, Qt.LeftButton) + qtbot.wait(stepDelay) + assert sessLog._saveData(sessLog.FMT_JSON) + qtbot.wait(stepDelay) + + jsonStats = path.join(nwTempGUI, "sessionStats.json") + with open(jsonStats, mode="r", encoding="utf-8") as inFile: + jsonData = json.loads(inFile.read()) + + assert len(jsonData) == 2 + assert jsonData[0]["length"] > 0 + assert jsonData[0]["newWords"] == 27 + assert jsonData[0]["novelWords"] == 59 + assert jsonData[0]["noteWords"] == 27 + + # No Note Files + qtbot.mouseClick(sessLog.incNovel, Qt.LeftButton) + qtbot.mouseClick(sessLog.incNotes, Qt.LeftButton) + qtbot.wait(stepDelay) + assert sessLog._saveData(sessLog.FMT_JSON) + qtbot.wait(stepDelay) + + jsonStats = path.join(nwTempGUI, "sessionStats.json") + with open(jsonStats, mode="r", encoding="utf-8") as inFile: + jsonData = json.loads(inFile.read()) + + assert len(jsonData) == 3 + assert jsonData[0]["length"] > 0 + assert jsonData[0]["newWords"] == 59 + assert jsonData[0]["novelWords"] == 59 + assert jsonData[0]["noteWords"] == 27 + + # No Negative Entries + qtbot.mouseClick(sessLog.incNotes, Qt.LeftButton) + qtbot.mouseClick(sessLog.hideNegative, Qt.LeftButton) + qtbot.wait(stepDelay) + assert sessLog._saveData(sessLog.FMT_JSON) + qtbot.wait(stepDelay) + + jsonStats = path.join(nwTempGUI, "sessionStats.json") + with open(jsonStats, mode="r", encoding="utf-8") as inFile: + jsonData = json.loads(inFile.read()) + + assert len(jsonData) == 1 + + # Un-hide Zero Entries + qtbot.mouseClick(sessLog.hideNegative, Qt.LeftButton) + qtbot.mouseClick(sessLog.hideZeros, Qt.LeftButton) + qtbot.wait(stepDelay) + assert sessLog._saveData(sessLog.FMT_JSON) + qtbot.wait(stepDelay) + + jsonStats = path.join(nwTempGUI, "sessionStats.json") + with open(jsonStats, mode="r", encoding="utf-8") as inFile: + jsonData = json.loads(inFile.read()) + + assert len(jsonData) == 4 + + # Group by Day + qtbot.mouseClick(sessLog.groupByDay, Qt.LeftButton) + qtbot.wait(stepDelay) + assert sessLog._saveData(sessLog.FMT_JSON) + qtbot.wait(stepDelay) + + jsonStats = path.join(nwTempGUI, "sessionStats.json") + with open(jsonStats, mode="r", encoding="utf-8") as inFile: + jsonData = json.loads(inFile.read()) + + # Check against both 1 and 2 as this can be 2 if test was started just before midnight. + # A failed test should in any case produce a 4 + assert len(jsonData) in (1, 2) + + # qtbot.stopForInteraction() + + sessLog._doClose() + nwGUI.closeMain() + @pytest.mark.gui def testAboutBox(qtbot, nwTempGUI, nwRef, nwTemp): nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI, "--data=%s" % nwTemp]) @@ -610,3 +732,136 @@ def testSplitTool(qtbot, nwTempGUI, nwLipsum, nwRef, nwTemp): # qtbot.stopForInteraction() nwGUI.closeMain() + +@pytest.mark.gui +def testDocAction(qtbot, nwTempGUI, nwLipsum, nwRef, nwTemp): + + nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI, "--data=%s" % nwTemp]) + qtbot.addWidget(nwGUI) + nwGUI.show() + qtbot.waitForWindowShown(nwGUI) + qtbot.wait(stepDelay) + + nwGUI.theProject.projTree.setSeed(42) + assert nwGUI.openProject(nwLipsum) + qtbot.wait(stepDelay) + + # CUT = 3 + # COPY = 4 + # PASTE = 5 + # SEL_ALL = 12 + # SEL_PARA = 13 + # FIND = 14 + # REPLACE = 15 + # GO_NEXT = 16 + # GO_PREV = 17 + # REPL_NEXT = 18 + + # Split By Chapter + assert nwGUI.openDocument("4c4f28287af27") + assert nwGUI.docEditor.setCursorPosition(30) + + cleanText = nwGUI.docEditor.getText()[27:74] + + # Bold + assert nwGUI.passDocumentAction(nwDocAction.BOLD) + assert nwGUI.docEditor.getText()[27:78] == "**Pellentesque** nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.BOLD) + assert nwGUI.docEditor.getText()[27:74] == cleanText + qtbot.wait(stepDelay) + + # Italic + assert nwGUI.passDocumentAction(nwDocAction.ITALIC) + assert nwGUI.docEditor.getText()[27:76] == "*Pellentesque* nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.ITALIC) + assert nwGUI.docEditor.getText()[27:74] == cleanText + qtbot.wait(stepDelay) + + # Bold-Italic + assert nwGUI.passDocumentAction(nwDocAction.BOLDITALIC) + assert nwGUI.docEditor.getText()[27:80] == "***Pellentesque*** nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.BOLDITALIC) + assert nwGUI.docEditor.getText()[27:74] == cleanText + qtbot.wait(stepDelay) + + # Strikethrough + assert nwGUI.passDocumentAction(nwDocAction.STRIKE) + assert nwGUI.docEditor.getText()[27:78] == "~~Pellentesque~~ nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.STRIKE) + assert nwGUI.docEditor.getText()[27:74] == cleanText + qtbot.wait(stepDelay) + + # Should get us back to plain + assert nwGUI.passDocumentAction(nwDocAction.BOLD) + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.ITALIC) + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.ITALIC) + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.BOLD) + assert nwGUI.docEditor.getText()[27:74] == cleanText + qtbot.wait(stepDelay) + + # Equivalent of the above + assert nwGUI.passDocumentAction(nwDocAction.BOLDITALIC) + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.ITALIC) + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.BOLD) + assert nwGUI.docEditor.getText()[27:74] == cleanText + qtbot.wait(stepDelay) + + # Double Quotes + assert nwGUI.passDocumentAction(nwDocAction.D_QUOTE) + assert nwGUI.docEditor.getText()[27:76] == "“Pellentesque” nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.UNDO) + assert nwGUI.docEditor.getText()[27:74] == cleanText + qtbot.wait(stepDelay) + + # Single Quotes + assert nwGUI.passDocumentAction(nwDocAction.S_QUOTE) + assert nwGUI.docEditor.getText()[27:76] == "‘Pellentesque’ nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.UNDO) + assert nwGUI.docEditor.getText()[27:74] == cleanText + qtbot.wait(stepDelay) + + # Block Formats + assert nwGUI.docEditor.setCursorPosition(30) + assert nwGUI.passDocumentAction(nwDocAction.BLOCK_H1) + assert nwGUI.docEditor.getText()[27:76] == "# Pellentesque nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.BLOCK_H2) + assert nwGUI.docEditor.getText()[27:77] == "## Pellentesque nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.BLOCK_H3) + assert nwGUI.docEditor.getText()[27:78] == "### Pellentesque nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.BLOCK_H4) + assert nwGUI.docEditor.getText()[27:79] == "#### Pellentesque nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.BLOCK_TXT) + assert nwGUI.docEditor.getText()[27:74] == cleanText + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.BLOCK_COM) + assert nwGUI.docEditor.getText()[27:76] == "% Pellentesque nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.BLOCK_TXT) + assert nwGUI.docEditor.getText()[27:74] == cleanText + qtbot.wait(stepDelay) + + # Undo/Redo + assert nwGUI.passDocumentAction(nwDocAction.UNDO) + assert nwGUI.docEditor.getText()[27:76] == "% Pellentesque nec erat ut nulla posuere commodo." + qtbot.wait(stepDelay) + assert nwGUI.passDocumentAction(nwDocAction.REDO) + assert nwGUI.docEditor.getText()[27:74] == cleanText + qtbot.wait(stepDelay) + + # qtbot.stopForInteraction() + nwGUI.closeMain()