Skip to content

Commit

Permalink
Fix occasional crashes when (re)moving items
Browse files Browse the repository at this point in the history
Describing all the rabbitholes that I and kakaroto have gone through
while debugging this one until dawn can frankly not do enough justice to
the crazy amount of rubberducking that went on while trying to fix this.

This bug would be triggered whenever you had a document open in the
editor and then moved an ancestor object downwards (visually) in the tree.
Or when you simply deleted the ancestor. Depending on the exact method
that caused the opened item to be removed from the internal model, the
exact nature of the bug would vary, which means this commit fixes a few
different bits of code that lead to what appears to be the same bug.

In order of appearance, the bugs that ruined our sleep were:

1) The editor widget was trying to handle the removed item at too late a
stage.

2) The editor widget tried to fix its view after a move by searching for
the new item with the same ID, but in the case of moving an object down
it came across its own old item, ruining the attempt.

3) The editor widget did not properly account for the hierarchical
nature of the model.

Upon fixing these the next day, it was revealed that:

4) The outlineItem.updateWordCount(emit=False) flag is broken. This
function would call setData() in several spots which would still cause
emits to bubble through the system despite emit=False, and we simply got
lucky that it stopped enough of them until now.

This last one was caused by a small mistake in the fixes for the first
three bugs, but it has led to a couple of extra changes to make any
future bug hunts slightly less arduous and frustrating:

a) When calling item.removeChild(c), it now resets the associated parent
and model to mirror item.insertChild(c). This has also led to an extra
check in model.parent() to check for its validity.

b) The outlineItem.updateWordCount(emit=) flag has been removed entirely
and it now emits away with reckless abandon. I have been unable to
reproduce the crashes the code warned about, so I consider this a code
quality fix to prevent mysterious future issues where things sometimes
do not properly update right.

Worthy of note is that the original code clearly showed the intention to
close tabs for items that were removed. Reworking the editor to support
closing a tab is unfortunately way out of scope, so this intention was
left in and the new fix was structured to make it trivial to implement
such a change when the time comes. An existing FIXME regarding unrelated
buggy editor behaviour was left in, too.

Many thanks to Kakaroto for burning the midnight oil with me to get to
the bottom of this. (I learned a lot that night!)

Issues olivierkes#479 and olivierkes#559 are fixed by this commit. And maybe some others,
too.
  • Loading branch information
worstje committed May 2, 2019
1 parent 5ab745b commit 4b4b8dd
Show file tree
Hide file tree
Showing 5 changed files with 69 additions and 28 deletions.
3 changes: 3 additions & 0 deletions manuskript/models/abstractItem.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ def removeChild(self, row):
@return: the removed abstractItem
"""
r = self.childItems.pop(row)
# Disassociate the child from its parent and the model.
r._parent = None
r.setModel(None)
return r

def parent(self):
Expand Down
20 changes: 16 additions & 4 deletions manuskript/models/abstractModel.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,15 @@ def findItemsContaining(self, text, columns, caseSensitive=False):
"""
return self.rootItem.findItemsContaining(text, columns, mainWindow(), caseSensitive)

def getItemByID(self, ID):
def getItemByID(self, ID, ignore=None):
"""Returns the item whose ID is `ID`, unless this item matches `ignore`."""

def search(item):
if item.ID() == ID:
if item == ignore:
# The item we really want won't be found in the children of this
# particular item anymore; stop searching this branch entirely.
return None
return item
for c in item.children():
r = search(c)
Expand All @@ -104,9 +110,12 @@ def search(item):
item = search(self.rootItem)
return item

def getIndexByID(self, ID, column=0):
"Returns the index of item whose ID is `ID`. If none, returns QModelIndex()."
item = self.getItemByID(ID)
def getIndexByID(self, ID, column=0, ignore=None):
"""Returns the index of item whose ID is `ID`. If none, returns QModelIndex().
It will ignore the given item as valid match for the ID."""

item = self.getItemByID(ID, ignore=ignore)
if not item:
return QModelIndex()
else:
Expand All @@ -122,6 +131,9 @@ def parent(self, index=QModelIndex()):
if parentItem == self.rootItem:
return QModelIndex()

if parentItem == None:
return QModelIndex()

return self.createIndex(parentItem.row(), 0, parentItem)

def rowCount(self, parent=QModelIndex()):
Expand Down
15 changes: 6 additions & 9 deletions manuskript/models/outlineItem.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,11 @@ def insertChild(self, row, child):

def removeChild(self, row):
r = abstractItem.removeChild(self, row)
# Might be causing segfault when updateWordCount emits dataChanged
self.updateWordCount(emit=False)
self.updateWordCount()
return r

