Add global search

This commit is contained in:
Moisés J 2019-12-21 15:42:49 +00:00
parent e52818043d
commit 1e52af54e2
59 changed files with 14969 additions and 12759 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python
#--!-- coding: utf8 --!--
# --!-- coding: utf8 --!--
from enum import IntEnum
@ -17,6 +17,7 @@ class Character(IntEnum):
summaryFull = 9
notes = 10
pov = 11
infos = 12
class Plot(IntEnum):
name = 0
@ -67,3 +68,18 @@ class Abstract(IntEnum):
title = 0
ID = 1
type = 2
class FlatData(IntEnum):
summarySituation = 0,
summarySentence = 1,
summaryPara = 2,
summaryPage = 3,
summaryFull = 4
class Model(IntEnum):
Character = 0
Plot = 1
PlotStep = 2
World = 3
Outline = 4
FlatData = 5

View File

@ -9,7 +9,7 @@ from PyQt5.QtCore import Qt, QRect, QStandardPaths, QObject, QRegExp, QDir
from PyQt5.QtCore import QUrl, QTimer
from PyQt5.QtGui import QBrush, QIcon, QPainter, QColor, QImage, QPixmap
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtWidgets import qApp, QFileDialog, QTextEdit
from PyQt5.QtWidgets import qApp, QFileDialog
from manuskript.enums import Outline
@ -450,5 +450,48 @@ def inspect():
s.function))
print(" " + "".join(s.code_context))
def search(searchRegex, text):
"""
Search all occurrences of a regex in a text.
:param searchRegex: a regex object with the search to perform
:param text: text to search on
:return: list of tuples (startPos, endPos)
"""
if text is not None:
return [(m.start(), m.end(), getSearchResultContext(text, m.start(), m.end())) for m in searchRegex.finditer(text)]
else:
return []
def getSearchResultContext(text, startPos, endPos):
matchSize = endPos - startPos
maxContextSize = max(matchSize, 600)
extraContextSize = int((maxContextSize - matchSize) / 2)
separator = "[...]"
context = ""
i = startPos - 1
while i > 0 and (startPos - i) < extraContextSize and text[i] != '\n':
i -= 1
contextStartPos = i
if i > 0:
context += separator + " "
context += text[contextStartPos:startPos].replace('\n', '')
context += '<b>' + text[startPos:endPos].replace('\n', '') + '</b>'
i = endPos
while i < len(text) and (i - endPos) < extraContextSize and text[i] != '\n':
i += 1
contextEndPos = i
context += text[endPos:contextEndPos].replace('\n', '')
if i < len(text):
context += " " + separator
return context
# Spellchecker loads writablePath from this file, so we need to load it after they get defined
from manuskript.functions.spellchecker import Spellchecker

View File

@ -9,7 +9,7 @@ from PyQt5.QtCore import (pyqtSignal, QSignalMapper, QTimer, QSettings, Qt, QPoi
QRegExp, QUrl, QSize, QModelIndex)
from PyQt5.QtGui import QStandardItemModel, QIcon, QColor
from PyQt5.QtWidgets import QMainWindow, QHeaderView, qApp, QMenu, QActionGroup, QAction, QStyle, QListWidgetItem, \
QLabel, QDockWidget, QWidget, QMessageBox
QLabel, QDockWidget, QWidget, QMessageBox, QLineEdit
from manuskript import settings
from manuskript.enums import Character, PlotStep, Plot, World, Outline
@ -129,6 +129,7 @@ class MainWindow(QMainWindow, Ui_MainWindow):
self.actCopy.triggered.connect(self.documentsCopy)
self.actCut.triggered.connect(self.documentsCut)
self.actPaste.triggered.connect(self.documentsPaste)
self.actSearch.triggered.connect(self.doSearch)
self.actRename.triggered.connect(self.documentsRename)
self.actDuplicate.triggered.connect(self.documentsDuplicate)
self.actDelete.triggered.connect(self.documentsDelete)
@ -499,6 +500,13 @@ class MainWindow(QMainWindow, Ui_MainWindow):
def documentsPaste(self):
"Paste clipboard item(s) into selected item."
if self._lastFocus: self._lastFocus.paste()
def doSearch(self):
"Do a global search."
self.dckSearch.show()
self.dckSearch.activateWindow()
searchTextInput = self.dckSearch.findChild(QLineEdit, 'searchTextInput')
searchTextInput.setFocus()
searchTextInput.selectAll()
def documentsRename(self):
"Rename selected item."
if self._lastFocus: self._lastFocus.rename()

View File

