#
# This file is part of TrackChanges
# Copyright 2009 Novimir Antoniuk Pablant
#
# TrackChanges 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 2 of the License, or
# (at your option) any later version.
#
# TrackChanges 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 TrackChanges. If not, see .
#
########################################################################
# trackchanges.py
########################################################################
# This program is a way to easly accept or regect changes made in
# latex using the trackchanges.sty package.
#
# To find the latest versions of this software and of the latex style
# file go to http://trackchanges.sourceforge.net/
#
#
#
# Written: 2009-03-09 by Novimir Pablant
#
# Version Log:
# 0.1.0 - 2009-03-09 - novi
# 0.1.1 - 2009-03-14 - novi - Now properly handles nested brackets.
# Added some error handling.
# Editing in the main pane is now possible.
# Fixed a major bug with how the mutex
# was being handled.
# 0.1.2 - 2009-03-17 - novi - Better error handling. Now will close
# mutex on error. Error dialogs.
# Saving does not stop editing.
# - 2009-03-20 - novi - Will ask about save on exit.
# Added about dialog.
# - 2009-04-05 - novi - Added a help dialog. Fix regexes.
# Added the refneeded command.
# - 2009-04-22 - novi - Changed path to help files.
#
########################################################################
#
# Current bugs/limitations:
#
# - When the search/edit is paused because the main window is being
# edited, the old, new & note planes need to be greyed out.
# Also do I want edits made in those boxes to be retained?
#
# - Scroll location is generally set so that only the first line of
# edit can be seen without scrolling.
#
# - If the help browser is opened, then when TrackChanges is closed
# A warning about a QWaitCondition is displayed. This appears
# to be a Qt/PyQt bug.
#
# ----------------------------------------------------------------------
# Stuff to do
#
# - Enable syntax highlighting.
#
# - Highlight the previous edit.
#
# - Add a recursive mode. In this mode TrackChanges will search for
# \include or \input, and then open those files for editing in turn.
#
# - Try to inteligently deal with white space, especially in the case
# of the \remove command. What I find often happens is that when
# a \remove{is used before a period or comma}, an extra space can be
# left after the text is removed.
#
# - The windows binary is really big. I might be able to shrink this
# by limiting what is imported. Or something. 10MB is ridiculus.
#
# - Well the Mac binary is even more riduclus 45MB. Yuck.
########################################################################
import sys
import os
import re
import traceback
from PyQt4 import QtGui
from PyQt4 import QtCore
import datetime
# Initialize the Qt application
app = QtGui.QApplication(sys.argv)
__version__ = (0,1,2,'Beta')
__version_date__ = datetime.date(2009,4,22)
# ======================================================================
# ======================================================================
# Setup the main driving class.
#
# This class handles the folowing things:
# - Initializes program
# - Initializes gui
# - Loads/saves TeX files
# - Starts main search/editing thread.
# - Handles signals betten thread and gui.
# - Handles higlighting of main window
# - Keeps original and new TeX files in sync.
class TrackChanges(QtCore.QObject):
def __init__(self, parent=None):
global ui_base_window
global trackchanges_main
trackchanges_main = self
QtCore.QObject.__init__(self,parent)
ui_base_window = UiBaseWindow(None)
ui_base_window.show()
# Setup some defaults
self.current_path = r"/"
# Setup regex patters
self.setupRegex()
# Setup the wait conditions for the seach/edit thread
self.button_wait = QtCore.QWaitCondition()
self.text_wait = QtCore.QWaitCondition()
self.current_action = ''
self.old_text = ''
self.new_text = ''
self.search_edit_thread_active = False
# This is used to keep track of the current search position
self.search_position = 0
# Use this to keep track of whether the current
# document has been modified
self.doc_saved = True
# Setup some error tracking.
# This is used to help with saving actions.
self.hadError = False
# Set up some options
self.debug = 1
def exit(self):
# Put the loop here incase the save gets canceled.
while True:
if not self.doc_saved:
result = self.messageBoxDocModifed()
if result == QtGui.QMessageBox.Save:
self.saveTexFile()
continue
elif result == QtGui.QMessageBox.Discard:
output = 'close'
break
elif result == QtGui.QMessageBox.Cancel:
output = 'cancel'
break
else:
output = 'close'
break
return output
def setupRegex(self):
"""
Here the regular expression patterns needed for the search are setup.
A pattern is set up for each of the six commands, then all of the
patterns are joined.
These patterns will optionally match the editor name. spaces are
allowed between the command and any brackets.
"""
self.command_names = ['note', 'add', 'remove', 'annote', 'change', 'refneeded']
self.regex_strings = {}
self.regex_strings['note'] = \
r"(?P(?P\\note) *(?P\[.*?\])?(?= *\{))"
self.regex_strings['add'] = \
r"(?P(?P\\add) *(?P\[.*?\])?(?= *\{))"
self.regex_strings['remove'] = \
r"(?P(?P\\remove) *(?P\[.*?\])?(?= *\{))"
self.regex_strings['refneeded'] = \
r"(?P(?P\\refneeded) *(?P\[.*?\])?(?= *\{))"
self.regex_strings['annote'] = \
r"(?P(?P\\annote) *(?P\[.*?\])?(?= *\{))"
self.regex_strings['change'] = \
r"(?P(?P\\change) *(?P\[.*?\])?(?= *\{))"
self.regex_pattern = re.compile('|'.join(self.regex_strings.values()))
def doItBaby(self):
"""
Here the main seach/edit tread is initialized, connected and started.
The thread uses the wait condition: self.button_wait.
Once woken it checks the string: self.current_action.
The value of that string determies the action that the thread will take.
The thread emits a number of signal that are used to control the
main program and gui.
"""
self.decide_changes_thread = DecideChangesThread()
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('setTextHighlight')
,self.setTextHighlight)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('removeTextHighlight')
,self.removeTextHighlight)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('setTextOld')
,self.setTextOld)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('setTextNew')
,self.setTextNew)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('setTextNote')
,self.setTextNote)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('setTypeAnnote')
,self.setTypeAnnote)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('setTypeNote')
,self.setTypeNote)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('setTypeChange')
,self.setTypeChange)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('done')
,self.doneDeciding)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('cancel')
,self.cancelDeciding)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('texSyntaxError')
,self.handleTexSyntaxError)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('error')
,self.handleError)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('removeText')
,self.removeText)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('addText')
,self.addText)
self.connect(self.decide_changes_thread
,QtCore.SIGNAL('replaceText')
,self.replaceText)
self.search_edit_thread_active = True
self.decide_changes_thread.start()
# ------------------------------------------------------------------
# Begin file loading/saving routines
# ------------------------------------------------------------------
def loadTexFile(self):
# Put the loop here incase the save gets canceled.
while True:
if not self.doc_saved:
result = self.messageBoxDocModifed()
if result == QtGui.QMessageBox.Save:
self.saveTexFile()
continue
elif result == QtGui.QMessageBox.Discard:
break
elif result == QtGui.QMessageBox.Cancel:
return
else:
break
# If the thread is active then cancel the current edits.
# This should be fine since we just checked if the document
# was modified yet.
if self.search_edit_thread_active:
self.cancel()
# Choose the file.
self.tex_filename = self.chooseTexFileLoad(QtGui.QFileDialog.ExistingFile)
if self.tex_filename:
# Read the file.
with open(self.tex_filename) as tex_file:
self.tex_orig = tex_file.read()
self.tex_new = self.tex_orig
self.doc_saved = True
# Reset the search position
self.search_position = 0
# Display the file in the main window.
ui_central_widget.text_main.setPlainText(self.tex_new)
# Start the main loop.
self.doItBaby()
def chooseTexFileLoad(self, filemode=None, directory=None):
text = 'Choose the Tex file to edit.'
if not filemode:
filemode = QtGui.QFileDialog.AnyFile
if not directory:
directory = self.current_path
file_dialog = QtGui.QFileDialog(ui_base_window, text, directory)
file_dialog.setFileMode(filemode)
file_dialog.setAcceptMode(QtGui.QFileDialog.AcceptOpen)
if file_dialog.exec_():
filenames = file_dialog.selectedFiles()
filename = str(filenames[0])
# Save the current path.
if filename:
self.current_path = os.path.dirname(filename)
return filename
else:
return ''
def saveTexFile(self):
filename = self.chooseTexFileSave(directory=self.tex_filename)
if filename:
# Read the file.
with open(filename, 'w') as tex_file:
tex_file.write(self.tex_new)
self.doc_saved = True
def chooseTexFileSave(self, filemode=None, directory=None):
text = 'Choose the Tex file to save to.'
if not filemode:
filemode = QtGui.QFileDialog.AnyFile
if not directory:
directory = self.current_path
file_dialog = QtGui.QFileDialog(ui_base_window, text, directory)
file_dialog.setFileMode(filemode)
file_dialog.setAcceptMode(QtGui.QFileDialog.AcceptSave)
file_dialog.setConfirmOverwrite(True)
if file_dialog.exec_():
filenames = file_dialog.selectedFiles()
filename = str(filenames[0])
# Save the current path.
if filename:
self.current_path = os.path.dirname(filename)
return filename
else:
return ''
def messageBoxDocModifed(self):
"""This dialog is used if a file load is attempted but
the current document is not saved.
"""
messageBox = QtGui.QMessageBox()
messageBox.setText("The current document has been modified.")
messageBox.setInformativeText("Do you want to save your changes.")
messageBox.setStandardButtons(QtGui.QMessageBox.Save |
QtGui.QMessageBox.Discard |
QtGui.QMessageBox.Cancel)
messageBox.setDefaultButton(QtGui.QMessageBox.Save)
result = messageBox.exec_()
return result
# ------------------------------------------------------------------
# Begin routines that control the search/edit thread.
# ------------------------------------------------------------------
#
# These routines will be called eithen by the gui or the
# main program to control the behavior of the thread.
#
# For all of the routine two actions must be taken.
# 1. self.current_action must be set
# 2. self.button_wait.wakeAll() must be called
#
# ------------------------------------------------------------------
def keepOld(self):
self.setStatusText('Keep old text.')
self.current_action = 'keep_old'
# Get the current text
self.new_text = str(ui_central_widget.text_new.toPlainText())
self.old_text = str(ui_central_widget.text_old.toPlainText())
# Wake up the thread
self.button_wait.wakeAll()
def keepNew(self):
self.setStatusText('Keep new text.')
# Get the current text
self.new_text = str(ui_central_widget.text_new.toPlainText())
self.old_text = str(ui_central_widget.text_old.toPlainText())
self.current_action = 'keep_new'
# Wake up the thread
self.button_wait.wakeAll()
def removeNote(self):
self.setStatusText('Remove Note.')
# Get the current text
self.new_text = str(ui_central_widget.text_new.toPlainText())
self.old_text = str(ui_central_widget.text_old.toPlainText())
self.current_action = 'remove_note'
# Wake up the thread
self.button_wait.wakeAll()
def cancel(self):
self.setStatusText('Canceling Now.')
self.current_action = 'cancel'
# Wake up the thread
self.button_wait.wakeAll()
def skip(self):
self.setStatusText('Skip.')
self.current_action = 'skip'
# Wake up the thread
self.button_wait.wakeAll()
def skipAll(self):
self.setStatusText('Skip all remaining.')
self.current_action = 'skip all'
# Wake up the thread
self.button_wait.wakeAll()
def resume(self):
self.setStatusText('Resuming.')
self.tex_new = ui_central_widget.text_main.toPlainText()
self.current_action = 'resume'
# Wake up the thread
self.button_wait.wakeAll()
def textChangeByUser(self, selection_start, selection_end, length_diff):
# For the time being I am treating the TrackChangesTextEdit
# panel mostly as a black box.
# When an edit is done it will return the section before
# and after the edit as well and the change in document length.
#
# From this I can figure out where the new search positon needs to be.
self.doc_saved = False
self.setTypeMainText()
if self.search_position < selection_start[0]:
# Current search positon is before the edit.
pass
elif self.search_position > selection_start[1]:
# Current search positon is after the edit.
self.search_position -= length_diff
else:
# Current search positon is in the middle of a selection,
# or at the inital cursor positon.
if selection_start[0] <= selection_end[0]:
new_position = selection_start[0]
else:
new_position = selection_end[0]
self.search_position = new_position
# ------------------------------------------------------------------
# Begin routines that are controled by the search/edit thread.
# ------------------------------------------------------------------
#
# These are the routine that are controled (using signals) by
# the main search/edit thread.
#
# These routines should only be called by the thread.
#
# ------------------------------------------------------------------
def removeText(self, span):
self.doc_saved = False
if self.search_position <= span[0]:
# Current search positon is before the remove.
pass
elif self.search_position > span[1]:
# Current search positon is after the edit.
self.search_position += span[0] - span[1]
else:
# Current search positon is in the middle of the removed text
self.search_position = span[0]
ui_central_widget.text_main.setUserEvent(False)
cursor = ui_central_widget.text_main.textCursor()
cursor.setPosition(span[0])
cursor.setPosition(span[1], QtGui.QTextCursor.KeepAnchor)
cursor.removeSelectedText()
self.tex_new = ui_central_widget.text_main.toPlainText()
self.text_wait.wakeAll()
def addText(self, location, added_text):
self.doc_saved = False
if self.search_position <= location:
# Current search positon is before the add.
pass
else:
# Current search positon is after the add.
self.search_position += len(added_text)
ui_central_widget.text_main.setUserEvent(False)
cursor = ui_central_widget.text_main.textCursor()
cursor.setPosition(location)
cursor.insertText(added_text)
self.tex_new = ui_central_widget.text_main.toPlainText()
self.text_wait.wakeAll()
def replaceText(self, span, new_text):
self.doc_saved = False
if self.search_position <= span[0]:
# Current search positon is before the remove.
pass
elif self.search_position > span[1]:
# Current search positon is after the edit.
self.search_position += (span[0] - span[1]) + len(new_text)
else:
# Current search positon is in the middle of the removed text
self.search_position = span[0]
ui_central_widget.text_main.setUserEvent(False)
cursor = ui_central_widget.text_main.textCursor()
cursor.setPosition(span[0])
cursor.setPosition(span[1], QtGui.QTextCursor.KeepAnchor)
cursor.removeSelectedText()
cursor.insertText(new_text)
self.tex_new = ui_central_widget.text_main.toPlainText()
self.text_wait.wakeAll()
def doneDeciding(self):
self.search_edit_thread_active = False
if not self.doc_saved:
self.saveTexFile()
self.setTextOld()
self.setTextNew()
self.setTextNote()
self.setTypeNone()
def cancelDeciding(self):
self.search_edit_thread_active = False
self.setTextOld()
self.setTextNew()
self.setTextNote()
self.setTypeNone()
# ------------------------------------------------------------------
# Begin error handling routines.
# ------------------------------------------------------------------
#
#
# ------------------------------------------------------------------
def handleError(self):
# This should pop up a dialog letting the user know that a general error
# was found and asking what action to take.
self.setStatusText('Error.')
print 'Encountered error.'
result = self.messageBoxError()
self.current_action = 'cancel'
# Wake up the thread
self.button_wait.wakeAll()
raise Error
def handleTexSyntaxError(self, span):
# This should pop up a dialog letting the user know that a syntax error
# was found and asking what action to take.
self.setStatusText('TeX Syntax Error.')
print 'Parsing error encountered.'
self.setTextHighlight(span)
if not self.doc_saved:
result = self.messageBoxSyntaxErrorSave()
if result == QtGui.QMessageBox.Save:
self.saveTexFile()
else:
result = self.messageBoxSyntaxError()
self.current_action = 'cancel'
# Wake up the thread
self.button_wait.wakeAll()
raise TexSyntaxError
def messageBoxError(self):
"""This dialog is used if an error is encountered.
It will provide no options to save.
"""
messageBox = QtGui.QMessageBox()
messageBox.setText("An error was encountered.")
messageBox.setInformativeText("Please restart TrackChanges.\nIt may be possible to save the current changes from file->save.")
messageBox.setStandardButtons(QtGui.QMessageBox.Ok)
messageBox.setDefaultButton(QtGui.QMessageBox.Ok)
result = messageBox.exec_()
return result
def messageBoxSyntaxErrorSave(self):
"""This dialog is used if an error is encountered.
It will give the option to save the currect document.
"""
messageBox = QtGui.QMessageBox()
messageBox.setText("A parsing error was encountered.")
messageBox.setInformativeText("Do you want to save your changes.")
messageBox.setDetailedText("This error was most likely due to either mismatched bracket, or to a malformed edit command.\n\nThese syntax errors will need to be fixed before the file can be used with TrackChanges.")
messageBox.setStandardButtons(QtGui.QMessageBox.Save |
QtGui.QMessageBox.Discard)
messageBox.setDefaultButton(QtGui.QMessageBox.Save)
result = messageBox.exec_()
return result
def messageBoxSyntaxError(self):
"""This dialog is used if an error is encountered.
It will give the option to save the currect document.
"""
messageBox = QtGui.QMessageBox()
messageBox.setText("A parsing error was encountered.")
messageBox.setDetailedText("This error was most likely due to either mismatched bracket, or to a malformed edit command.\n\nThese syntax errors will need to be fixed before the file can be used with TrackChanges.")
messageBox.setStandardButtons(QtGui.QMessageBox.Ok)
messageBox.setDefaultButton(QtGui.QMessageBox.Ok)
result = messageBox.exec_()
return result
# ------------------------------------------------------------------
# Begin routines that controll the gui
# ------------------------------------------------------------------
#
# These routines control the gui.
# They can be called either from the main program or from the
# from the search/edit thread (using signals).
#
# ------------------------------------------------------------------
def setStatusText(self, status_text):
ui_base_window.statusBar().showMessage(status_text)
def setTextOld(self, span=None):
if span:
text = self.tex_new[span[0]+1:span[1]-1]
else:
text = ''
ui_central_widget.text_old.setPlainText(text)
def setTextNew(self, span=None):
if span:
text = self.tex_new[span[0]+1:span[1]-1]
else:
text = ''
ui_central_widget.text_new.setPlainText(text)
def setTextNote(self, span=None):
if span:
text = self.tex_new[span[0]+1:span[1]-1]
else:
text = ''
ui_central_widget.text_note.setPlainText(text)
def setTypeAnnote(self):
ui_central_widget.button_resume.setDisabled(True)
ui_central_widget.button_old.setDisabled(True)
ui_central_widget.button_new.setDisabled(True)
ui_central_widget.button_note.setDisabled(False)
ui_central_widget.button_skip.setDisabled(False)
ui_central_widget.text_new.setReadOnly(True)
ui_central_widget.text_old.setReadOnly(False)
def setTypeNote(self):
ui_central_widget.button_resume.setDisabled(True)
ui_central_widget.button_old.setDisabled(True)
ui_central_widget.button_new.setDisabled(True)
ui_central_widget.button_note.setDisabled(False)
ui_central_widget.button_skip.setDisabled(False)
ui_central_widget.text_new.setReadOnly(True)
ui_central_widget.text_old.setReadOnly(True)
def setTypeChange(self):
ui_central_widget.button_resume.setDisabled(True)
ui_central_widget.button_old.setDisabled(False)
ui_central_widget.button_new.setDisabled(False)
ui_central_widget.button_note.setDisabled(True)
ui_central_widget.button_skip.setDisabled(False)
ui_central_widget.text_new.setReadOnly(False)
ui_central_widget.text_old.setReadOnly(False)
def setTypeMainText(self):
ui_central_widget.button_resume.setDisabled(False)
ui_central_widget.button_old.setDisabled(True)
ui_central_widget.button_new.setDisabled(True)
ui_central_widget.button_note.setDisabled(True)
ui_central_widget.button_skip.setDisabled(True)
ui_central_widget.text_new.setReadOnly(True)
ui_central_widget.text_old.setReadOnly(True)
def setTypeNone(self):
ui_central_widget.button_resume.setDisabled(True)
ui_central_widget.button_old.setDisabled(True)
ui_central_widget.button_new.setDisabled(True)
ui_central_widget.button_note.setDisabled(True)
ui_central_widget.button_skip.setDisabled(True)
ui_central_widget.text_new.setReadOnly(True)
ui_central_widget.text_old.setReadOnly(True)
def removeTextHighlight(self):
"""Removes all background colors from the text."""
# Set this so that the text widget does
# not try update the search position.
ui_central_widget.text_main.setUserEvent(False)
text_length = ui_central_widget.text_main.getTextLength()
cursor = ui_central_widget.text_main.textCursor()
cursor.setPosition(0)
cursor.setPosition(text_length, QtGui.QTextCursor.KeepAnchor)
format = QtGui.QTextCharFormat()
format.setBackground(QtGui.QBrush(QtCore.Qt.NoBrush))
cursor.mergeCharFormat(format)
def setTextHighlight(self, span, color=(255,255,0)):
"""Adds a background to the text specifed by span.
Default Color is yellow.
"""
# Set this so that the text widget does
# not try update the search position.
ui_central_widget.text_main.setUserEvent(False)
cursor = ui_central_widget.text_main.textCursor()
cursor.setPosition(span[0])
cursor.setPosition(span[1], QtGui.QTextCursor.KeepAnchor)
format = QtGui.QTextCharFormat()
format.setBackground(QtGui.QBrush(QtGui.QColor(color[0],color[1],color[2])))
cursor.mergeCharFormat(format)
cursor.setPosition(span[0])
ui_central_widget.text_main.setTextCursor(cursor)
#=======================================================================
#=======================================================================
# This is the thread where the main work is done
#
# This needs a little clean up.
# Right now this is one big long script.
# I should add some functions and stuff to make it easyer to read.
class DecideChangesThread(QtCore.QThread):
"""This is the main thread where searching, editing and
accepting/rejecting of comments is done.
"""
def __init__(self, parent=None):
global trackchanges_main
QtCore.QThread.__init__(self, parent)
self.mutex = QtCore.QMutex()
def run(self):
with QtCore.QMutexLocker(self.mutex) as locker:
while True:
try:
# Find the next command
match = trackchanges_main.regex_pattern.search(
trackchanges_main.tex_new
,trackchanges_main.search_position)
if not match:
# No commands found
break
# Figure out which command was found
for self.command in trackchanges_main.command_names:
if match.group('type_'+self.command):
break
# Find the first set of brackets
try:
span_arg1 = findInnerSpan(
trackchanges_main.tex_new
,('{','}')
,match.end('full_'+self.command))
# Check that there is nothing but white space between the
# command head and the brackets.
if span_arg1[0] != match.end('full_'+self.command):
if not isWhiteSpace(trackchanges_main.tex_new[
match.end('full_'+self.command):span_arg1[0]]):
raise TexSyntaxError
span_full = (match.start('full_'+self.command), span_arg1[1])
except (EndOfStringError, IndexError):
# Something went wrong with the search.
raise TexSyntaxError
# Find the second set of brackets
if self.command in ['annote','change']:
try:
span_arg2 = findInnerSpan(
trackchanges_main.tex_new
,('{','}')
,span_arg1[1])
# Check that there is nothing but white space between the
# first and second sets of brackets.
if span_arg2[0] != span_arg1[1]:
if not isWhiteSpace(trackchanges_main.tex_new[
span_arg1[1]:span_arg2[0]]):
raise TexSyntaxError
span_full = (match.start('full_'+self.command), span_arg2[1])
except (EndOfStringError, IndexError):
# Something went wrong with the search.
raise TexSyntaxError
# Highlight the matched text
self.emit(QtCore.SIGNAL('setTextHighlight')
,span_full)
# Update the gui.
if self.command in ['note', 'refneeded']:
self.emit(QtCore.SIGNAL('setTypeNote'))
self.emit(QtCore.SIGNAL('setTextOld'))
self.emit(QtCore.SIGNAL('setTextNew'))
self.emit(QtCore.SIGNAL('setTextNote'), span_arg1)
elif self.command == 'add':
self.emit(QtCore.SIGNAL('setTypeChange'))
self.emit(QtCore.SIGNAL('setTextOld'))
self.emit(QtCore.SIGNAL('setTextNew'), span_arg1)
self.emit(QtCore.SIGNAL('setTextNote'))
elif self.command == 'remove':
self.emit(QtCore.SIGNAL('setTypeChange'))
self.emit(QtCore.SIGNAL('setTextOld'), span_arg1)
self.emit(QtCore.SIGNAL('setTextNew'))
self.emit(QtCore.SIGNAL('setTextNote'))
elif self.command == 'annote':
self.emit(QtCore.SIGNAL('setTypeAnnote'))
self.emit(QtCore.SIGNAL('setTextOld'), span_arg1)
self.emit(QtCore.SIGNAL('setTextNew'))
self.emit(QtCore.SIGNAL('setTextNote'), span_arg2)
elif self.command == 'change':
self.emit(QtCore.SIGNAL('setTypeChange'))
self.emit(QtCore.SIGNAL('setTextOld'), span_arg1)
self.emit(QtCore.SIGNAL('setTextNew'), span_arg2)
self.emit(QtCore.SIGNAL('setTextNote'))
else:
raise Error
print '%03d-%03d:%s: %s'%(
span_full[0]
,span_full[1]
,match.group('type_'+self.command)
,trackchanges_main.tex_new[span_full[0]:span_full[1]])
# Start error handling on the search
except TexSyntaxError:
if trackchanges_main.debug:
traceback.print_exc()
self.emit(QtCore.SIGNAL('texSyntaxError'), match.span('full_'+self.command))
except:
if trackchanges_main.debug:
traceback.print_exc()
self.emit(QtCore.SIGNAL('error'))
# Now wait until we get a command signal from the main program.
trackchanges_main.button_wait.wait(locker.mutex())
# We got a signal.
# Take action based on trackchanges_main.current_action
try:
# Remove the highlight from the matched text
self.emit(QtCore.SIGNAL('removeTextHighlight'))
if trackchanges_main.current_action == 'skip':
trackchanges_main.search_position = span_full[1]
continue
elif trackchanges_main.current_action == 'resume':
continue
elif trackchanges_main.current_action == 'skip all':
break
elif trackchanges_main.current_action == 'cancel':
self.emit(QtCore.SIGNAL('cancel'))
return
elif self.command in trackchanges_main.command_names:
trackchanges_main.search_position = span_full[1]
if self.command in ['note', 'refneeded']:
if trackchanges_main.current_action == 'remove_note':
self.emit(QtCore.SIGNAL('removeText')
,span_full)
elif self.command == 'annote':
if trackchanges_main.current_action == 'remove_note':
self.emit(QtCore.SIGNAL('replaceText')
,span_full
,trackchanges_main.old_text)
elif self.command in ['change', 'add', 'remove']:
if trackchanges_main.current_action == 'keep_old':
self.emit(QtCore.SIGNAL('replaceText')
,span_full
,trackchanges_main.old_text)
elif trackchanges_main.current_action == 'keep_new':
self.emit(QtCore.SIGNAL('replaceText')
,span_full
,trackchanges_main.new_text)
else:
raise Error
# Now wait until the text from the main program has been updated.
trackchanges_main.text_wait.wait(locker.mutex())
except:
if trackchanges_main.debug:
traceback.print_exc()
self.emit(QtCore.SIGNAL('error'))
# Done searching.
# Let the main program know that the thread is done.
self.emit(QtCore.SIGNAL('done'))
#=======================================================================
#=======================================================================
# Here we define the central widget.
# This has all of the view and buttons.
class UiCentralWindow(QtGui.QWidget):
def __init__(self, parent=None):
global trackchanges_main
QtGui.QWidget.__init__(self, parent)
# First setup all the widgets
self.text_main = TrackChangesTextEdit(self)
self.label_old = QtGui.QLabel('Old Text:', self)
self.text_old = QtGui.QTextEdit(self)
self.label_new = QtGui.QLabel('New Text:', self)
self.text_new = QtGui.QTextEdit(self)
self.label_note = QtGui.QLabel('Note:', self)
self.text_note = QtGui.QTextEdit(self)
self.text_note.setReadOnly(True)
self.button_resume = QtGui.QPushButton('Resume', self)
self.button_resume.setDisabled(True)
self.button_old = QtGui.QPushButton('Keep Old', self)
self.button_new = QtGui.QPushButton('Keep New', self)
self.button_note = QtGui.QPushButton('Remove Note', self)
self.button_skip = QtGui.QPushButton('Skip', self)
# Connect all the widgets
self.connectThings()
# Now set the style of everything
self.text_main.setFrameShape(QtGui.QFrame.Box)
self.text_old.setFrameShape(QtGui.QFrame.Box)
self.text_new.setFrameShape(QtGui.QFrame.Box)
self.text_note.setFrameShape(QtGui.QFrame.Box)
# Set the layout
left_layout = QtGui.QVBoxLayout()
right_layout = QtGui.QVBoxLayout()
right_button_layout = QtGui.QHBoxLayout()
left_button_layout = QtGui.QHBoxLayout()
right_button_layout.addStretch(1)
right_button_layout.addWidget(self.button_old)
right_button_layout.addWidget(self.button_new)
right_button_layout.addWidget(self.button_note)
right_button_layout.addWidget(self.button_skip)
left_button_layout.addStretch(1)
left_button_layout.addWidget(self.button_resume)
right_layout.addWidget(self.label_old)
right_layout.addWidget(self.text_old)
right_layout.addWidget(self.label_new)
right_layout.addWidget(self.text_new)
right_layout.addWidget(self.label_note)
right_layout.addWidget(self.text_note)
right_layout.addStretch(1)
right_layout.addLayout(right_button_layout)
left_layout.addWidget(self.text_main)
left_layout.addLayout(left_button_layout)
main_layout = QtGui.QHBoxLayout()
main_layout.addLayout(left_layout)
main_layout.addLayout(right_layout)
self.setLayout(main_layout)
# Choose the inital window size
self.resize(800,600)
def connectThings(self):
self.connect(self.text_main
,QtCore.SIGNAL('textChangedByUser')
,trackchanges_main.textChangeByUser)
self.connect(self.button_old
,QtCore.SIGNAL('clicked()')
,trackchanges_main.keepOld)
self.connect(self.button_new
,QtCore.SIGNAL('clicked()')
,trackchanges_main.keepNew)
self.connect(self.button_note
,QtCore.SIGNAL('clicked()')
,trackchanges_main.removeNote)
self.connect(self.button_skip
,QtCore.SIGNAL('clicked()')
,trackchanges_main.skip)
self.connect(self.button_resume
,QtCore.SIGNAL('clicked()')
,trackchanges_main.resume)
class TrackChangesTextEdit(QtGui.QTextEdit):
"""Here QtGui.QTextEdit is reimplemented so I can figure out what the last
selection was before an edit wase done.
This only works for edits that are done with a keyboard or mouse command,
not any commands given from the program.
"""
def __init__(self, *args):
QtGui.QTextEdit.__init__(self, *args)
# For now disable Undo/Redo.
# To enable this is a lot of work.
# There are two ways to go about it:
# 1. Keep track of all undo/redo information myself.
# 2. Re implement a bunch of stuff and try to use Qt's existing undo/redo stack.
self.setUndoRedoEnabled(False)
self.connectThings()
self.last_length = 0
self.last_selection = [0,0]
self.user_event = False
def connectThings(self):
self.connect(self
,QtCore.SIGNAL('textChanged()')
,self.textChange)
def textChange(self):
current_length = len(self.toPlainText())
if self.user_event:
# First get the selection before the event happend.
self.last_selection.sort()
# Now get the selection after the event happend.
cursor = self.textCursor()
current_selection = [cursor.anchor(), cursor.position()]
current_selection.sort()
# Find the length difference before and after the event
length_diff = current_length - self.last_length
# Emit a signal with the selections.
self.emit(QtCore.SIGNAL('textChangedByUser')
,self.last_selection
,current_selection
,length_diff)
self.last_length = current_length
def setPlainText(self, text):
self.last_length = len(text)
QtGui.QTextEdit.setPlainText(self, text)
def mousePressEvent(self, event):
self.user_event = True
# Call the standard function.
QtGui.QTextEdit.mousePressEvent(self, event)
def mouseReleaseEvent(self, event):
# Save the selection WHEN the mouse was released.
cursor = self.textCursor()
self.last_selection = [cursor.anchor(), cursor.position()]
self.user_event = True
# Call the standard function.
QtGui.QTextEdit.mouseReleaseEvent(self, event)
def keyPressEvent(self, event):
# Save the selection BEFORE the key was pressed.
cursor = self.textCursor()
self.last_selection = [cursor.anchor(), cursor.position()]
self.user_event = True
# Call the standard function.
QtGui.QTextEdit.keyPressEvent(self, event)
def setUserEvent(self, user_event):
self.user_event = user_event
def getTextLength(self):
return self.last_length
#=======================================================================
#=======================================================================
# Set up the base window.
# The menu and status bar are setup in here.
#
# Close event are caught here.
class UiBaseWindow(QtGui.QMainWindow):
def __init__(self, parent):
global trackchanges_main
global ui_central_widget
global ui_menubar
global ui_menu_items
QtGui.QWidget.__init__(self, parent)
self.setWindowTitle('TrackChanges - ' + versionString())
ui_central_widget = UiCentralWindow(self)
# Set the central Widget
self.setCentralWidget(ui_central_widget)
# Now setup a status bar
self.statusBar().showMessage('Track those changes baby.')
# How about a menu bar
self.createMenuBar()
# Connect things
self.connectThings()
def closeEvent(self, event):
result = trackchanges_main.exit()
if result == 'close':
self.closeAllWindows()
event.accept()
elif result == 'cancel':
event.ignore()
def createMenuBar(self):
# How about a menu bar
ui_menubar = self.menuBar()
# Now add the menus
ui_menubar_file = ui_menubar.addMenu('&File')
ui_menubar_commands = ui_menubar.addMenu('&Commands')
ui_menubar_help = ui_menubar.addMenu('&Help')
# Setup some actions.
self.createActions()
# Add the actions to the menus
# --------------------------------------------------------------
# First the file menu.
ui_menubar_file.addAction(self.ui_menu_items['load'])
ui_menubar_file.addAction(self.ui_menu_items['save'])
ui_menubar_file.addAction(self.ui_menu_items['exit'])
# Now the commands menu.
ui_menubar_commands.addAction(self.ui_menu_items['skip all'])
ui_menubar_commands.addAction(self.ui_menu_items['cancel'])
# Now the help menu
ui_menubar_help.addAction(self.ui_menu_items['about'])
ui_menubar_help.addAction(self.ui_menu_items['help'])
def createActions(self):
# Setup some actions.
self.ui_menu_items = {}
self.ui_menu_items['load'] = QtGui.QAction('Load', self)
self.ui_menu_items['load'].setStatusTip("Load TeX file")
self.ui_menu_items['save'] = QtGui.QAction('Save', self)
self.ui_menu_items['save'].setStatusTip("Save changes")
self.ui_menu_items['exit'] = QtGui.QAction('Exit', self)
self.ui_menu_items['exit'].setStatusTip("Don't leave me baby.")
self.ui_menu_items['skip all'] = QtGui.QAction('Skip All', self)
self.ui_menu_items['skip all'].setStatusTip("Skip all remaining changes and save.")
self.ui_menu_items['cancel'] = QtGui.QAction('Cancel', self)
self.ui_menu_items['cancel'].setStatusTip("Cancel and do not save.")
self.ui_menu_items['about'] = QtGui.QAction('About', self)
self.ui_menu_items['about'].setStatusTip("About TrackChanges")
self.ui_menu_items['help'] = QtGui.QAction('Help', self)
self.ui_menu_items['help'].setStatusTip("Help!")
def connectThings(self):
self.connect(self.ui_menu_items['load']
,QtCore.SIGNAL('triggered()')
,trackchanges_main.loadTexFile)
self.connect(self.ui_menu_items['save']
,QtCore.SIGNAL('triggered()')
,trackchanges_main.saveTexFile)
self.connect(self.ui_menu_items['exit']
,QtCore.SIGNAL('triggered()')
,QtCore.SLOT('close()'))
self.connect(self.ui_menu_items['skip all']
,QtCore.SIGNAL('triggered()')
,trackchanges_main.skipAll)
self.connect(self.ui_menu_items['cancel']
,QtCore.SIGNAL('triggered()')
,trackchanges_main.cancel)
self.connect(self.ui_menu_items['about']
,QtCore.SIGNAL('triggered()')
,self.showAboutDialog)
self.connect(self.ui_menu_items['help']
,QtCore.SIGNAL('triggered()')
,self.showHelpDialog)
def closeAllWindows(self):
"""
Closes all application windows.
For now this is just the help window.
"""
try:
status = self.help_window.close()
except AttributeError:
pass
def showAboutDialog(self):
version_string = versionString()
version_date_string = versionDateString()
file_about = r'../documentation/web/html_simple/about.html'
with open(file_about) as file:
about_text_format = file.read()
about_text = about_text_format.format(version=version_string
,date=version_date_string)
QtGui.QMessageBox.about(
self
,'About TrackChanges'
,about_text
)
def showHelpDialog(self):
#help = HelpWindow(self)
self.help_window = HelpWindow(None)
self.help_window.show()
class HelpWindow(QtGui.QMainWindow):
def __init__(self, parent):
QtGui.QMainWindow.__init__(self, parent)
self.setWindowTitle('TrackChanges Help')
self.help_browser = HelpWebBrowser(self)
self.setCentralWidget(self.help_browser)
self.resize(700,500)
class HelpWebBrowser(QtGui.QWidget):
def __init__(self, parent=None):
QtGui.QWidget.__init__(self, parent)
# We load this on the fly to reduce memory footprint.
from PyQt4 import QtWebKit
self.browser_main = QtWebKit.QWebView(self)
# Connect things
self.connectThings()
url_main = QtCore.QUrl(r'../documentation/web/html_css/help.html')
self.browser_main.setUrl(url_main)
# Set the layout
main_layout = QtGui.QHBoxLayout()
main_layout.addWidget(self.browser_main)
self.setLayout(main_layout)
def connectThings(self):
pass
# =====================================================================
# =====================================================================
# This is meant to be a lightweight help browser for TrackChanges.
#
# Unfortunatly Qt's rich text handling is all messed up and this
# does not work. Qt puts the ends of tags in the wrong place.
class HelpBrowser(QtGui.QWidget):
def __init__(self, parent=None):
QtGui.QWidget.__init__(self, parent)
self.browser_main = QtGui.QTextBrowser(self)
self.browser_main.setSearchPaths(['../documentation/web/'])
url_main = QtCore.QUrl(r'../documentation/web/html_simple/help.html')
self.browser_main.setSource(url_main)
self.browser_index = QtGui.QTextBrowser(self)
url_index = QtCore.QUrl(r'../documentation/web/html_simple/help_index.html')
self.browser_index.setSource(url_index)
self.browser_index.setMaximumWidth(150)
self.browser_index.setMinimumWidth(150)
self.browser_index.setOpenLinks(False)
# Connect things
self.connectThings()
# Set the layout
left_layout = QtGui.QVBoxLayout()
right_layout = QtGui.QVBoxLayout()
right_layout.addWidget(self.browser_main)
left_layout.addWidget(self.browser_index)
main_layout = QtGui.QHBoxLayout()
main_layout.addLayout(left_layout)
main_layout.addLayout(right_layout)
self.setLayout(main_layout)
def connectThings(self):
self.connect(self.browser_index
,QtCore.SIGNAL("anchorClicked(QUrl)")
,self.browser_main
,QtCore.SLOT("setSource(QUrl)"))
#=======================================================================
#=======================================================================
# Some string parsing utilities.
# These should eventually go into a utilities module.
def findallInnerSpan(string, marker, start=None, end=None):
"""
Returns a list of the spans of the first substring surrounded
by the markers as well as any nested matches.
This is to be used in extracting the contents of
brackets. This will deal with nested brackets properly.
The returned spans include the brackets.
If the end of the string is encounted before all marker pairs have
been closed endOfStringError will be raised.
"""
if not start:
start = 0
if not end:
end = len(string)
# if the markers are the same we need to deal with things differently
if marker[0] == marker[1]:
pattern = re.compile('%s.*?%s' %(re.escape(marker[0]),re.escape(marker[1])))
match = pattern.search(string, start, end)
if match:
return [match.span(0)]
else:
return None
else:
# create the regular expression.
pattern = re.compile('%s|%s' %(re.escape(marker[0]),re.escape(marker[1])))
# Here we store the locations of the starting and ending markers.
open_stack = []
close_stack = []
# Here we store the final {starting,ending) pairs.
final_stack = []
# Iterate over the pattern.
# Note that we build the close_stack in reverse order.
for match in pattern.finditer(string, start, end):
if match.group(0) == marker[0]:
open_stack.append(match.start(0))
close_stack.insert(0,None)
else:
empty_index = close_stack.index(None)
close_stack[empty_index] = match.end(0)
if not None in close_stack:
break
# Check for an incomplete match
if None in close_stack:
raise EndOfStringError
# The close stack was built in reverse order
close_stack.reverse()
# The same number of open and close markers have been found
# so we have a match.
for i in range(len(open_stack)):
final_stack.append((open_stack[i],close_stack[i]))
if final_stack:
return final_stack
else:
return None
def findInnerSpan(string, marker, start=None, end=None):
"""
Returns a the span of the first substring surrounded
by the markers. Properly handles nested brackets.
The returned span includes the brackets.
If the end of the string is encounted before all marker pairs have
been closed endOfStringError will be raised.
"""
# First get a list that includes the spans of
# all the nested brackets.
final_stack = findallInnerSpan(string, marker, start, end)
# Now just return the outermost span
try:
return final_stack[0]
except:
return None
def isWhiteSpace(string):
match = re.search(r"[^\s]", string)
if match:
return False
else:
return True
def versionString():
string='.'.join(map(str,__version__[:3]))
return string
def versionDateString():
return __version_date__.strftime('%Y-%m-%d')
#=======================================================================
#=======================================================================
# Errors for string parsing.
# These should eventually go into a utilities module.
class Error(Exception):
"""Base class for exceptions in this module."""
pass
class EndOfStringError(Error):
"""Execption raised when the end of the string is encountered
before processing has finished.
"""
pass
#=======================================================================
#=======================================================================
# Errors for TrackChanges.
class TexSyntaxError(Error):
"""Execption raised when a Tex syntax error is encountered."""
pass
#=======================================================================
#=======================================================================
# Start the program.
if __name__ == '__main__':
trackchanges_main = TrackChanges()
sys.exit(app.exec_())