352 lines
13 KiB
Python
352 lines
13 KiB
Python
# -*- 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]
|
||
|
||
"""
|
||
|
||
import logging
|
||
import nw
|
||
|
||
from os import path
|
||
from time import time
|
||
from enum import Enum
|
||
|
||
from PyQt5.QtCore import Qt, QByteArray
|
||
from PyQt5.QtWidgets import (
|
||
QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QMenu, QAction,
|
||
QAbstractItemView
|
||
)
|
||
|
||
from nw.constants import nwKeyWords, nwLabels, nwOutline
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class GuiProjectOutline(QTreeWidget):
|
||
|
||
COL_DEF = {
|
||
nwOutline.TITLE : (200, True, nwLabels.OUTLINE_COLS[nwOutline.TITLE]),
|
||
nwOutline.LEVEL : ( 40, False, nwLabels.OUTLINE_COLS[nwOutline.LEVEL]),
|
||
nwOutline.LABEL : (150, True, nwLabels.OUTLINE_COLS[nwOutline.LABEL]),
|
||
nwOutline.LINE : ( 40, False, nwLabels.OUTLINE_COLS[nwOutline.LINE]),
|
||
nwOutline.CCOUNT : ( 50, False, nwLabels.OUTLINE_COLS[nwOutline.CCOUNT]),
|
||
nwOutline.WCOUNT : ( 50, True, nwLabels.OUTLINE_COLS[nwOutline.WCOUNT]),
|
||
nwOutline.PCOUNT : ( 50, True, nwLabels.OUTLINE_COLS[nwOutline.PCOUNT]),
|
||
nwOutline.POV : (100, True, nwLabels.OUTLINE_COLS[nwOutline.POV]),
|
||
nwOutline.CHAR : (100, True, nwLabels.OUTLINE_COLS[nwOutline.CHAR]),
|
||
nwOutline.PLOT : (100, True, nwLabels.OUTLINE_COLS[nwOutline.PLOT]),
|
||
nwOutline.TIME : (100, False, nwLabels.OUTLINE_COLS[nwOutline.TIME]),
|
||
nwOutline.WORLD : (100, True, nwLabels.OUTLINE_COLS[nwOutline.WORLD]),
|
||
nwOutline.OBJECT : (100, False, nwLabels.OUTLINE_COLS[nwOutline.OBJECT]),
|
||
nwOutline.ENTITY : (100, False, nwLabels.OUTLINE_COLS[nwOutline.ENTITY]),
|
||
nwOutline.CUSTOM : (100, False, nwLabels.OUTLINE_COLS[nwOutline.CUSTOM]),
|
||
nwOutline.SYNOP : (200, True, nwLabels.OUTLINE_COLS[nwOutline.SYNOP]),
|
||
}
|
||
|
||
def __init__(self, theParent, theProject):
|
||
QTreeWidget.__init__(self, theParent)
|
||
|
||
logger.debug("Initialising ProjectOutline ...")
|
||
|
||
self.mainConf = nw.CONFIG
|
||
self.theParent = theParent
|
||
self.theProject = theProject
|
||
self.theIndex = self.theParent.theIndex
|
||
self.optState = self.theProject.optState
|
||
self.headerMenu = GuiOutlineHeaderMenu(self, self.COL_DEF, nwOutline.TITLE)
|
||
|
||
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.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.COL_DEF.keys()
|
||
self.treeNCols = len(self.treeOrder)
|
||
self.colWidth = [150]*self.treeNCols
|
||
self.colHidden = [False]*self.treeNCols
|
||
self.colIndex = {}
|
||
|
||
# Set defaults
|
||
for hItem in self.treeOrder:
|
||
self.colWidth[hItem.value] = self.COL_DEF[hItem][0]
|
||
self.colHidden[hItem.value] = self.COL_DEF[hItem][1]
|
||
|
||
logger.debug("ProjectOutline initialisation complete")
|
||
|
||
return
|
||
|
||
def refreshTree(self):
|
||
"""Called whenever the Outline tab is activated and controls
|
||
what data to load, and if necessary, force a rebuild of the
|
||
tree.
|
||
"""
|
||
|
||
if self.firstView:
|
||
self._loadHeaderState()
|
||
self._populateTree()
|
||
|
||
self.firstView = False
|
||
|
||
return
|
||
|
||
def closeOutline(self):
|
||
"""Called before a project is closed.
|
||
"""
|
||
self._saveHeaderState()
|
||
self.clear()
|
||
self.firstView = True
|
||
return
|
||
|
||
##
|
||
# Slots
|
||
##
|
||
|
||
def _treeDoubleClick(self, tItem, tCol):
|
||
print(tItem, tCol)
|
||
return
|
||
|
||
def _headerRightClick(self, clickPos):
|
||
self.headerMenu.exec_(self.mapToGlobal(clickPos))
|
||
print("Menu closed")
|
||
return
|
||
|
||
def _columnMoved(self, logIdx, oldVisualIdx, newVisualIdx):
|
||
"""Make sure the order and width read from settings file, or
|
||
with default values, is kept up-to-date when columns are moved
|
||
around. Otherwise, the original order will be restored on a tree
|
||
rebuild.
|
||
"""
|
||
self.treeOrder.insert(newVisualIdx, self.treeOrder.pop(oldVisualIdx))
|
||
self.colWidth.insert(newVisualIdx, self.colWidth.pop(oldVisualIdx))
|
||
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.
|
||
keysOrder = self.COL_DEF.keys()
|
||
tempOrder = self.optState.getValue("GuiProjectOutline", "headerOrder", keysOrder)
|
||
treeOrder = []
|
||
for hName in tempOrder:
|
||
for hItem in nwOutline:
|
||
if hItem.name == hName:
|
||
treeOrder.append(hItem)
|
||
|
||
# Add columns that were not in tempOrder to treeOrder, but in
|
||
# the default column order.
|
||
for cItem in keysOrder:
|
||
if cItem not in treeOrder:
|
||
treeOrder.append(cItem)
|
||
|
||
# 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))
|
||
|
||
# The columns widths and hidden state we just fill with whatever
|
||
# we've got, and append the rest with defaults, and truncate to
|
||
# desired length.
|
||
tmpWidth = self.optState.getValue("GuiProjectOutline", "columnWidth", [])
|
||
colWidth = [int(w) for w in tmpWidth]
|
||
self.colWidth = (colWidth + self.colWidth)[0:self.treeNCols]
|
||
|
||
tmpHidden = self.optState.getValue("GuiProjectOutline", "columnHidden", [])
|
||
colHidden = [int(w) for w in tmpHidden]
|
||
self.colHidden = (colHidden + self.colHidden)[0:self.treeNCols]
|
||
|
||
return
|
||
|
||
def _saveHeaderState(self):
|
||
"""Save the state of the main tree header, that is, column order
|
||
and column width.
|
||
"""
|
||
|
||
# If we haven't built the tree, there is nothing to save.
|
||
if self.lastBuild == 0:
|
||
return
|
||
|
||
treeOrder = []
|
||
colWidth = []
|
||
colHidden = []
|
||
for iCol in range(self.columnCount()):
|
||
treeOrder.append(self.treeOrder[iCol].name)
|
||
iLog = self.treeHead.logicalIndex(iCol)
|
||
colWidth.append(self.columnWidth(iLog))
|
||
colHidden.append(self.isColumnHidden(iLog))
|
||
|
||
self.optState.setValue("GuiProjectOutline", "headerOrder", treeOrder)
|
||
self.optState.setValue("GuiProjectOutline", "columnWidth", colWidth)
|
||
self.optState.setValue("GuiProjectOutline", "columnHidden", colHidden)
|
||
self.optState.saveSettings()
|
||
|
||
return
|
||
|
||
def _populateTree(self):
|
||
"""Build the tree based on the project index.
|
||
"""
|
||
|
||
theLabels = []
|
||
for i, hItem in enumerate(self.treeOrder):
|
||
theLabels.append(self.COL_DEF[hItem][2])
|
||
self.colIndex[hItem] = i
|
||
|
||
self.clear()
|
||
self.setHeaderLabels(theLabels)
|
||
for n, colW in enumerate(self.colWidth):
|
||
self.setColumnWidth(n,colW)
|
||
|
||
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():
|
||
|
||
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"]
|
||
tTime = self.theIndex.novelIndex[tHandle][sTitle]["updated"]
|
||
tItem = self._createTreeItem(tHandle, sTitle)
|
||
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):
|
||
"""Populate a tree item with all the column values.
|
||
"""
|
||
|
||
nwItem = self.theProject.getItem(tHandle)
|
||
novIdx = self.theIndex.novelIndex[tHandle][sTitle]
|
||
|
||
newItem = QTreeWidgetItem()
|
||
|
||
newItem.setText(self.colIndex[nwOutline.TITLE], novIdx["title"])
|
||
newItem.setText(self.colIndex[nwOutline.LEVEL], novIdx["level"])
|
||
newItem.setText(self.colIndex[nwOutline.LABEL], nwItem.itemName)
|
||
newItem.setText(self.colIndex[nwOutline.LINE], sTitle[1:])
|
||
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 GuiProjectOutline
|
||
|
||
class GuiOutlineHeaderMenu(QMenu):
|
||
|
||
def __init__(self, theParent, colDefault, skipCol):
|
||
QMenu.__init__(self, theParent)
|
||
|
||
mnuHead = QAction("Select Columns", self)
|
||
self.addAction(mnuHead)
|
||
self.addSeparator()
|
||
|
||
self.actionMap = {}
|
||
|
||
for hItem in nwOutline:
|
||
if hItem == skipCol:
|
||
continue
|
||
if hItem not in colDefault:
|
||
continue
|
||
self.actionMap[hItem] = QAction(colDefault[hItem][2], self)
|
||
self.actionMap[hItem].setCheckable(True)
|
||
self.actionMap[hItem].setChecked(colDefault[hItem][1])
|
||
self.actionMap[hItem].toggled.connect(
|
||
lambda isChecked, tItem=hItem : self._columnToggled(isChecked, tItem)
|
||
)
|
||
self.addAction(self.actionMap[hItem])
|
||
|
||
return
|
||
|
||
def _columnToggled(self, isChecked, theItem):
|
||
print(isChecked, theItem.name)
|
||
return
|
||
|
||
# END Class GuiOutlineHeaderMenu
|