@ -3,11 +3,14 @@
from PyQt5.QtCore import QModelIndex, Qt, QAbstractItemModel, QVariant
from PyQt5.QtGui import QIcon, QPixmap, QColor
from manuskript.functions import randomColor, iconColor, mainWindow
from manuskript.enums import Character as C
from manuskript.functions import randomColor, iconColor, mainWindow, search
from manuskript.enums import Character as C, Model
from manuskript.searchLabels import CharacterSearchLabels
from manuskript.models.searchableModel import searchableModel
from manuskript.models.searchableItem import searchableItem
class characterModel(QAbstractItemModel):
class characterModel(QAbstractItemModel, searchableModel):
def __init__(self, parent):
QAbstractItemModel.__init__(self, parent)
@ -229,12 +232,14 @@ class characterModel(QAbstractItemModel):
c.infos.pop(r)
self.endRemoveRows()
def searchableItems(self):
return self.characters
###############################################################################
# CHARACTER
###############################################################################
class Character():
class Character(searchableItem):
def __init__(self, model, name="No name", importance = 0):
self._model = model
self.lastPath = ""
@ -248,6 +253,8 @@ class Character():
self.infos = []
super().__init__(CharacterSearchLabels)
def name(self):
return self._data[C.name.value]
@ -263,6 +270,12 @@ class Character():
def index(self, column=0):
return self._model.indexFromItem(self, column)
def data(self, column):
if column == "Info":
return self.infos
else:
return self._data.get(column, None)
def assignRandomColor(self):
"""
Assigns a random color the the character.
@ -325,6 +338,41 @@ class Character():
r.append((i.description, i.value))
return r
def searchTitle(self, column):
return self.name()
def searchOccurrences(self, searchRegex, column):
results = []
data = self.searchData(column)
if isinstance(data, list):
for i in range(0, len(data)):
# For detailed info we will highlight the full row, so we pass the row index
# to the highlighter instead of the (startPos, endPos) of the match itself.
results += [self.wrapSearchOccurrence(column, i, 0, context) for
(startPos, endPos, context) in search(searchRegex, data[i].description)]
results += [self.wrapSearchOccurrence(column, i, 0, context) for
(startPos, endPos, context) in search(searchRegex, data[i].value)]
else:
results += super().searchOccurrences(searchRegex, column)
return results
def searchID(self):
return self.ID()
def searchPath(self, column):
return [self.translate("Characters"), self.name(), self.translate(self.searchColumnLabel(column))]
def searchData(self, column):
if column == C.infos:
return self.infos
else:
return self.data(column)
def searchModel(self):
return Model.Character
class CharacterInfo():
def __init__(self, character, description="", value=""):

View File

@ -0,0 +1,53 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.enums import FlatData, Model
from manuskript.searchLabels import FlatDataSearchLabels
from manuskript.models.searchableModel import searchableModel
from manuskript.models.searchableItem import searchableItem
"""
All searches are performed on models inheriting from searchableModel, but special metadata such as book summaries
are stored directly on a GUI element (QStandardItemModel). We wrap this GUI element inside this wrapper class
so it exposes the same interface for searches.
"""
class flatDataModelWrapper(searchableModel, searchableItem):
def __init__(self, qstandardItemModel):
self.qstandardItemModel = qstandardItemModel
def searchableItems(self):
return [flatDataItemWrapper(self.qstandardItemModel)]
class flatDataItemWrapper(searchableItem):
def __init__(self, qstandardItemModel):
super().__init__(FlatDataSearchLabels)
self.qstandardItemModel = qstandardItemModel
def searchModel(self):
return Model.FlatData
def searchID(self):
return None
def searchTitle(self, column):
return self.translate(self.searchColumnLabel(column))
def searchPath(self, column):
return [self.translate("Summary"), self.translate(self.searchColumnLabel(column))]
def searchData(self, column):
return self.qstandardItemModel.item(1, self.searchDataIndex(column)).text()
@staticmethod
def searchDataIndex(column):
columnIndices = {
FlatData.summarySituation: 0,
FlatData.summarySentence: 1,
FlatData.summaryPara: 2,
FlatData.summaryPage: 3,
FlatData.summaryFull: 4
}
return columnIndices[column]

View File

@ -8,10 +8,13 @@ from PyQt5.QtGui import QFont, QIcon
from PyQt5.QtWidgets import qApp
from lxml import etree as ET
from manuskript.models.abstractItem import abstractItem
from manuskript.models.searchableItem import searchableItem
from manuskript import enums
from manuskript import functions as F
from manuskript import settings
from manuskript.converters import HTML2PlainText
from manuskript.searchLabels import OutlineSearchLabels
from manuskript.enums import Outline, Model
try:
locale.setlocale(locale.LC_ALL, '')
@ -21,7 +24,7 @@ except:
pass
class outlineItem(abstractItem):
class outlineItem(abstractItem, searchableItem):
enum = enums.Outline
@ -30,6 +33,7 @@ class outlineItem(abstractItem):
def __init__(self, model=None, title="", _type="folder", xml=None, parent=None, ID=None):
abstractItem.__init__(self, model, title, _type, xml, parent, ID)
searchableItem.__init__(self, OutlineSearchLabels)
self.defaultTextType = None
if not self._data.get(self.enum.compile):
@ -355,8 +359,7 @@ class outlineItem(abstractItem):
return lst
def findItemsContaining(self, text, columns, mainWindow=F.mainWindow(),
caseSensitive=False, recursive=True):
def findItemsContaining(self, text, columns, mainWindow=F.mainWindow(), caseSensitive=False, recursive=True):
"""Returns a list if IDs of all subitems
containing ``text`` in columns ``columns``
(being a list of int).
@ -369,16 +372,14 @@ class outlineItem(abstractItem):
return lst
def itemContains(self, text, columns, mainWindow=F.mainWindow(),
caseSensitive=False):
def itemContains(self, text, columns, mainWindow=F.mainWindow(), caseSensitive=False):
lst = []
text = text.lower() if not caseSensitive else text
for c in columns:
if c == self.enum.POV and self.POV():
c = mainWindow.mdlCharacter.getCharacterByID(self.POV())
if c:
searchIn = c.name()
character = mainWindow.mdlCharacter.getCharacterByID(self.POV())
if character:
searchIn = character.name()
else:
searchIn = ""
print("Character POV not found:", self.POV())
@ -393,7 +394,6 @@ class outlineItem(abstractItem):
searchIn = self.data(c)
searchIn = searchIn.lower() if not caseSensitive else searchIn
if text in searchIn:
if not self.ID() in lst:
lst.append(self.ID())
@ -515,3 +515,39 @@ class outlineItem(abstractItem):
for child in root:
if child.tag == "revision":
self.appendRevision(child.attrib["timestamp"], child.attrib["text"])
#######################################################################
# Search
#######################################################################
def searchModel(self):
return Model.Outline
def searchID(self):
return self.data(Outline.ID)
def searchTitle(self, column):
return self.title()
def searchPath(self, column):
return [self.translate("Outline")] + self.path().split(' > ') + [self.translate(self.searchColumnLabel(column))]
def searchData(self, column):
mainWindow = F.mainWindow()
searchData = None
if column == self.enum.POV and self.POV():
character = mainWindow.mdlCharacter.getCharacterByID(self.POV())
if character:
searchData = character.name()
elif column == self.enum.status:
searchData = mainWindow.mdlStatus.item(F.toInt(self.status()), 0).text()
elif column == self.enum.label:
searchData = mainWindow.mdlLabels.item(F.toInt(self.label()), 0).text()
else:
searchData = self.data(column)
return searchData

View File

@ -2,12 +2,29 @@
# --!-- coding: utf8 --!--
from manuskript.models.abstractModel import abstractModel
from manuskript.models.searchableModel import searchableModel
class outlineModel(abstractModel):
class outlineModel(abstractModel, searchableModel):
def __init__(self, parent):
abstractModel.__init__(self, parent)
def findItemsByPOV(self, POV):
"Returns a list of IDs of all items whose POV is ``POV``."
return self.rootItem.findItemsByPOV(POV)
def searchableItems(self):
result = []
for child in self.rootItem.children():
result += self._searchableItems(child)
return result
def _searchableItems(self, item):
result = [item]
for child in item.children():
result += self._searchableItems(child)
return result

View File