def updateWordCount(self, emit=True):
"""Update word count for item and parents.
If emit is False, no signal is emitted (sometimes cause segfault)"""
def updateWordCount(self):
"""Update word count for item and parents."""
if not self.isFolder():
setGoal = F.toInt(self.data(self.enum.setGoal))
goal = F.toInt(self.data(self.enum.goal))
Expand Down Expand Up @@ -219,12 +217,11 @@ def updateWordCount(self, emit=True):
else:
self.setData(self.enum.goalPercentage, "")

if emit:
self.emitDataChanged([self.enum.goal, self.enum.setGoal,
self.enum.wordCount, self.enum.goalPercentage])
self.emitDataChanged([self.enum.goal, self.enum.setGoal,
self.enum.wordCount, self.enum.goalPercentage])

if self.parent():
self.parent().updateWordCount(emit)
self.parent().updateWordCount()

def stats(self):
wc = self.data(enums.Outline.wordCount)
Expand Down
57 changes: 43 additions & 14 deletions manuskript/ui/editors/editorWidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,8 @@ def addSpacer():

try:
self._model.dataChanged.connect(self.modelDataChanged, AUC)
self._model.rowsInserted.connect(self.updateIndexFromID, AUC)
self._model.rowsRemoved.connect(self.updateIndexFromID, AUC)
#self.mw.mdlOutline.rowsAboutToBeRemoved.connect(self.rowsAboutToBeRemoved, AUC)
self._model.rowsInserted.connect(self.rowsInserted, AUC)
self._model.rowsAboutToBeRemoved.connect(self.rowsAboutToBeRemoved, AUC)
except TypeError:
pass

Expand All @@ -313,17 +312,26 @@ def setCurrentModelIndex(self, index=None):
if self._model:
self.setView()

def updateIndexFromID(self):
def updateIndexFromID(self, fallback=None, ignore=None):
"""
Index might have changed (through drag an drop), so we keep current
item's ID and update index. Item might have been deleted too.
It will ignore the passed model item to avoid ambiguity during times
of inconsistent state.
"""
idx = self._model.getIndexByID(self.currentID)

# If we have an ID but the ID does not exist, it has been deleted
idx = self._model.getIndexByID(self.currentID, ignore=ignore)

# If we have an ID but the ID does not exist, it has been deleted.
if self.currentID and idx == QModelIndex():
# Item has been deleted, we open the parent instead
self.setCurrentModelIndex(self.currentIndex.parent())
# If we are given a fallback item to display, do so.
if fallback:
self.setCurrentModelIndex(fallback)
else:
#self.mw.mainEditor.tabSplitter.tab.removeTab(self.mw.mainEditor.tab.indexOf(self))
self.setCurrentModelIndex(None) # TODO: change this to close the tab.

# FIXME: selection in self.mw.treeRedacOutline is not updated
# but we cannot simply setCurrentIndex through treeRedacOutline
# because this might be a tab in the background / out of focus
Expand All @@ -344,12 +352,33 @@ def modelDataChanged(self, topLeft, bottomRight):
if topLeft.row() <= self.currentIndex.row() <= bottomRight.row():
self.updateStatusBar()

#def rowsAboutToBeRemoved(self, parent, first, last):
#if self.currentIndex:
#if self.currentIndex.parent() == parent and \
#first <= self.currentIndex.row() <= last:
## Item deleted, close tab
#self.mw.mainEditor.tab.removeTab(self.mw.mainEditor.tab.indexOf(self))
def rowsInserted(self, parent, first, last):
if self.currentIndex:
self.updateIndexFromID(fallback=self.currentIndex.parent())

def rowsAboutToBeRemoved(self, parent, first, last):
if not self.currentIndex:
return

# Check parents until we find a parent that matches the parent we are removing.
crumb = self.currentIndex
commonality = crumb.parent() # start at folder above current text
while (commonality != parent):
crumb = commonality
commonality = commonality.parent()

if commonality == None:
return # we ran out of parents without finding the matching QModelIndex

if commonality == parent:
break # we found our shared parent

# Verify our origins come from the relevant first..last range.
if first <= crumb.row() <= last:
# If the row in question was actually moved, there is a duplicate item
# already inserted elsewhere in the tree. Try to update this tab view,
# but make sure we exclude ourselves from the search for a replacement.
self.updateIndexFromID(fallback=parent, ignore=self.currentIndex.internalPointer())

def updateStatusBar(self):
# Update progress
Expand Down
2 changes: 1 addition & 1 deletion manuskript/ui/views/outlineBasics.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ def moveIndex(self, index, delta=1):

parentItem.childItems.insert(index.row() + delta,
parentItem.childItems.pop(index.row()))
parentItem.updateWordCount(emit=False)
parentItem.updateWordCount()

def moveUp(self): self.move(-1)
def moveDown(self): self.move(+1)
Expand Down

0 comments on commit 4b4b8dd

Please sign in to comment.