Files
novelWriter/nw/gui/outline.py
T
Veronica K. B. Olsen eb66b879c5 Imports cleanup
2020-09-26 13:02:51 +02:00

508 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""novelWriter GUI Project Outline
novelWriter GUI Project Outline
===================================
Class holding the project outline view
File History:
Created: 2019-11-16 [0.4.1]
This file is a part of novelWriter
Copyright 20182020, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import nw
import logging
from time import time
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtWidgets import (
QTreeWidget, QTreeWidgetItem, QMenu, QAction, QAbstractItemView
)
from nw.constants import nwKeyWords, nwLabels, nwOutline
logger = logging.getLogger(__name__)
class GuiOutline(QTreeWidget):
DEF_WIDTH = {
nwOutline.TITLE : 200,
nwOutline.LEVEL : 40,
nwOutline.LABEL : 150,
nwOutline.LINE : 40,
nwOutline.CCOUNT : 50,
nwOutline.WCOUNT : 50,
nwOutline.PCOUNT : 50,
nwOutline.POV : 100,
nwOutline.CHAR : 100,
nwOutline.PLOT : 100,
nwOutline.TIME : 100,
nwOutline.WORLD : 100,
nwOutline.OBJECT : 100,
nwOutline.ENTITY : 100,
nwOutline.CUSTOM : 100,
nwOutline.SYNOP : 200,
}
DEF_HIDDEN = {
nwOutline.TITLE : False,
nwOutline.LEVEL : True,
nwOutline.LABEL : False,
nwOutline.LINE : True,
nwOutline.CCOUNT : True,
nwOutline.WCOUNT : False,
nwOutline.PCOUNT : False,
nwOutline.POV : False,
nwOutline.CHAR : False,
nwOutline.PLOT : False,
nwOutline.TIME : True,
nwOutline.WORLD : False,
nwOutline.OBJECT : True,
nwOutline.ENTITY : True,
nwOutline.CUSTOM : True,
nwOutline.SYNOP : False,
}
def __init__(self, theParent):
QTreeWidget.__init__(self, theParent)
logger.debug("Initialising GuiOutline ...")
self.mainConf = nw.CONFIG
self.theParent = theParent
self.theProject = theParent.theProject
self.theTheme = theParent.theTheme
self.theIndex = theParent.theIndex
self.optState = theParent.theProject.optState
self.headerMenu = GuiOutlineHeaderMenu(self)
self.firstView = True
self.lastBuild = 0
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setSelectionMode(QAbstractItemView.SingleSelection)
self.setExpandsOnDoubleClick(False)
self.setDragEnabled(False)
self.itemDoubleClicked.connect(self._treeDoubleClick)
self.itemSelectionChanged.connect(self._itemSelected)
iPx = self.theTheme.baseIconSize
self.setIconSize(QSize(iPx, iPx))
self.setIndentation(iPx)
self.treeHead = self.header()
self.treeHead.setContextMenuPolicy(Qt.CustomContextMenu)
self.treeHead.customContextMenuRequested.connect(self._headerRightClick)
self.treeHead.sectionMoved.connect(self._columnMoved)
self.treeMap = {}
self.treeOrder = []
self.colWidth = {}
self.colHidden = {}
self.colIndex = {}
self.treeNCols = 0
self.clearOutline()
self.headerMenu.setHiddenState(self.colHidden)
logger.debug("GuiOutline initialisation complete")
return
def clearOutline(self):
"""Clear the tree and header and set the default values for the
columns arrays.
"""
self.clear()
self.setColumnCount(1)
self.setHeaderLabel(nwLabels.OUTLINE_COLS[nwOutline.TITLE])
self.treeOrder = []
self.colWidth = {}
self.colHidden = {}
self.colIndex = {}
self.treeNCols = 0
for i, hItem in enumerate(nwOutline):
self.treeOrder.append(hItem)
self.colWidth[hItem] = self.DEF_WIDTH[hItem]
self.colHidden[hItem] = self.DEF_HIDDEN[hItem]
self.treeNCols = len(self.treeOrder)
return
def refreshTree(self, overRide=False):
"""Called whenever the Outline tab is activated and controls
what data to load, and if necessary, force a rebuild of the
tree.
"""
# If it's the first time, we always build
if self.firstView or self.firstView and overRide:
self._loadHeaderState()
self._populateTree()
self.firstView = False
return
# If the novel index has changed since the tree was last built,
# we rebuild the tree from the updated index.
lastChange = self.theParent.theIndex.timeNovel
logger.verbose("Last outline build: %.3f" % self.lastBuild)
logger.verbose("Novel index change: %.3f" % lastChange)
doBuild = lastChange > self.lastBuild and self.theProject.autoOutline
if doBuild or overRide:
logger.debug("Rebuilding Project Outline")
self._populateTree()
return
def closeOutline(self):
"""Called before a project is closed.
"""
self._saveHeaderState()
self.clearOutline()
self.firstView = True
return
##
# Slots
##
def _treeDoubleClick(self, tItem, tCol):
"""Extract the handle and line number of the title double-
clicked, and send it to the main gui class for opening in the
document editor.
"""
tHandle = tItem.data(self.colIndex[nwOutline.TITLE], Qt.UserRole)
try:
tLine = int(tItem.text(self.colIndex[nwOutline.LINE]))
except Exception:
tLine = 1
logger.verbose("User selected entry with handle %s on line %s" % (tHandle, tLine))
self.theParent.openDocument(tHandle, tLine=tLine-1, doScroll=True)
return
def _itemSelected(self):
"""Extract the handle and line number of the currently selected
title, and send it to the details panel.
"""
selItems = self.selectedItems()
if selItems:
tHandle = selItems[0].data(self.colIndex[nwOutline.TITLE], Qt.UserRole)
sTitle = selItems[0].data(self.colIndex[nwOutline.LINE], Qt.UserRole)
self.theParent.projMeta.showItem(tHandle, sTitle)
return
def _headerRightClick(self, clickPos):
"""Show the header column menu.
"""
self.headerMenu.exec_(self.mapToGlobal(clickPos))
return
def _columnMoved(self, logIdx, oldVisualIdx, newVisualIdx):
"""Make sure the order array is up to date with the actual order
of the columns.
"""
self.treeOrder.insert(newVisualIdx, self.treeOrder.pop(oldVisualIdx))
self._saveHeaderState()
return
def _menuColumnToggled(self, isChecked, theItem):
"""Receive the changes to column visibility forwarded by the
header context menu.
"""
logger.verbose("User toggled Outline column '%s'" % theItem.name)
if theItem in self.colIndex:
self.setColumnHidden(self.colIndex[theItem], not isChecked)
self._saveHeaderState()
return
##
# Internal Functions
##
def _loadHeaderState(self):
"""Load the state of the main tree header, that is, column order
and column width.
"""
# Load whatever we saved last time, regardless of wether it
# contains the correct names or number of columns. The names
# must be valid though.
tempOrder = self.optState.getValue("GuiOutline", "headerOrder", [])
treeOrder = []
for hName in tempOrder:
try:
treeOrder.append(nwOutline[hName])
except Exception:
logger.warning("Ignored unknown outline column '%s'" % str(hName))
# Add columns that was not in the file to the treeOrder array.
for hItem in nwOutline:
if hItem not in treeOrder:
treeOrder.append(hItem)
# Check that we now have a complete list, and only if so, save
# the order loaded from file. Otherwise, we keep the default.
if len(treeOrder) == self.treeNCols:
self.treeOrder = treeOrder
else:
logger.error("Failed to extract outline column order from previous session")
logger.error("Column count doesn't match %d != %d" % (len(treeOrder), self.treeNCols))
# We load whatever column widths and hidden states we find in
# the file, and leave the rest in their default state.
tmpWidth = self.optState.getValue("GuiOutline", "columnWidth", {})
for hName in tmpWidth:
try:
self.colWidth[nwOutline[hName]] = self.mainConf.pxInt(tmpWidth[hName])
except Exception:
logger.warning("Ignored unknown outline column '%s'" % str(hName))
tmpHidden = self.optState.getValue("GuiOutline", "columnHidden", {})
for hName in tmpHidden:
try:
self.colHidden[nwOutline[hName]] = tmpHidden[hName]
except Exception:
logger.warning("Ignored unknown outline column '%s'" % str(hName))
self.headerMenu.setHiddenState(self.colHidden)
return
def _saveHeaderState(self):
"""Save the state of the main tree header, that is, column
order, column width and column hidden state. We don't want to
save the current width of hidden columns though. This preserves
the last known width in case they're unhidden again.
"""
# If we haven't built the tree, there is nothing to save.
if self.lastBuild == 0:
return
treeOrder = []
colWidth = {}
colHidden = {}
for hItem in nwOutline:
colWidth[hItem.name] = self.mainConf.rpxInt(self.colWidth[hItem])
colHidden[hItem.name] = self.colHidden[hItem]
for iCol in range(self.columnCount()):
hName = self.treeOrder[iCol].name
treeOrder.append(hName)
iLog = self.treeHead.logicalIndex(iCol)
logWidth = self.mainConf.rpxInt(self.columnWidth(iLog))
logHidden = self.isColumnHidden(iLog)
colHidden[hName] = logHidden
if not logHidden and logWidth > 0:
colWidth[hName] = logWidth
self.optState.setValue("GuiOutline", "headerOrder", treeOrder)
self.optState.setValue("GuiOutline", "columnWidth", colWidth)
self.optState.setValue("GuiOutline", "columnHidden", colHidden)
self.optState.saveSettings()
return
def _populateTree(self):
"""Build the tree based on the project index, and the header
based on the defined constants, default values and user selected
width, order and hidden state. All columns are populated, even
if they are hidden. This ensures that showing and hiding columns
is fast and doesn't require a rebuild of the tree.
"""
self.clear()
if self.firstView:
theLabels = []
for i, hItem in enumerate(self.treeOrder):
theLabels.append(nwLabels.OUTLINE_COLS[hItem])
self.colIndex[hItem] = i
self.setHeaderLabels(theLabels)
for hItem in self.treeOrder:
self.setColumnWidth(self.colIndex[hItem], self.colWidth[hItem])
self.setColumnHidden(self.colIndex[hItem], self.colHidden[hItem])
# Make sure title column is always visible,
# and handle column always hidden
self.setColumnHidden(self.colIndex[nwOutline.TITLE], False)
headItem = self.headerItem()
headItem.setTextAlignment(self.colIndex[nwOutline.CCOUNT], Qt.AlignRight)
headItem.setTextAlignment(self.colIndex[nwOutline.WCOUNT], Qt.AlignRight)
headItem.setTextAlignment(self.colIndex[nwOutline.PCOUNT], Qt.AlignRight)
currTitle = None
currChapter = None
currScene = None
for titleKey in self.theIndex.getNovelStructure(skipExcluded=True):
if len(titleKey) < 16:
continue
tHandle = titleKey[:13]
sTitle = titleKey[14:]
if tHandle not in self.theIndex.novelIndex:
continue
if sTitle not in self.theIndex.novelIndex[tHandle]:
continue
tLevel = self.theIndex.novelIndex[tHandle][sTitle]["level"]
tItem = self._createTreeItem(tHandle, sTitle, tLevel)
self.treeMap[titleKey] = tItem
if tLevel == "H1":
currTitle = tItem
self.addTopLevelItem(tItem)
elif tLevel == "H2":
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
currChapter = tItem
elif tLevel == "H3":
if currChapter is None:
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
else:
currChapter.addChild(tItem)
currScene = tItem
elif tLevel == "H4":
if currScene is None:
if currChapter is None:
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
else:
currChapter.addChild(tItem)
else:
currScene.addChild(tItem)
tItem.setExpanded(True)
self.lastBuild = time()
return
def _createTreeItem(self, tHandle, sTitle, tLevel):
"""Populate a tree item with all the column values.
"""
nwItem = self.theProject.projTree[tHandle]
novIdx = self.theIndex.novelIndex[tHandle][sTitle]
newItem = QTreeWidgetItem()
hIcon = "doc_%s" % tLevel.lower()
newItem.setText(self.colIndex[nwOutline.TITLE], novIdx["title"])
newItem.setData(self.colIndex[nwOutline.TITLE], Qt.UserRole, tHandle)
newItem.setIcon(self.colIndex[nwOutline.TITLE], self.theTheme.getIcon(hIcon))
newItem.setText(self.colIndex[nwOutline.LEVEL], novIdx["level"])
newItem.setText(self.colIndex[nwOutline.LABEL], nwItem.itemName)
newItem.setIcon(self.colIndex[nwOutline.LABEL], self.theTheme.getIcon("proj_document"))
newItem.setText(self.colIndex[nwOutline.LINE], sTitle[1:].lstrip("0"))
newItem.setData(self.colIndex[nwOutline.LINE], Qt.UserRole, sTitle)
newItem.setText(self.colIndex[nwOutline.SYNOP], novIdx["synopsis"])
newItem.setText(self.colIndex[nwOutline.CCOUNT], str(novIdx["cCount"]))
newItem.setText(self.colIndex[nwOutline.WCOUNT], str(novIdx["wCount"]))
newItem.setText(self.colIndex[nwOutline.PCOUNT], str(novIdx["pCount"]))
newItem.setTextAlignment(self.colIndex[nwOutline.CCOUNT], Qt.AlignRight)
newItem.setTextAlignment(self.colIndex[nwOutline.WCOUNT], Qt.AlignRight)
newItem.setTextAlignment(self.colIndex[nwOutline.PCOUNT], Qt.AlignRight)
theRefs = self.theIndex.getReferences(tHandle, sTitle)
newItem.setText(self.colIndex[nwOutline.POV], ", ".join(theRefs[nwKeyWords.POV_KEY]))
newItem.setText(self.colIndex[nwOutline.CHAR], ", ".join(theRefs[nwKeyWords.CHAR_KEY]))
newItem.setText(self.colIndex[nwOutline.PLOT], ", ".join(theRefs[nwKeyWords.PLOT_KEY]))
newItem.setText(self.colIndex[nwOutline.TIME], ", ".join(theRefs[nwKeyWords.TIME_KEY]))
newItem.setText(self.colIndex[nwOutline.WORLD], ", ".join(theRefs[nwKeyWords.WORLD_KEY]))
newItem.setText(self.colIndex[nwOutline.OBJECT], ", ".join(theRefs[nwKeyWords.OBJECT_KEY]))
newItem.setText(self.colIndex[nwOutline.ENTITY], ", ".join(theRefs[nwKeyWords.ENTITY_KEY]))
newItem.setText(self.colIndex[nwOutline.CUSTOM], ", ".join(theRefs[nwKeyWords.CUSTOM_KEY]))
return newItem
# END Class GuiOutline
class GuiOutlineHeaderMenu(QMenu):
def __init__(self, theParent):
QMenu.__init__(self, theParent)
self.theParent = theParent
self.acceptToggle = True
mnuHead = QAction("Select Columns", self)
self.addAction(mnuHead)
self.addSeparator()
self.actionMap = {}
for hItem in nwOutline:
if hItem == nwOutline.TITLE:
continue
self.actionMap[hItem] = QAction(nwLabels.OUTLINE_COLS[hItem], self)
self.actionMap[hItem].setCheckable(True)
self.actionMap[hItem].toggled.connect(
lambda isChecked, tItem=hItem : self._columnToggled(isChecked, tItem)
)
self.addAction(self.actionMap[hItem])
return
def setHiddenState(self, hiddenState):
"""Overwrite the checked state of the columns as the inverse of
the hidden state. Skip the TITLE column as it cannot be hidden.
"""
self.acceptToggle = False
for hItem in nwOutline:
if hItem == nwOutline.TITLE or hItem not in hiddenState:
continue
self.actionMap[hItem].setChecked(not hiddenState[hItem])
self.acceptToggle = True
return
##
# Slots
##
def _columnToggled(self, isChecked, theItem):
"""The user has toggled the visibility of a column. Forward the
event to the parent class only if we're accepting changes.
"""
if self.acceptToggle:
self.theParent._menuColumnToggled(isChecked, theItem)
return
# END Class GuiOutlineHeaderMenu