@ -8,12 +8,15 @@ from PyQt5.QtGui import QStandardItem
from PyQt5.QtGui import QStandardItemModel
from PyQt5.QtWidgets import QAction, QMenu
from manuskript.enums import Plot
from manuskript.enums import PlotStep
from manuskript.enums import Plot, PlotStep, Model
from manuskript.functions import toInt, mainWindow
from manuskript.models.searchResultModel import searchResultModel
from manuskript.searchLabels import PlotSearchLabels, PLOT_STEP_COLUMNS_OFFSET
from manuskript.functions import search
from manuskript.models.searchableModel import searchableModel
from manuskript.models.searchableItem import searchableItem
class plotModel(QStandardItemModel):
class plotModel(QStandardItemModel, searchableModel):
def __init__(self, parent):
QStandardItemModel.__init__(self, 0, 3, parent)
self.setHorizontalHeaderLabels([i.name for i in Plot])
@ -266,3 +269,118 @@ class plotModel(QStandardItemModel):
mpr.mapped.connect(self.addPlotPerso)
self.mw.btnAddPlotPerso.setMenu(menu)
#######################################################################
# Search
#######################################################################
def searchableItems(self):
items = []
for i in range(self.rowCount()):
items.append(plotItemSearchWrapper(i, self.item, self.mw.mdlCharacter.getCharacterByID))
return items
class plotItemSearchWrapper(searchableItem):
def __init__(self, rowIndex, getItem, getCharacterByID):
self.rowIndex = rowIndex
self.getItem = getItem
self.getCharacterByID = getCharacterByID
super().__init__(PlotSearchLabels)
def searchOccurrences(self, searchRegex, column):
results = []
plotName = self.getItem(self.rowIndex, Plot.name).text()
if column >= PLOT_STEP_COLUMNS_OFFSET:
results += self.searchInPlotSteps(self.rowIndex, plotName, column, column - PLOT_STEP_COLUMNS_OFFSET, searchRegex, False)
else:
item_name = self.getItem(self.rowIndex, Plot.name).text()
if column == Plot.characters:
charactersList = self.getItem(self.rowIndex, Plot.characters)
for i in range(charactersList.rowCount()):
characterID = charactersList.child(i).text()
character = self.getCharacterByID(characterID)
if character:
columnText = character.name()
characterResults = search(searchRegex, columnText)
if len(characterResults):
# We will highlight the full character row in the plot characters list, so we
# return the row index instead of the match start and end positions.
results += [
searchResultModel(Model.Plot, self.getItem(self.rowIndex, Plot.ID).text(), column,
self.translate(item_name),
self.searchPath(column),
[(i, 0)], context) for start, end, context in
search(searchRegex, columnText)]
else:
results += super().searchOccurrences(searchRegex, column)
if column == Plot.name:
results += self.searchInPlotSteps(self.rowIndex, plotName, Plot.name, PlotStep.name,
searchRegex, False)
elif column == Plot.summary:
results += self.searchInPlotSteps(self.rowIndex, plotName, Plot.summary, PlotStep.summary,
searchRegex, True)
return results
def searchModel(self):
return Model.Plot
def searchID(self):
return self.getItem(self.rowIndex, Plot.ID).text()
def searchTitle(self, column):
return self.getItem(self.rowIndex, Plot.name).text()
def searchPath(self, column):
def _path(item):
path = []
if item.parent():
path += _path(item.parent())
path.append(item.text())
return path
return [self.translate("Plot")] + _path(self.getItem(self.rowIndex, Plot.name)) + [self.translate(self.searchColumnLabel(column))]
def searchData(self, column):
return self.getItem(self.rowIndex, column).text()
def plotStepPath(self, plotName, plotStepName, column):
return [self.translate("Plot"), plotName, plotStepName, self.translate(self.searchColumnLabel(column))]
def searchInPlotSteps(self, plotIndex, plotName, plotColumn, plotStepColumn, searchRegex, searchInsidePlotStep):
results = []
# Plot step info can be found in two places: the own list of plot steps (this is the case for ie. name and meta
# fields) and "inside" the plot step once it is selected in the list (as it's the case for the summary).
if searchInsidePlotStep:
# We are searching *inside* the plot step, so we return both the row index (for selecting the right plot
# step in the list), and (start, end) positions of the match inside the text field for highlighting it.
getSearchData = lambda rowIndex, start, end, context: ([(rowIndex, 0), (start, end)], context)
else:
# We are searching *in the plot step row*, so we only return the row index for selecting the right plot
# step in the list when highlighting search results.
getSearchData = lambda rowIndex, start, end, context: ([(rowIndex, 0)], context)
item = self.getItem(plotIndex, Plot.steps)
for i in range(item.rowCount()):
if item.child(i, PlotStep.ID):
plotStepName = item.child(i, PlotStep.name).text()
plotStepText = item.child(i, plotStepColumn).text()
# We will highlight the full plot step row in the plot steps list, so we
# return the row index instead of the match start and end positions.
results += [searchResultModel(Model.PlotStep, self.getItem(plotIndex, Plot.ID).text(), plotStepColumn,
self.translate(plotStepName),
self.plotStepPath(plotName, plotStepName, plotColumn),
*getSearchData(i, start, end, context)) for start, end, context in
search(searchRegex, plotStepText)]
return results

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
class searchFilter:
def __init__(self, label, enabled, modelColumns = None):
if not isinstance(label, str):
raise TypeError("label must be a str")
if not isinstance(enabled, bool):
raise TypeError("enabled must be a bool")
if modelColumns is not None and (not isinstance(modelColumns, list)):
raise TypeError("modelColumns must be a list or None")
self._label = label
self._enabled = enabled
self._modelColumns = modelColumns
if self._modelColumns is None:
self._modelColumns = []
def label(self):
return self._label
def enabled(self):
return self._enabled
def modelColumns(self):
return self._modelColumns
def setEnabled(self, enabled):
self._enabled = enabled

View File

@ -0,0 +1,44 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
class searchResultModel():
def __init__(self, model_type, model_id, column, title, path, pos, context):
self._type = model_type
self._id = model_id
self._column = column
self._title = title
self._path = path
self._pos = pos
self._context = context
def type(self):
return self._type
def id(self):
return self._id
def column(self):
return self._column
def title(self):
return self._title
def path(self):
return self._path
def pos(self):
return self._pos
def context(self):
return self._context
def __repr__(self):
return "(%s, %s, %s, %s, %s, %s, %s)" % (self._type, self._id, self._column, self._title, self._path, self._pos, self._context)
def __eq__(self, other):
return self.type() == other.type() and \
self.id() == other.id() and \
self.column == other.column and \
self.pos() == other.pos() and \
self.context == other.context

View File

@ -0,0 +1,38 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.models.searchResultModel import searchResultModel
from manuskript.functions import search
from PyQt5.QtCore import QCoreApplication
class searchableItem():
def __init__(self, searchColumnLabels):
self._searchColumnLabels = searchColumnLabels
def searchOccurrences(self, searchRegex, column):
return [self.wrapSearchOccurrence(column, startPos, endPos, context) for (startPos, endPos, context) in search(searchRegex, self.searchData(column))]
def wrapSearchOccurrence(self, column, startPos, endPos, context):
return searchResultModel(self.searchModel(), self.searchID(), column, self.searchTitle(column), self.searchPath(column), [(startPos, endPos)], context)
def searchModel(self):
raise NotImplementedError
def searchID(self):
raise NotImplementedError
def searchTitle(self, column):
raise NotImplementedError
def searchPath(self, column):
return []
def searchData(self, column):
raise NotImplementedError
def searchColumnLabel(self, column):
return self._searchColumnLabels.get(column, "")
def translate(self, text):
return QCoreApplication.translate("MainWindow", text)

View File

@ -0,0 +1,15 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
class searchableModel():
def searchOccurrences(self, searchRegex, columns):
results = []
for item in self.searchableItems():
for column in columns:
results += item.searchOccurrences(searchRegex, column)
return results
def searchableItems(self):
raise NotImplementedError

View File

@ -1,18 +1,20 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from PyQt5.QtCore import QModelIndex
from PyQt5.QtCore import QSize
from PyQt5.QtCore import QModelIndex, QSize
from PyQt5.QtCore import Qt, QMimeData, QByteArray
from PyQt5.QtGui import QStandardItem, QBrush, QFontMetrics
from PyQt5.QtGui import QStandardItemModel, QColor
from PyQt5.QtWidgets import QMenu, QAction, qApp
from manuskript.enums import World
from manuskript.enums import World, Model
from manuskript.functions import mainWindow
from manuskript.ui import style as S
from manuskript.models.searchableModel import searchableModel
from manuskript.models.searchableItem import searchableItem
from manuskript.searchLabels import WorldSearchLabels
class worldModel(QStandardItemModel):
class worldModel(QStandardItemModel, searchableModel):
def __init__(self, parent):
QStandardItemModel.__init__(self, 0, len(World), parent)
self.mw = mainWindow()
@ -356,3 +358,51 @@ class worldModel(QStandardItemModel):
return QSize(0, h + 6)
return QStandardItemModel.data(self, index, role)
#######################################################################
# Search
#######################################################################
def searchableItems(self):
def readAll(item):
items = [WorldItemSearchWrapper(item, self.itemID(item), self.indexFromItem(item), self.data)]
for c in self.children(item):
items += readAll(c)
return items
return readAll(self.invisibleRootItem())
class WorldItemSearchWrapper(searchableItem):
def __init__(self, item, itemID, itemIndex, getColumnData):
super().__init__(WorldSearchLabels)
self.item = item
self.itemID = itemID
self.itemIndex = itemIndex
self.getColumnData = getColumnData
def searchModel(self):
return Model.World
def searchID(self):
return self.itemID
def searchTitle(self, column):
return self.item.text()
def searchPath(self, column):
def _path(item):
path = []
if item.parent():
path += _path(item.parent())
path.append(item.text())
return path
return [self.translate("World")] + _path(self.item) + [self.translate(self.searchColumnLabel(column))]
def searchData(self, column):
return self.getColumnData(self.itemIndex.sibling(self.itemIndex.row(), column))

View File

