# -*- coding: utf-8 -*- # DocumentWrapper.py # This file is part of the Gedit LaTeX Plugin # # Copyright (C) 2006 Michael Zeising # # This program is free software; you can redistribute it and/or modify it under # the terms of the GNU General Public Licence as published by the Free Software # Foundation; either version 2 of the Licence, or (at your option) any later # version. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU General Public Licence for more # details. # # You should have received a copy of the GNU General Public Licence along with # this program; if not, write to the Free Software Foundation, Inc., 51 Franklin # Street, Fifth Floor, Boston, MA 02110-1301, USA import gtk, gobject, re from completion import CompletionProvider import const LEVELS = ["part", "chapter", "section", "subsection", "subsubsection", "paragraph", "subparagraph"] # keys recognized, when the completion popup is visible validCompletionKeys = ["Up", "Down", "Page_Up", "Page_Down", "Escape", "Return"] class DocumentWrapper(gobject.GObject): """ Encapsulates an open document """ __gsignals__ = { "environment-changed" : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), "structure-updated" : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()) } environment = const.UNKNOWN _uri = None _changed = True _masterFilename = None # these re's may compiled on demand reIndent, reStructure, reLabelOrBibitem, reNewcommand, reEnvironment, reDocument = None, None, None, None, None, None messageDialog = None def __init__(self, textView, instance): gobject.GObject.__init__(self) self.reComment = re.compile(r"%.*?$", re.M) self.rePackage = re.compile(r"\\usepackage(\[(.*?)\])?\{(.*?)\}", re.M) self._instance = instance self._textView = textView self._textBuffer = textView.get_buffer() self.modelStructure = gtk.TreeStore(str, str) # name, complete string self.modelLabelsAndBibs = gtk.ListStore(str, int, str) # name, type (0:label, 1:bibitem), complete string self.modelNewcommands = gtk.ListStore(str, str, str, str) # name, definition, markup (name), complete string self.modelPackages = gtk.ListStore(str, str) # package, options self._completionProvider = CompletionProvider(instance._builtinCompletionTargets, self.modelNewcommands, self.modelLabelsAndBibs, self.modelPackages) self._textBuffer.connect("changed", self.__onBufferChanged) #self._textBuffer.connect("end-user-action", self.__onEndUserAction) #if instance._prefs.setdefault("/auto-closing-enabled", True): # self.insertHandler = self._textBuffer.connect("insert-text", self._onInsert) textView.connect("key-release-event", lambda *a: self._recognizeEnvironment()) textView.connect("button-release-event", lambda *a: self._recognizeEnvironment()) if instance._prefs.setdefault("/auto-completion-enabled", True): textView.connect_after("key-release-event", self._onKeyReleaseEvent) textView.connect("key-press-event", self._onKeyPressEvent) textView.connect("focus-out-event", self._onFocusOutEvent) def _isFragment(self): """ Returns True if this document seems to be only a fragment """ string = self._textBuffer.get_text(self._textBuffer.get_start_iter(), self._textBuffer.get_end_iter(), False) return (string.find(r"\begin{document}") == -1) def _getMasterFilename(self): """ Returns the filename of this document's master """ # return cached filename if available if self._masterFilename: return self._masterFilename # reset self._masterFilename = None ownFilename = self.getFilename() if ownFilename: import os, glob docName = os.path.splitext(os.path.basename(ownFilename))[0] for filename in glob.glob(os.path.dirname(ownFilename) + "/*.tex"): contents = open(filename).read() if contents.find(r"\include{" + docName) != -1: # this one includes the file if contents.find(r"\begin{document}") != -1: # and it's a full document >> bingo! self._masterFilename = filename break return self._masterFilename def _onFocusOutEvent(self, textView, event): self._instance._completionPopup.hide() def _onKeyPressEvent(self, textView, event): if self._instance._completionPopup.get_property("visible"): keyVal = gtk.gdk.keyval_name(event.keyval) if keyVal in validCompletionKeys: path = self._instance._completionPopup.treeView.get_cursor()[0] if path: index = path[0] maxIndex = self._instance._completionPopup.model.iter_n_children(None) keyVal = gtk.gdk.keyval_name(event.keyval) if keyVal == "Return": # accept the selected target listStore, listIter = self._instance._completionPopup.treeView.get_selection().get_selected() target = listStore.get_value(listIter, 0) self._textBuffer.insert_at_cursor(target[len(self._completionProvider.lastPrefix):]) self.__autoClose() self._instance._completionPopup.hide() return True elif keyVal == "Escape": # close the popup self._instance._completionPopup.hide() return True if keyVal == "Up": index -= 1 elif keyVal == "Down": index += 1 elif keyVal == "Page_Up": index -= 5 elif keyVal == "Page_Down": index += 5 if index < 0: index = 0 elif index >= maxIndex: index = maxIndex - 1 self._instance._completionPopup.treeView.set_cursor((index)) return True else: return False def __autoClose(self): """ perform auto-closing """ insIter = self._textBuffer.get_iter_at_mark(self._textBuffer.get_insert()) limitIter = insIter.copy() limitIter.backward_chars(30) match = insIter.backward_search("\\", False, limitIter) if not match: return False if not self.reEnvironment: self.reEnvironment = re.compile(r"begin\{([a-zA-Z]+)\}$") string = self._textBuffer.get_text(insIter, match[1], False) match = self.reEnvironment.match(string) if match: if not self.isLatex(): return False indent = self._getIndent(insIter.get_line()) closeString = "\n%s\t\n%s\\end{%s}" % (indent, indent, match.group(1)) mark = self._textBuffer.create_mark(None, self._textBuffer.get_iter_at_mark(self._textBuffer.get_insert()), True) self._textBuffer.insert_at_cursor(closeString) insIter = self._textBuffer.get_iter_at_mark(mark) insIter.forward_line() insIter.forward_to_line_end() self._textBuffer.place_cursor(insIter) self._textBuffer.delete_mark(mark) def _onKeyReleaseEvent(self, textView, event): """ Try to complete a written command using self._completionProvider and show the self._instance._completionPopup """ keyVal = gtk.gdk.keyval_name(event.keyval) if keyVal == "Escape": return True if self._instance._completionPopup.get_property("visible"): if keyVal in validCompletionKeys: # Signal bezieht sich auf Popup, nicht weitergeben, also blockieren return True insIter = self._textBuffer.get_iter_at_mark(self._textBuffer.get_insert()) if keyVal == "braceright": self._instance._completionPopup.hide() self.__autoClose() return False if not insIter.ends_word(): self._instance._completionPopup.hide() return False # look for command limitIter = insIter.copy() limitIter.backward_chars(30) match = insIter.backward_search("\\", False, limitIter) if not match: self._instance._completionPopup.hide() return False # FIXME: this is very bad, signal handlers should not be connected until # there is LaTeX in the document to save resources if not self.isLatex(): self._instance._completionPopup.hide() return False string = self._textBuffer.get_text(insIter, match[1], False) # find targets completionList = self._completionProvider.complete(string) numOfMatches = len(completionList) if not numOfMatches or string in [match[0] for match in completionList]: self._instance._completionPopup.hide() return False # store matches in the model self._instance._completionPopup.model.clear() for match in completionList: self._instance._completionPopup.model.append([match[0], match[1]]) self._instance._completionPopup.treeView.set_cursor((0)) # calculate iter coords rect = self._textView.get_iter_location(self._textBuffer.get_iter_at_mark(self._textBuffer.get_insert())) x, y = self._textView.buffer_to_window_coords(gtk.TEXT_WINDOW_WIDGET, rect.x, rect.y) # add widget coords rootX, rootY = self._textView.get_window(gtk.TEXT_WINDOW_WIDGET).get_geometry()[:2] x += rootX y += rootY # add window coords rootX, rootY = self._instance._window.get_position() x += rootX y += rootY # move and show the popup self._instance._completionPopup.move(x + 16, y + 18) self._instance._completionPopup.show_all() return True def update(self): """ Called when the document is activated on tab change """ uri = self._textBuffer.get_uri() if uri != self._uri or self._changed: self._uri = uri self._changed = False self.update() def findString(self, string): """ finds a string in the buffer and selects it """ iter = self._textBuffer.get_start_iter() result = iter.forward_search(string, False) if result: self._textBuffer.select_range(result[0], result[1]) self._textView.scroll_to_iter(result[0], .25) def getString(self): """ returns the text in the buffer """ return self._textBuffer.get_text(self._textBuffer.get_start_iter(), self._textBuffer.get_end_iter()) def getFilename(self): import gnomevfs """ returns the document's filename extracted from its URI """ uri = self._textBuffer.get_uri() if uri and gnomevfs.get_uri_scheme(uri) == "file": return gnomevfs.get_local_path_from_uri(uri) return None def comment_selection(self, uncomment=False): """ (un-)comments the currently selected line(s) """ bounds = self._textBuffer.get_selection_bounds() if len(bounds): for line_number in xrange(bounds[0].get_line(), bounds[1].get_line() + 1): start_iter = self._textBuffer.get_iter_at_line(line_number) end_iter = self._textBuffer.get_iter_at_line_offset(line_number, 1) if uncomment and self._textBuffer.get_text(start_iter, end_iter) == "%": # uncomment self._textBuffer.delete(start_iter, end_iter) else: # comment self._textBuffer.insert(start_iter, "%") else: insert = self._textBuffer.get_iter_at_mark(self._textBuffer.get_insert()) start_iter = self._textBuffer.get_iter_at_line(insert.get_line()) end_iter = self._textBuffer.get_iter_at_line_offset(insert.get_line(), 1) if uncomment and self._textBuffer.get_text(start_iter, end_iter) == "%": # uncomment self._textBuffer.delete(start_iter, end_iter) else: # comment self._textBuffer.insert(start_iter, "%") def updatePackages(self, completeUpdate=False, string=None): """ updates the list of included packages """ # \usepackage commands may only be used in the preamble # so if we are a fragment, only the master's packages have to be parsed if self._isFragment(): masterFilename = self._getMasterFilename() if not masterFilename: return string = open(masterFilename).read().decode("utf-8") else: if not completeUpdate: string = self._textBuffer.get_text(self._textBuffer.get_start_iter(), self._textBuffer.get_end_iter(), False).decode("utf-8") self.modelPackages.clear() for match in self.rePackage.finditer(string): self.modelPackages.append([match.group(3), match.group(2)]) # update the CompletionStore if not completeUpdate: self._completionProvider.update() def updateLabelsAndBibitems(self, completeUpdate=False, string=None): """ updates the list of \labelS and \bibitemS """ if not self.reLabelOrBibitem: # TODO: match the complete \bibitem to be able to show it as a hint self.reLabelOrBibitem = re.compile(r"\\(label|bibitem)(\[.*?\])?\{(.*?)\}", re.M) if not completeUpdate: string = self._textBuffer.get_text(self._textBuffer.get_start_iter(), self._textBuffer.get_end_iter(), False).decode("utf-8") # \labelS may also be defined in the master document # so this must be parsed, too if self._isFragment(): masterFilename = self._getMasterFilename() if masterFilename: stringMaster = open(masterFilename).read().decode("utf-8") string += stringMaster self.modelLabelsAndBibs.clear() for match in self.reLabelOrBibitem.finditer(string): if match.group(1) == "label": self.modelLabelsAndBibs.append([match.group(3), const.LABEL, match.group(0)]) else: self.modelLabelsAndBibs.append([match.group(3), const.BIBITEM, match.group(0)]) # update the CompletionStore if not completeUpdate: self._completionProvider.update() def updateNewcommands(self, completeUpdate=False, string=None): """ updates the list of newcommands completeUpdate: this call is part of a complete parsing string: content of the TextBuffer if completeUpdate == True """ if not self.reNewcommand: # FIXME: 2nd parameter may only be [1-9] self.reNewcommand = re.compile(r"\\newcommand\{(.*?)\}(\[[1-9]?\])?\{(.*)\}", re.M) if not completeUpdate: string = self._textBuffer.get_text(self._textBuffer.get_start_iter(), self._textBuffer.get_end_iter(), False).decode("utf-8") # \newcommandS may also be defined in the master document # so this must be parsed, too if self._isFragment(): masterFilename = self._getMasterFilename() if masterFilename: stringMaster = open(masterFilename).read().decode("utf-8") string += stringMaster self.modelNewcommands.clear() for match in self.reNewcommand.finditer(string): self.modelNewcommands.append([match.group(1), match.group(3), "%s" % match.group(1), match.group(0)]) # update the CompletionStore if not completeUpdate: self._completionProvider.update() def updateStructure(self, completeUpdate=False, string=None): """ updates the structure tree completeUpdate: this call is part of a complete parsing string: content of the TextBuffer if completeUpdate == True """ # TODO: if we are only a fragment, then embed our structure into the one # of the master document if not self.reStructure: self.reStructure = re.compile(r"\\(part|chapter|section|subsection|subsubsection|paragraph|subparagraph|include)\*?(\[.*?\])?(\{.*\})", re.M) if not completeUpdate: string = self._textBuffer.get_text(self._textBuffer.get_start_iter(), self._textBuffer.get_end_iter(), False).decode("utf-8") self.modelStructure.clear() lastAbsLevel = 0 # last absolute level of element treeOffset = 0 # current offset in the tree parentNodes = [None] for match in self.reStructure.finditer(string): # match brackets argument = match.group(3) depth = 0 i = 1 while i < len(argument): if argument[i] == "{": depth += 1 elif argument[i] == "}": depth -= 1 i += 1 if depth == -1: break argument = argument[1:i-1].replace("&", "&") if match.group(1) == "include": node = self.modelStructure.append(parentNodes[treeOffset], ["%s" % argument, match.group(0)]) else: absLevel = LEVELS.index(match.group(1)) # absolute level of element delta = absLevel - lastAbsLevel # delta of levels lastAbsLevel = absLevel if delta >= 1: treeOffset += 1 else: treeOffset += delta if treeOffset < 1: treeOffset = 1 node = self.modelStructure.append(parentNodes[treeOffset - 1], [argument, match.group(0)]) try: parentNodes[treeOffset] = node except IndexError: parentNodes.append(node) self.emit("structure-updated") def update(self): """ Parses the document and updates the structure, labels and newcommands models """ # no text? if not self._textBuffer.get_end_iter().get_offset(): return if not self.isLatex(): return string = self._textBuffer.get_text(self._textBuffer.get_start_iter(), self._textBuffer.get_end_iter(), False).decode("utf-8") # kill comments string = self.reComment.sub("", string) self.updateStructure(True, string) self.updateLabelsAndBibitems(True, string) self.updateNewcommands(True, string) self.updatePackages(True, string) self._completionProvider.update() def isLatex(self): """ Returns True if the document appears to contain LaTeX """ language = self._textBuffer.get_language() if language and language.get_id() == "LaTeX": return True uri = self._textBuffer.get_uri() if uri and uri.endswith(".tex"): return True return False def getLine(self): """ returns the current line number """ return self._textBuffer.get_iter_at_mark(self._textBuffer.get_insert()).get_line() def _getIndent(self, lineNumber=-1): """ Returns the indentation of the current line or a given line number """ if not self.reIndent: self.reIndent = re.compile("[ \t]+") if lineNumber > -1: startIter = self._textBuffer.get_iter_at_line(lineNumber) else: startIter = self._textBuffer.get_iter_at_mark(self._textBuffer.get_insert()) startIter.set_line_offset(0) endIter = startIter.copy() endIter.forward_to_line_end() string = self._textBuffer.get_text(startIter, endIter) match = self.reIndent.match(string) if match: return match.group() else: return "" def scrollToLine(self, line): """ Scrolls the document to a given line """ self._textView.scroll_to_iter(self._textBuffer.get_iter_at_line(line), .25) def _get_line_number_from_offset(self, offset): """ Returns the line number that corresponds to an offset """ return self._textBuffer.get_iter_at_offset(offset).get_line() def insert(self, arg, line_number=-1): """ Inserts text into the document @param arg: the text to be inserted. If this is a list and there's _no_ selection, then its items are inserted successively and the cursor is placed after the first one. If it's a list and there _is_ a selection, then its first item is inserted at the selection's start and the following ones are inserted at its end successively. @param line_number: if > -1, the text is inserted after this line @param restoreIndent: if True, the indentation of the current line is restored and continued in the following lines """ if line_number > -1: # get indent at given line and prepend it indent = self._getIndent(line_number) arg = "%s%s" % (indent, "".join(arg)) # TODO: follow indent on multiline inserts self._textBuffer.insert(self._textBuffer.get_iter_at_line(line_number), arg) else: indent = self._getIndent() if type(arg) is str: # one single string self._textBuffer.insert_at_cursor(arg.replace("\n", "\n" + indent)) elif type(arg) is tuple: # multiple strings bounds = self._textBuffer.get_selection_bounds() if len(bounds): # store the selection marks l_mark = self._textBuffer.create_mark(None, bounds[0], False) r_mark = self._textBuffer.create_mark(None, bounds[1], True) # insert first piece at the beginning... self._textBuffer.insert(bounds[0], arg[0]) # ...and the other ones at the end self._textBuffer.insert(self._textBuffer.get_iter_at_mark(r_mark), "".join(arg[1:])) # restore selection self._textBuffer.select_range(self._textBuffer.get_iter_at_mark(l_mark), self._textBuffer.get_iter_at_mark(r_mark)) # delete marks self._textBuffer.delete_mark(l_mark) self._textBuffer.delete_mark(r_mark) else: # insert first item head = arg[0].replace("\n", "\n" + indent) self._textBuffer.insert_at_cursor(head) # copy the insert mark mark = self._textBuffer.create_mark(None, self._textBuffer.get_iter_at_mark(self._textBuffer.get_insert()), True) # insert the remaining pieces tail = "".join(arg[1:]).replace("\n", "\n" + indent) self._textBuffer.insert_at_cursor(tail) # place cursor after the first one self._textBuffer.place_cursor(self._textBuffer.get_iter_at_mark(mark)) # delete mark self._textBuffer.delete_mark(mark) self._recognizeEnvironment() self._textView.grab_focus() def enable_highlight(self): """ Sets the document's source language to LaTeX and enables highlighting """ import gtksourceview manager = gtksourceview.SourceLanguagesManager() language = manager.get_language_from_mime_type("text/x-tex") self._textBuffer.set_language(language) self._textBuffer.set_highlight(True) def requirePackages(self, packages): if not len(packages): return if not self.reDocument: self.reDocument = re.compile(r"(\\documentclass|\\documentstyle)(\[[A-Za-z0-9,]*\])*\{[A-Za-z0-9,]*\}") string = self._textBuffer.get_text(self._textBuffer.get_start_iter(), self._textBuffer.get_end_iter()) # filter packages not yet included and bundle them to one string filteredPackages = [p for p in packages if not re.compile(r"\\usepackage\{%s\}" % p).search(string)] bundle = "".join(["\\usepackage{%s}\n" % p for p in filteredPackages]) # find a line to insert the string result = self.rePackage.search(string) if result: # there are already included packages, place it at the start of them lineNumber = self._get_line_number_from_offset(result.span()[0]) else: # there are no packages yet result = self.reDocument.search(string) if result: # place it after document start lineNumber = self._get_line_number_from_offset(result.span()[1]) + 1 else: # no document at all, place it at the start lineNumber = 0 # insert self.insert(bundle, lineNumber) # update packages self.updatePackages() # popup message if not self.messageDialog: self.messageDialog = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_INFO, gtk.BUTTONS_OK, None) if len(filteredPackages) > 1: self.messageDialog.set_markup("The inserted command required the additional LaTeX packages %s. They have been added to your document." % ", ".join(filteredPackages)) else: self.messageDialog.set_markup("The inserted command required the additional LaTeX package %s. It has been added to your document." % filteredPackages[0]) self.messageDialog.run() self.messageDialog.hide() def insert_newcommand(self, name, definition, refCount=0): """ Inserts a \newcommand into the document """ text = self.getString() refStr = "" if refCount: refStr = "[%s]" % refCount if re.compile(r"\\newcommand\{%s\}" % re.escape(name)).search(text): # the name of this newcommand is already in use message = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_INFO, gtk.BUTTONS_OK, None) message.set_markup("The document already contains a new command named %s." % name) message.run() message.destroy() self.updateNewcommands() return result = re.compile(r"\\usepackage(\[[A-Za-z0-9,]*\])*\{[A-Za-z0-9,]*\}").search(text) if result: # place this \newcommand after the last one found in the document line_number = self._get_line_number_from_offset(result.span()[0]) self.insert("\\newcommand{%s}%s{%s}\n" % (name, refStr, definition), line_number) self.updateNewcommands() return result = re.compile(r"(\\documentclass|\\documentstyle)(\[[A-Za-z0-9,]*\])*\{[A-Za-z0-9,]*\}").search(text) if result: # place it after document start line_number = self._get_line_number_from_offset(result.span()[1]) self.insert("\n\\newcommand{%s}%s{%s}" % (name, refStr, definition), line_number) self.updateNewcommands() return # insert it at the start of the file self.insert("\\newcommand{%s}%s{%s}\n" % (name, refStr, definition), 0) self.updateNewcommands() def remove_newcommand(self, name): pass def _recognizeEnvironment(self): """ Recognize the environment the cursor is in """ # FIXME: only parse when the buffer has changed or the cursor has moved # (don't parse on Ctrl or Alt) if self.isLatex(): string = self._textBuffer.get_text(self._textBuffer.get_start_iter(), self._textBuffer.get_iter_at_mark(self._textBuffer.get_insert())) string = self.reComment.sub("", string) counter = 0 for item in [r"$", r"{math", r"{displaymath", r"{eqnarray", r"\[", r"\]"]: counter += string.count(item) if counter % 2: environment = const.MATH else: environment = const.GENERAL if environment != self.environment: self.environment = environment self.emit("environment-changed") else: environment = const.UNKNOWN if environment != self.environment: self.environment = environment self.emit("environment-changed") def __onBufferChanged(self, textBuffer): self._changed = True # register GObject gobject.type_register(DocumentWrapper)