@ -0,0 +1,56 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.enums import Outline, Character, FlatData, World, Plot, PlotStep
OutlineSearchLabels = {
Outline.title: "Title",
Outline.text: "Text",
Outline.summarySentence: "One sentence summary",
Outline.summaryFull: "Summary",
Outline.POV: "POV",
Outline.notes: "Notes",
Outline.status: "Status",
Outline.label: "Label"
}
CharacterSearchLabels = {
Character.name: "Name",
Character.motivation: "Motivation",
Character.goal: "Goal",
Character.conflict: "Conflict",
Character.epiphany: "Epiphany",
Character.summarySentence: "One sentence summary",
Character.summaryPara: "One paragraph summary",
Character.summaryFull: "Summary",
Character.notes: "Notes",
Character.infos: "Detailed info"
}
FlatDataSearchLabels = {
FlatData.summarySituation: "Situation",
FlatData.summarySentence: "One sentence summary",
FlatData.summaryPara: "One paragraph summary",
FlatData.summaryPage: "One page summary",
FlatData.summaryFull: "Full summary"
}
WorldSearchLabels = {
World.name: "Name",
World.description: "Description",
World.passion: "Passion",
World.conflict: "Conflict"
}
# Search menu includes one single option for both plot and plotStep models. For plotStep related fields
# (like PlotStep.meta) we add an offset so it is not confused with the Plot enum value mapping to the same integer.
PLOT_STEP_COLUMNS_OFFSET = 30
PlotSearchLabels = {
Plot.name: "Name",
Plot.description: "Description",
Plot.characters: "Characters",
Plot.result: "Result",
Plot.summary: "Summary",
PLOT_STEP_COLUMNS_OFFSET + PlotStep.meta: "Meta"
}

View File

@ -0,0 +1,41 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
import pytest
from manuskript.models.searchFilter import searchFilter
def test_searchFilter_constructionOk():
filter = searchFilter("label", True, [3])
assert filter.label() == "label"
assert filter.enabled() is True
assert filter.modelColumns() == [3]
def test_searchFilter_constructionOkWithNoneModelColumn():
filter = searchFilter("label", True)
assert filter.label() == "label"
assert filter.enabled() is True
assert filter.modelColumns() == []
def test_searchFilter_constructionBadLabelType():
with pytest.raises(TypeError, match=r".*label must be a str.*"):
searchFilter(13, True, [3])
def test_searchFilter_constructionBadEnabledType():
with pytest.raises(TypeError, match=r".*enabled must be a bool.*"):
searchFilter("label", 3, [3])
def test_searchFilter_constructionBadModelColumnType():
with pytest.raises(TypeError, match=r".*modelColumns must be a list or None.*"):
searchFilter("label", False, True)
def test_searchFilter_setEnabled():
filter = searchFilter("label", True, [3])
assert filter.enabled() is True
filter.setEnabled(False)
assert filter.enabled() is False

View File

@ -0,0 +1,16 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.models.searchResultModel import searchResultModel
from manuskript.enums import Character
def test_searchResultModel_constructionOk():
searchResult = searchResultModel("Character", "3", Character.notes, "Lucas", "A > B > C", (15, 18), "This is <b>Lucas</b>")
assert searchResult.id() == "3"
assert searchResult.column() == Character.notes
assert searchResult.title() == "Lucas"
assert searchResult.path() == "A > B > C"
assert searchResult.pos() == (15, 18)
assert searchResult.context() == "This is <b>Lucas</b>"

View File

@ -3,6 +3,7 @@
"""Tests for functions"""
import re
from manuskript import functions as F
def test_wordCount():
@ -94,3 +95,52 @@ def test_mainWindow():
F.printObjects()
assert len(F.findWidgetsOfClass(QWidget)) > 0
assert len(F.findWidgetsOfClass(QLCDNumber)) == 0
def test_search_noMatch():
assert F.search(re.compile("text"), "foo") == []
def test_search_singleLine_fullMatch():
assert F.search(re.compile("text"), "text") == [(0, 4, "<b>text</b>")]
def test_search_singleLine_start():
assert F.search(re.compile("text"), "text is this") == [(0, 4, "<b>text</b> is this")]
def test_search_singleLine_end():
assert F.search(re.compile("text"), "This is text") == [(8, 12, "This is <b>text</b>")]
def test_search_multipleLines_fullMatch():
assert F.search(re.compile("text"), "This is\ntext\nOK") == [(8, 12, "[...] <b>text</b> [...]")]
def test_search_multipleLines_start():
assert F.search(re.compile("text"), "This is\ntext oh yeah\nOK") == [(8, 12, "[...] <b>text</b> oh yeah [...]")]
def test_search_multipleLines_end():
assert F.search(re.compile("text"), "This is\nsome text\nOK") == [(13, 17, "[...] some <b>text</b> [...]")]
def test_search_multipleLines_full():
assert F.search(re.compile("text"), "This is\ntext\nOK") == [(8, 12, "[...] <b>text</b> [...]")]
def test_search_multiple_strMatches():
assert F.search(re.compile("text"), "text, text and more text") == [
(0, 4, "<b>text</b>, text and more text"),
(6, 10, "text, <b>text</b> and more text"),
(20, 24, "text, text and more <b>text</b>")
]
def test_search_multiple_strMatches_caseSensitive():
assert F.search(re.compile("text"), "TeXt, TEXT and more text") == [(20, 24, "TeXt, TEXT and more <b>text</b>")]
assert F.search(re.compile("text", re.IGNORECASE), "TeXt, TEXT and more text") == [
(0, 4, "<b>TeXt</b>, TEXT and more text"),
(6, 10, "TeXt, <b>TEXT</b> and more text"),
(20, 24, "TeXt, TEXT and more <b>text</b>")
]

View File

@ -0,0 +1,56 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.ui.searchMenu import searchMenu
from manuskript.enums import Outline, Character, FlatData, World, Plot, PlotStep, Model
from manuskript.searchLabels import PLOT_STEP_COLUMNS_OFFSET
def triggerFilter(filterKey, actions):
list(filter(lambda action: action.data() == filterKey, actions))[0].trigger()
def test_searchMenu_defaultColumns():
"""
By default all model columns are selected.
"""
search_menu = searchMenu()
assert set(search_menu.columns(Model.Outline)) == {
Outline.title, Outline.text, Outline.summaryFull,
Outline.summarySentence, Outline.notes, Outline.POV,
Outline.status, Outline.label
}
assert set(search_menu.columns(Model.Character)) == {
Character.name, Character.motivation, Character.goal, Character.conflict,
Character.epiphany, Character.summarySentence, Character.summaryPara,
Character.summaryFull, Character.notes, Character.infos
}
assert set(search_menu.columns(Model.FlatData)) == {
FlatData.summarySituation, FlatData.summarySentence, FlatData.summaryPara,
FlatData.summaryPage, FlatData.summaryFull
}
assert set(search_menu.columns(Model.World)) == {
World.name, World.description, World.passion, World.conflict
}
assert set(search_menu.columns(Model.Plot)) == {
Plot.name, Plot.description, Plot.characters, Plot.result,
Plot.summary, PLOT_STEP_COLUMNS_OFFSET + PlotStep.meta
}
def test_searchMenu_someColumns():
"""
When deselecting some filters the columns associated to those filters are not returned.
"""
search_menu = searchMenu()
triggerFilter(Model.Outline, search_menu.actions())
triggerFilter(Model.Character, search_menu.actions())
assert set(search_menu.columns(Model.Outline)) == set()
assert set(search_menu.columns(Model.Character)) == set()

View File

@ -0,0 +1,2 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--

View File

@ -0,0 +1,13 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
class abstractSearchResultHighlighter():
"""
Interface for all classes highlighting search results on widgets.
"""
def __init__(self):
pass
def highlightSearchResult(self, searchResult):
raise NotImplementedError

View File

@ -0,0 +1,24 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.ui.highlighters.searchResultHighlighters.widgetSelectionHighlighter import widgetSelectionHighlighter
class abstractSearchResultHighlighter():
def __init__(self):
self._widgetSelectionHighlighter = widgetSelectionHighlighter()
def highlightSearchResult(self, searchResult):
self.openView(searchResult)
widgets = self.retrieveWidget(searchResult)
if not isinstance(widgets, list):
widgets = [widgets]
for i in range(len(widgets)):
self._widgetSelectionHighlighter.highlight_widget_selection(widgets[i], searchResult.pos()[i][0], searchResult.pos()[i][1], i == len(widgets) - 1)
def openView(self, searchResult):
raise RuntimeError
def retrieveWidget(self, searchResult):
raise RuntimeError

View File

@ -0,0 +1,38 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.models import references as Ref
from manuskript.functions import mainWindow
from manuskript.enums import Character
from PyQt5.QtWidgets import QTextEdit, QTableView, QLineEdit
from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter
class characterSearchResultHighlighter(abstractSearchResultHighlighter):
def __init__(self):
super().__init__()
def openView(self, searchResult):
r = Ref.characterReference(searchResult.id())
Ref.open(r)
mainWindow().tabPersos.setEnabled(True)
def retrieveWidget(self, searchResult):
textEditMap = {
Character.name: (0, "txtPersoName", QLineEdit),
Character.goal: (0, "txtPersoGoal", QTextEdit),
Character.motivation: (0, "txtPersoMotivation", QTextEdit),
Character.conflict: (0, "txtPersoConflict", QTextEdit),
Character.epiphany: (0, "txtPersoEpiphany", QTextEdit),
Character.summarySentence: (0, "txtPersoSummarySentence", QTextEdit),
Character.summaryPara: (0, "txtPersoSummaryPara", QTextEdit),
Character.summaryFull: (1, "txtPersoSummaryFull", QTextEdit),
Character.notes: (2, "txtPersoNotes", QTextEdit),
Character.infos: (3, "tblPersoInfos", QTableView)
}
characterTabIndex, characterWidgetName, characterWidgetClass = textEditMap[searchResult.column()]
mainWindow().tabPersos.setCurrentIndex(characterTabIndex)
return mainWindow().tabPersos.findChild(characterWidgetClass, characterWidgetName)

View File

@ -0,0 +1,29 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.functions import mainWindow
from manuskript.enums import FlatData
from PyQt5.QtWidgets import QTextEdit, QLineEdit
from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter
class flatDataSearchResultHighlighter(abstractSearchResultHighlighter):
def __init__(self):
super().__init__()
def openView(self, searchResult):
mainWindow().tabMain.setCurrentIndex(mainWindow().TabSummary)
def retrieveWidget(self, searchResult):
editors = {
FlatData.summarySituation: (0, "txtSummarySituation", QLineEdit, mainWindow()),
FlatData.summarySentence: (0, "txtSummarySentence", QTextEdit, mainWindow().tabSummary),
FlatData.summaryPara: (1, "txtSummaryPara", QTextEdit, mainWindow().tabSummary),
FlatData.summaryPage: (2, "txtSummaryPage", QTextEdit, mainWindow().tabSummary),
FlatData.summaryFull: (3, "txtSummaryFull", QTextEdit, mainWindow().tabSummary)
}
stackIndex, editorName, editorClass, rootWidget = editors[searchResult.column()]
mainWindow().tabSummary.setCurrentIndex(stackIndex)
return rootWidget.findChild(editorClass, editorName)

View File

@ -0,0 +1,47 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.models import references as Ref
from manuskript.enums import Outline
from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter
from manuskript.functions import mainWindow
from PyQt5.QtWidgets import QTextEdit, QLineEdit, QLabel
from manuskript.ui.views.metadataView import metadataView
from manuskript.ui.collapsibleGroupBox2 import collapsibleGroupBox2
class outlineSearchResultHighlighter(abstractSearchResultHighlighter):
def __init__(self):
super().__init__()
self.outline_index = None
def openView(self, searchResult):
r = Ref.textReference(searchResult.id())
Ref.open(r)
def retrieveWidget(self, searchResult):
editors = {
Outline.text: ("txtRedacText", QTextEdit, None),
Outline.title: ("txtTitle", QLineEdit, "grpProperties"),
Outline.summarySentence: ("txtSummarySentence", QLineEdit, "grpSummary"),
Outline.summaryFull: ("txtSummaryFull", QTextEdit, "grpSummary"),
Outline.notes: ("txtNotes", QTextEdit, "grpNotes"),
# TODO: Tried to highlight the combo box themselves (ie. cmbPOV) but didn't succeed.
Outline.POV: ("lblPOV", QLabel, "grpProperties"),
Outline.status: ("lblStatus", QLabel, "grpProperties"),
Outline.label: ("lblLabel", QLabel, "grpProperties")
}
editorName, editorClass, parentName = editors[searchResult.column()]
# Metadata columns are inside a splitter widget that my be hidden, so we show them.
if parentName:
metadataViewWidget = mainWindow().findChild(metadataView, "redacMetadata")
metadataViewWidget.show()
metadataViewWidget.findChild(collapsibleGroupBox2, parentName).button.setChecked(True)
widget = metadataViewWidget.findChild(editorClass, editorName)
else:
widget = mainWindow().mainEditor.currentEditor().findChild(editorClass, editorName)
return widget

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.models import references as Ref
from manuskript.functions import mainWindow
from manuskript.enums import Plot
from PyQt5.QtWidgets import QTextEdit, QLineEdit, QListView
from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter
class plotSearchResultHighlighter(abstractSearchResultHighlighter):
def __init__(self):
super().__init__()
def openView(self, searchResult):
r = Ref.plotReference(searchResult.id())
Ref.open(r)
mainWindow().tabPlot.setEnabled(True)
def retrieveWidget(self, searchResult):
textEditMap = {
Plot.name: (0, "txtPlotName", QLineEdit),
Plot.description: (0, "txtPlotDescription", QTextEdit),
Plot.characters: (0, "lstPlotPerso", QListView),
Plot.result: (0, "txtPlotResult", QTextEdit)
}
tabIndex, widgetName, widgetClass = textEditMap[searchResult.column()]
mainWindow().tabPlot.setCurrentIndex(tabIndex)
return mainWindow().tabPlot.findChild(widgetClass, widgetName)

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.models import references as Ref
from manuskript.functions import mainWindow
from manuskript.enums import PlotStep
from PyQt5.QtWidgets import QTableView, QTextEdit
from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter
class plotStepSearchResultHighlighter(abstractSearchResultHighlighter):
def __init__(self):
super().__init__()
def openView(self, searchResult):
r = Ref.plotReference(searchResult.id())
Ref.open(r)
mainWindow().tabPlot.setEnabled(True)
def retrieveWidget(self, searchResult):
textEditMap = {
PlotStep.name: [(1, "lstSubPlots", QTableView)],
PlotStep.meta: [(1, "lstSubPlots", QTableView)],
PlotStep.summary: [(1, "lstSubPlots", QTableView), (1, "txtSubPlotSummary", QTextEdit)]
}
map = textEditMap[searchResult.column()]
widgets = []
for tabIndex, widgetName, widgetClass in map:
mainWindow().tabPlot.setCurrentIndex(tabIndex)
widgets.append(mainWindow().tabPlot.findChild(widgetClass, widgetName))
return widgets

View File

@ -0,0 +1,34 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.ui.highlighters.searchResultHighlighters.abstractSearchResultHighlighter import abstractSearchResultHighlighter
from manuskript.ui.highlighters.searchResultHighlighters.characterSearchResultHighlighter import characterSearchResultHighlighter
from manuskript.ui.highlighters.searchResultHighlighters.flatDataSearchResultHighlighter import flatDataSearchResultHighlighter
from manuskript.ui.highlighters.searchResultHighlighters.outlineSearchResultHighlighter import outlineSearchResultHighlighter
from manuskript.ui.highlighters.searchResultHighlighters.worldSearchResultHighlighter import worldSearchResultHighlighter
from manuskript.ui.highlighters.searchResultHighlighters.plotSearchResultHighlighter import plotSearchResultHighlighter
from manuskript.ui.highlighters.searchResultHighlighters.plotStepSearchResultHighlighter import plotStepSearchResultHighlighter
from manuskript.enums import Model
class searchResultHighlighter(abstractSearchResultHighlighter):
def __init__(self):
super().__init__()
def highlightSearchResult(self, searchResult):
if searchResult.type() == Model.Character:
highlighter = characterSearchResultHighlighter()
elif searchResult.type() == Model.FlatData:
highlighter = flatDataSearchResultHighlighter()
elif searchResult.type() == Model.Outline:
highlighter = outlineSearchResultHighlighter()
elif searchResult.type() == Model.World:
highlighter = worldSearchResultHighlighter()
elif searchResult.type() == Model.Plot:
highlighter = plotSearchResultHighlighter()
elif searchResult.type() == Model.PlotStep:
highlighter = plotStepSearchResultHighlighter()
else:
raise NotImplementedError
highlighter.highlightSearchResult(searchResult)

View File

@ -0,0 +1,91 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from PyQt5.QtGui import QTextCursor
from PyQt5.QtWidgets import QTextEdit, QTableView, QListView, QLineEdit, QPlainTextEdit, QLabel
class widgetSelectionHighlighter():
"""
Utility class for highlighting a search result on a widget.
"""
def __init__(self):
pass
def highlight_widget_selection(self, widget, startPos, endPos, clearOnFocusOut=True):
if isinstance(widget, QTextEdit) or isinstance(widget, QPlainTextEdit):
self._highlightTextEditSearchResult(widget, startPos, endPos, clearOnFocusOut)
elif isinstance(widget, QLineEdit):
self._highlightLineEditSearchResult(widget, startPos, endPos, clearOnFocusOut)
elif isinstance(widget, QTableView):
self._highlightTableViewSearchResult(widget, startPos, clearOnFocusOut)
elif isinstance(widget, QListView):
self._highlightListViewSearchResult(widget, startPos, clearOnFocusOut)
elif isinstance(widget, QLabel):
self._highlightLabelSearchResult(widget, clearOnFocusOut)
else:
raise NotImplementedError
widget.setFocus(True)
@staticmethod
def generateClearHandler(widget, clearCallback):
"""
Generates a clear handler to be run when the given widget loses focus.
:param widget: widget we want to attach the handler to
:param clearCallback: callback to be called when the given widget loses focus.
:return:
"""
def clearHandler(_widget, previous_on_focus_out_event):
clearCallback(_widget)
_widget.focusOutEvent = previous_on_focus_out_event
widget.focusOutEvent = lambda e: clearHandler(widget, widget.focusOutEvent)
def _highlightTextEditSearchResult(self, textEdit, startPos, endPos, clearOnFocusOut):
# On focus out, clear text edit selection.
oldTextCursor = textEdit.textCursor()
if clearOnFocusOut:
self.generateClearHandler(textEdit, lambda widget: widget.setTextCursor(oldTextCursor))
# Highlight search result on the text edit.
c = textEdit.textCursor()
c.setPosition(startPos)
c.setPosition(endPos, QTextCursor.KeepAnchor)
textEdit.setTextCursor(c)
def _highlightLineEditSearchResult(self, lineEdit, startPos, endPos, clearOnFocusOut):
# On focus out, clear line edit selection.
if clearOnFocusOut:
self.generateClearHandler(lineEdit, lambda widget: widget.deselect())
# Highlight search result on line edit.
lineEdit.setCursorPosition(startPos)
lineEdit.cursorForward(True, endPos - startPos)
def _highlightTableViewSearchResult(self, tableView, startPos, clearOnFocusOut):
# On focus out, clear table selection.
if clearOnFocusOut:
self.generateClearHandler(tableView, lambda widget: widget.clearSelection())
# Highlight table row containing search result.
tableView.selectRow(startPos)
def _highlightListViewSearchResult(self, listView, startPos, clearOnFocusOut):
# On focus out, clear table selection.
if clearOnFocusOut:
self.generateClearHandler(listView, lambda widget: widget.selectionModel().clearSelection())
# Highlight list item containing search result.
listView.setCurrentIndex(listView.model().index(startPos, 0, listView.rootIndex()))
def _highlightLabelSearchResult(self, label, clearOnFocusOut):
# On focus out, clear label selection.
# FIXME: This would overwrite all styles!
oldStyle = label.styleSheet()
if clearOnFocusOut:
self.generateClearHandler(label, lambda widget: widget.setStyleSheet(oldStyle))
# Highlight search result on label.
label.setStyleSheet("background-color: steelblue")

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from manuskript.models import references as Ref
from manuskript.functions import mainWindow
from manuskript.enums import World
from PyQt5.QtWidgets import QTextEdit, QLineEdit
from manuskript.ui.highlighters.searchResultHighlighters.abstractSpecificSearchResultHighlighter import abstractSearchResultHighlighter
class worldSearchResultHighlighter(abstractSearchResultHighlighter):
def __init__(self):
super().__init__()
def openView(self, searchResult):
r = Ref.worldReference(searchResult.id())
Ref.open(r)
mainWindow().tabWorld.setEnabled(True)
def retrieveWidget(self, searchResult):
textEditMap = {
World.name: (0, "txtWorldName", QLineEdit),
World.description: (0, "txtWorldDescription", QTextEdit),
World.passion: (1, "txtWorldPassion", QTextEdit),
World.conflict: (1, "txtWorldConflict", QTextEdit),
}
tabIndex, widgetName, widgetClass = textEditMap[searchResult.column()]
mainWindow().tabWorld.setCurrentIndex(tabIndex)
return mainWindow().tabWorld.findChild(widgetClass, widgetName)

View File

@ -1282,6 +1282,10 @@ class Ui_MainWindow(object):
self.actFormatList.setObjectName("actFormatList")
self.actFormatBlockquote = QtWidgets.QAction(MainWindow)
self.actFormatBlockquote.setObjectName("actFormatBlockquote")
self.actSearch = QtWidgets.QAction(MainWindow)
icon = QtGui.QIcon.fromTheme("edit-find")
self.actSearch.setIcon(icon)
self.actSearch.setObjectName("actSearch")
self.menuFile.addAction(self.actOpen)
self.menuFile.addAction(self.menuRecents.menuAction())
self.menuFile.addAction(self.actSave)
@ -1325,6 +1329,7 @@ class Ui_MainWindow(object):
self.menuEdit.addAction(self.actCopy)
self.menuEdit.addAction(self.actPaste)
self.menuEdit.addAction(self.actDelete)
self.menuEdit.addAction(self.actSearch)
self.menuEdit.addAction(self.actRename)
self.menuEdit.addSeparator()
self.menuEdit.addAction(self.mnuFormat.menuAction())
@ -1648,6 +1653,9 @@ class Ui_MainWindow(object):
self.actFormatOrderedList.setText(_translate("MainWindow", "&Ordered list"))
self.actFormatList.setText(_translate("MainWindow", "&Unordered list"))
self.actFormatBlockquote.setText(_translate("MainWindow", "B&lockquote"))
self.actSearch.setText(_translate("MainWindow", "Search"))
self.actSearch.setShortcut(_translate("MainWindow", "Ctrl+F"))
from manuskript.ui.cheatSheet import cheatSheet
from manuskript.ui.editors.mainEditor import mainEditor
from manuskript.ui.search import search

View File

@ -2203,6 +2203,7 @@
<addaction name="actCopy"/>
<addaction name="actPaste"/>
<addaction name="actDelete"/>
<addaction name="actSearch"/>
<addaction name="actRename"/>
<addaction name="separator"/>
<addaction name="mnuFormat"/>
@ -2838,6 +2839,17 @@
<string>B&amp;lockquote</string>
</property>
</action>
<action name="actSearch">
<property name="icon">
<iconset theme="edit-find"/>
</property>
<property name="text">
<string>Search</string>
</property>
<property name="shortcut">
<string>Ctrl+F</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>

View File

@ -1,147 +1,151 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from PyQt5.QtCore import Qt, QRect
from PyQt5.QtGui import QPalette, QFontMetrics
from PyQt5.QtWidgets import QWidget, QMenu, QAction, qApp, QListWidgetItem, QStyledItemDelegate, QStyle
from PyQt5.QtCore import Qt, QRect, QEvent, QCoreApplication
from PyQt5.QtGui import QPalette, QFontMetrics, QKeySequence
from PyQt5.QtWidgets import QWidget, qApp, QListWidgetItem, QStyledItemDelegate, QStyle, QLabel, QToolTip, QShortcut
from manuskript.enums import Outline
from manuskript.functions import mainWindow
from manuskript.ui import style
from manuskript.ui.search_ui import Ui_search
from manuskript.models import references as Ref
from manuskript.enums import Model
from manuskript.models.flatDataModelWrapper import flatDataModelWrapper
from manuskript.ui.searchMenu import searchMenu
from manuskript.ui.highlighters.searchResultHighlighters.searchResultHighlighter import searchResultHighlighter
class search(QWidget, Ui_search):
def __init__(self, parent=None):
_translate = QCoreApplication.translate
QWidget.__init__(self, parent)
self.setupUi(self)
self.options = {
"All": True,
"Title": True,
"Text": True,
"Summary": False,
"Notes": False,
"POV": False,
"Status": False,
"Label": False,
"CS": True
}
self.searchTextInput.returnPressed.connect(self.search)
self.text.returnPressed.connect(self.search)
self.generateOptionMenu()
self.searchMenu = searchMenu()
self.btnOptions.setMenu(self.searchMenu)
self.delegate = listResultDelegate(self)
self.result.setItemDelegate(self.delegate)
self.result.setMouseTracking(True)
self.result.itemClicked.connect(self.openItem)
self.result.setStyleSheet(style.searchResultSS())
self.text.setStyleSheet(style.lineEditSS())
self.searchTextInput.setStyleSheet(style.lineEditSS())
def generateOptionMenu(self):
self.menu = QMenu(self)
a = QAction(self.tr("Search in:"), self.menu)
a.setEnabled(False)
self.menu.addAction(a)
for i, d in [
(self.tr("All"), "All"),
(self.tr("Title"), "Title"),
(self.tr("Text"), "Text"),
(self.tr("Summary"), "Summary"),
(self.tr("Notes"), "Notes"),
(self.tr("POV"), "POV"),
(self.tr("Status"), "Status"),
(self.tr("Label"), "Label"),
]:
a = QAction(i, self.menu)
a.setCheckable(True)
a.setChecked(self.options[d])
a.setData(d)
a.triggered.connect(self.updateOptions)
self.menu.addAction(a)
self.menu.addSeparator()
self.searchResultHighlighter = searchResultHighlighter()
a = QAction(self.tr("Options:"), self.menu)
a.setEnabled(False)
self.menu.addAction(a)
for i, d in [
(self.tr("Case sensitive"), "CS"),
]:
a = QAction(i, self.menu)
a.setCheckable(True)
a.setChecked(self.options[d])
a.setData(d)
a.triggered.connect(self.updateOptions)
self.menu.addAction(a)
self.menu.addSeparator()
self.noResultsLabel = QLabel(_translate("Search", "No results found"), self.result)
self.noResultsLabel.setVisible(False)
self.noResultsLabel.setStyleSheet("QLabel {color: gray;}")
self.btnOptions.setMenu(self.menu)
# Add shortcuts for navigating through search results
QShortcut(QKeySequence(_translate("MainWindow", "F3")), self.searchTextInput, self.nextSearchResult)
QShortcut(QKeySequence(_translate("MainWindow", "Shift+F3")), self.searchTextInput, self.previousSearchResult)
def updateOptions(self):
a = self.sender()
self.options[a.data()] = a.isChecked()
# These texts are already included in translation files but including ":" at the end. We force here the
# translation for them without ":"
_translate("MainWindow", "Situation")
_translate("MainWindow", "Status")
def nextSearchResult(self):
if self.result.currentRow() < self.result.count() - 1:
self.result.setCurrentRow(self.result.currentRow() + 1)
else:
self.result.setCurrentRow(0)
if 0 < self.result.currentRow() < self.result.count():
self.openItem(self.result.currentItem())
def previousSearchResult(self):
if self.result.currentRow() > 0:
self.result.setCurrentRow(self.result.currentRow() - 1)
else:
self.result.setCurrentRow(self.result.count() - 1)
if 0 < self.result.currentRow() < self.result.count():
self.openItem(self.result.currentItem())
def prepareRegex(self, searchText):
import re
flags = re.UNICODE
if self.searchMenu.caseSensitive() is False:
flags |= re.IGNORECASE
if self.searchMenu.regex() is False:
searchText = re.escape(searchText)
if self.searchMenu.matchWords() is True:
# Source: https://stackoverflow.com/a/15863102
searchText = r'\b' + searchText + r'\b'
return re.compile(searchText, flags)
def search(self):
text = self.text.text()
# Choosing the right columns
lstColumns = [
("Title", Outline.title),
("Text", Outline.text),
("Summary", Outline.summarySentence),
("Summary", Outline.summaryFull),
("Notes", Outline.notes),
("POV", Outline.POV),
("Status", Outline.status),
("Label", Outline.label),
]
columns = [c[1] for c in lstColumns if self.options[c[0]] or self.options["All"]]
# Setting override cursor
qApp.setOverrideCursor(Qt.WaitCursor)
# Searching
model = mainWindow().mdlOutline
results = model.findItemsContaining(text, columns, self.options["CS"])
# Showing results
self.result.clear()
for r in results:
index = model.getIndexByID(r)
if not index.isValid():
continue
item = index.internalPointer()
i = QListWidgetItem(item.title(), self.result)
i.setData(Qt.UserRole, r)
i.setData(Qt.UserRole + 1, item.path())
self.result.addItem(i)
self.result.setCurrentRow(0)
# Removing override cursor
qApp.restoreOverrideCursor()
searchText = self.searchTextInput.text()
if len(searchText) > 0:
searchRegex = self.prepareRegex(searchText)
results = []
# Set override cursor
qApp.setOverrideCursor(Qt.WaitCursor)
for model, modelName in [
(mainWindow().mdlOutline, Model.Outline),
(mainWindow().mdlCharacter, Model.Character),
(flatDataModelWrapper(mainWindow().mdlFlatData), Model.FlatData),
(mainWindow().mdlWorld, Model.World),
(mainWindow().mdlPlots, Model.Plot)
]:
filteredColumns = self.searchMenu.columns(modelName)
# Searching
if len(filteredColumns):
results += model.searchOccurrences(searchRegex, filteredColumns)
# Showing results
self.generateResultsLists(results)
# Remove override cursor
qApp.restoreOverrideCursor()
def generateResultsLists(self, results):
self.noResultsLabel.setVisible(len(results) == 0)
for result in results:
item = QListWidgetItem(result.title(), self.result)
item.setData(Qt.UserRole, result)
item.setData(Qt.UserRole + 1, ' > '.join(result.path()))
item.setData(Qt.UserRole + 2, result.context())
self.result.addItem(item)
def openItem(self, item):
r = Ref.textReference(item.data(Qt.UserRole))
Ref.open(r)
# mw = mainWindow()
# index = mw.mdlOutline.getIndexByID(item.data(Qt.UserRole))
# mw.mainEditor.setCurrentModelIndex(index, newTab=True)
self.searchResultHighlighter.highlightSearchResult(item.data(Qt.UserRole))
def leaveEvent(self, event):
self.delegate.mouseLeave()
class listResultDelegate(QStyledItemDelegate):
def __init__(self, parent=None):
QStyledItemDelegate.__init__(self, parent)
self._tooltipRowIndex = -1
def paint(self, painter, option, index):
extra = index.data(Qt.UserRole + 1)
if not extra:
return QStyledItemDelegate.paint(self, painter, option, index)
else:
if option.state & QStyle.State_Selected:
painter.fillRect(option.rect, option.palette.color(QPalette.Highlight))
title = index.data()
extra = " - {}".format(extra)
painter.drawText(option.rect.adjusted(2, 1, 0, 0), Qt.AlignLeft, title)
fm = QFontMetrics(option.font)
@ -153,5 +157,18 @@ class listResultDelegate(QStyledItemDelegate):
painter.setPen(Qt.white)
else:
painter.setPen(Qt.gray)
painter.drawText(r.adjusted(2, 1, 0, 0), Qt.AlignLeft, extra)
painter.drawText(r.adjusted(2, 1, 0, 0), Qt.AlignLeft, " - {}".format(extra))
painter.restore()
def editorEvent(self, event, model, option, index):
if event.type() == QEvent.MouseMove and self._tooltipRowIndex != index.row():
self._tooltipRowIndex = index.row()
context = index.data(Qt.UserRole + 2)
extra = index.data(Qt.UserRole + 1)
QToolTip.showText(event.globalPos(),
"<p>#" + str(index.row()) + " - " + extra + "</p><p>" + context + "</p>")
return True
return False
def mouseLeave(self):
self._tooltipRowIndex = -1

108
manuskript/ui/searchMenu.py Normal file
View File

@ -0,0 +1,108 @@
#!/usr/bin/env python
# --!-- coding: utf8 --!--
from PyQt5.QtWidgets import QMenu, QAction
from PyQt5.QtCore import QCoreApplication
from PyQt5 import QtCore
from manuskript.searchLabels import OutlineSearchLabels, CharacterSearchLabels, FlatDataSearchLabels, WorldSearchLabels, PlotSearchLabels
from manuskript.models.searchFilter import searchFilter
from manuskript.enums import Model
def filterKey(modelPreffix, column):
return modelPreffix + str(column)
class searchMenu(QMenu):
def __init__(self, parent=None):
QMenu.__init__(self, parent)
_translate = QCoreApplication.translate
# Model keys must match the ones used in search widget class
self.filters = {
Model.Outline: searchFilter(_translate("MainWindow", "Outline"), True, list(OutlineSearchLabels.keys())),
Model.Character: searchFilter(_translate("MainWindow", "Characters"), True, list(CharacterSearchLabels.keys())),
Model.FlatData: searchFilter(_translate("MainWindow", "FlatData"), True, list(FlatDataSearchLabels.keys())),
Model.World: searchFilter(_translate("MainWindow", "World"), True, list(WorldSearchLabels.keys())),
Model.Plot: searchFilter(_translate("MainWindow", "Plot"), True, list(PlotSearchLabels.keys()))
}
self.options = {
"CS": [self.tr("Case sensitive"), True],
"MatchWords": [self.tr("Match words"), False],
"Regex": [self.tr("Regex"), False]
}
self._generateOptions()
def _generateOptions(self):
a = QAction(self.tr("Search in:"), self)
a.setEnabled(False)
self.addAction(a)
for filterKey in self.filters:
a = QAction(self.tr(self.filters[filterKey].label()), self)
a.setCheckable(True)
a.setChecked(self.filters[filterKey].enabled())
a.setData(filterKey)
a.triggered.connect(self._updateFilters)
self.addAction(a)
self.addSeparator()
a = QAction(self.tr("Options:"), self)
a.setEnabled(False)
self.addAction(a)
for optionKey in self.options:
a = QAction(self.options[optionKey][0], self)
a.setCheckable(True)
a.setChecked(self.options[optionKey][1])
a.setData(optionKey)
a.triggered.connect(self._updateOptions)
self.addAction(a)
self.addSeparator()
def _updateFilters(self):
a = self.sender()
self.filters[a.data()].setEnabled(a.isChecked())
def _updateOptions(self):
a = self.sender()
self.options[a.data()][1] = a.isChecked()
def columns(self, modelName):
if self.filters[modelName].enabled():
return self.filters[modelName].modelColumns()
else:
return []
def caseSensitive(self):
return self.options["CS"][1]
def matchWords(self):
return self.options["MatchWords"][1]
def regex(self):
return self.options["Regex"][1]
def mouseReleaseEvent(self, event):
# Workaround for enabling / disabling actions without closing the menu.
# Source: https://stackoverflow.com/a/14967212
action = self.activeAction()
if action:
action.setEnabled(False)
QMenu.mouseReleaseEvent(self, event)
action.setEnabled(True)
action.trigger()
else:
QMenu.mouseReleaseEvent(self, event)
def keyPressEvent(self, event):
# Workaround for enabling / disabling actions without closing the menu.
# Source: https://stackoverflow.com/a/14967212
action = self.activeAction()
if action and event.key() == QtCore.Qt.Key_Return:
action.setEnabled(False)
QMenu.keyPressEvent(self, event)
action.setEnabled(True)
action.trigger()
else:
QMenu.keyPressEvent(self, event)

View File

@ -19,12 +19,12 @@ class Ui_search(object):
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setSpacing(0)
self.horizontalLayout.setObjectName("horizontalLayout")
self.text = QtWidgets.QLineEdit(search)
self.text.setInputMask("")
self.text.setFrame(False)
self.text.setClearButtonEnabled(True)
self.text.setObjectName("text")
self.horizontalLayout.addWidget(self.text)
self.searchTextInput = QtWidgets.QLineEdit(search)
self.searchTextInput.setInputMask("")
self.searchTextInput.setFrame(False)
self.searchTextInput.setClearButtonEnabled(True)
self.searchTextInput.setObjectName("searchTextInput")
self.horizontalLayout.addWidget(self.searchTextInput)
self.btnOptions = QtWidgets.QPushButton(search)
self.btnOptions.setText("")
icon = QtGui.QIcon.fromTheme("edit-find")
@ -45,5 +45,5 @@ class Ui_search(object):
def retranslateUi(self, search):
_translate = QtCore.QCoreApplication.translate
search.setWindowTitle(_translate("search", "Form"))
self.text.setPlaceholderText(_translate("search", "Search for..."))
self.searchTextInput.setPlaceholderText(_translate("search", "Search for..."))

View File

@ -35,7 +35,7 @@
<number>0</number>
</property>
<item>
<widget class="QLineEdit" name="text">
<widget class="QLineEdit" name="searchTextInput">
<property name="inputMask">
<string/>
</property>

View File

@ -43,11 +43,19 @@ class corkDelegate(QStyledItemDelegate):
return QStyledItemDelegate.editorEvent(self, event, model, option, index)
def createEditor(self, parent, option, index):
# When the user performs a global search and selects an Outline result (title or summary), the
# associated chapter is selected in cork view, triggering a call to this method with the results
# list widget set in self.sender(). In this case we store the searched column so we know which
# editor should be created.
searchedColumn = None
if self.sender() is not None and self.sender().objectName() == 'result' and self.sender().currentItem():
searchedColumn = self.sender().currentItem().data(Qt.UserRole).column()
self.updateRects(option, index)
bgColor = self.bgColors.get(index, "white")
if self.mainLineRect.contains(self.lastPos):
if searchedColumn == Outline.summarySentence or (self.lastPos is not None and self.mainLineRect.contains(self.lastPos)):
# One line summary
self.editing = Outline.summarySentence
edt = QLineEdit(parent)
@ -64,7 +72,7 @@ class corkDelegate(QStyledItemDelegate):
edt.setStyleSheet("background: {}; color: black;".format(bgColor))
return edt
elif self.titleRect.contains(self.lastPos):
elif searchedColumn == Outline.title or (self.lastPos is not None and self.titleRect.contains(self.lastPos)):
# Title
self.editing = Outline.title
edt = QLineEdit(parent)