#@+leo-ver=4
#@+node:@file mod_spelling.py
"""Spell Checker Plugin

- Perfoms spell checking on nodes within a Leo document.
- Uses aspell.exe to do the checking and suggest alternatives."""


from leoPlugins import *
from leoGlobals import *
import leoFind

try:    import Tkinter
except: Tkinter = None
Tk = Tkinter

import ConfigParser,os,popen2,re,sys,traceback

#@+others
#@+node:createSpellMenu
def createSpellMenu(tag,keywords):
	
	"""Create the Check Spelling menu item in the Edit menu."""
	
	c = keywords.get("c")

	table = (
		("-",None,None),
		("Check Spelling","Alt+Shift+A",spellFrame.checkSpelling))

	c.frame.createMenuItemsFromTable("Edit",table)
#@nonl
#@-node:createSpellMenu
#@+node:onSelect
def onSelect (tag,keywords):
	
	"""A new vnode has just been selected.  Update the Spell Check window."""

	c = keywords.get("c")
	v = keywords.get("new_v")
	global spellFrame
	
	if top() and c and c.currentVnode():
		if c.currentVnode() != spellFrame.v:
			# print "onSelect",tag,`c.currentVnode()`,`spellFrame.v`
			spellFrame.update(show=false,fill=true)
		else:
			spellFrame.updateButtons()
#@nonl
#@-node:onSelect
#@+node:onCommand
def onCommand (tag,keywords):
	
	"""Update the Spell Check window after any command that might change text."""

	global spellFrame
	
	if top() and top().currentVnode():
		
		# print "onCommand",tag
		spellFrame.update(show=false,fill=false)
#@nonl
#@-node:onCommand
#@+node:class Aspell
class Aspell:
	
	"""A wrapper class for Aspell spell checker"""
	
	#@	@+others
	#@+node:__init__
	def __init__(self,local_dictionary_file,local_language_code):
		
		"""Ctor for the Aspell class."""
		
		self.altre = re.compile(".\s(.+)\s(\d+)\s(\d+):(.*)")
		self.attached = None
		self.input,self.output = None,None
		self.signonGiven = false
		
		self.aspell_exe_loc = self.getAspellDirectory()
		self.local_language_code = local_language_code
		
		if local_dictionary_file:
			self.local_dictionary_file = local_dictionary_file
			self.local_dictionary = "%s.wl" % os.path.splitext(local_dictionary_file)[0]
		else:
			print "failed to set aspell.local_dictionary"
			self.local.dictionary_file = None
			self.local_dictionary = None
	#@nonl
	#@-node:__init__
	#@+node:getAspellDirectory
	def getAspellDirectory(self):
		
		"""Get the directory containing aspell.exe from mod_spelling.ini"""
	
		try:
			fileName = os.path.join(app().loadDir,"../","plugins","mod_spelling.ini")
			config = ConfigParser.ConfigParser()
			config.read(fileName)
			return config.get("main","aspell_dir")
		except:
			es_exception()
			return None
	#@nonl
	#@-node:getAspellDirectory
	#@+node:openPipes
	def openPipes (self):
		
		"""Open the pipes to aspell.exe"""
		
		if self.input or self.output:
			print "pipes already open!"
			self.closePipes()
		
		#@	<< Ensure local dictionary is present >>
		#@+node:<< Ensure local dictionary is present >>
		add_dicts = ""
		
		if self.local_dictionary:
			if self.updateDictionary():
				add_dicts = "--add-extra-dicts %s" % self.local_dictionary
		
		
		#@-node:<< Ensure local dictionary is present >>
		#@nl
		cmd = "%s pipe %s" % (self.aspell_exe_loc, add_dicts)
		if not self.local_dictionary:
			print "openPipes: command = " + cmd
		
		try:
			self.input, self.output = popen2.popen2(cmd)
		except:
			print "exception opening pipe"
			self.input = self.output = None
		
		if self.input:
			self.attached = self.input.readline()
		else:
			self.attached = None
			
		if not self.signonGiven:
			self.signonGiven = true
			if self.attached:
				print self.attached
				es(self.attached,color="blue")
			else:
				print "can not open aspell"
				es("can not open aspell",color="red")
	#@nonl
	#@-node:openPipes
	#@+node:closePipes
	def closePipes (self):
		
		"""Close the pipes to aspell.exe"""
		
		# if self.input or self.output: print "closePipes"
		
		if self.input:
			self.input.close()
			self.input = None
	
		if self.output:
			self.output.close()
			self.output = None
	#@nonl
	#@-node:closePipes
	#@+node:listAlternates
	def listAlternates(self, aspell_return):
		
		"""Return a list of alternates from aspell."""
		
		match = self.altre.match(aspell_return)
	
		if match:
			return [item.strip() for item in match.groups()[3].split(",")]
		else:
			return []
	#@nonl
	#@-node:listAlternates
	#@+node:processWord
	def processWord(self, word):
	
		"""Pass a word to aspell and return the list of alternatives."""
		
		if not self.attached:
			return None
	
		# print "processWord",`word`,`self.output`
		
		self.output.write("%s\n" % word)
		
		ret,junk = self.input.readline(),self.input.readline()
	
		if ret == "*\n":
			return None
		else:
			return self.listAlternates(ret)
	#@nonl
	#@-node:processWord
	#@+node:updateDictionary
	def updateDictionary(self):
		
		"""Update the aspell dictionary from a list of words.
		
		Return true if the dictionary was update correctly."""
	
		try:
			# Create master list
			basename = os.path.splitext(self.local_dictionary)[0]
			cmd = (
				"%s --lang=%s create master %s.wl < %s.txt" %
				(self.aspell_exe_loc,self.local_language_code,basename,basename))
			os.popen(cmd)
			return true
	
		except Exception, err:
			es("Unable to update local aspell dictionary: %s" % err)
			print err
			add_dicts = ""
			return false
	#@nonl
	#@-node:updateDictionary
	#@-others
#@-node:class Aspell
#@+node:class spellDialog
class spellDialog (leoFind.leoFindBase):
	
	"""A class to create and manage Leo's Spell Check dialog."""
	
	#@	@+others
	#@+node:spell.__init__
	def __init__ (self):
		
		"""Ctor for the Leo Spelling dialog."""
		
		# Call the base ctor to create the dialog.
		leoFind.leoFindBase.__init__(self,"Leo Spell Checking",resizeable=false)
		
		self.local_dictionary_file = self.getLocalDictionary()
		self.local_language_code = self.getLocalLanguageCode("en")
		self.aspell = Aspell(self.local_dictionary_file,self.local_language_code)
		#@	<< set self.dictionary >>
		#@+node:<< set self.dictionary >>
		if self.local_dictionary_file:
		
			self.dictionary = self.readLocalDictionary(self.local_dictionary_file)
			if self.dictionary:
				# print "Local dictionary:", self.local_dictionary_file
				es("Local dictionary: %s" % shortFileName(self.local_dictionary_file),color="blue")
				if 0:
					keys = self.dictionary.keys()
					keys.sort()
					print "local dict:", keys
			else:
				self.dictionary = {}
				self.local_dictionary_file = None
		else:
			self.dictionary = {}
		#@nonl
		#@-node:<< set self.dictionary >>
		#@nl
		
		self.createFrame()
		self.fillbox([])
		
		# State variables.
		self.currentWord = None
		self.suggestions = []
		self.c = None
		self.v = None
		self.body = None
		self.work = Tk.Text(None) # A text widget for scanning.
	
		self.listBox.bind("<Double-Button-1>",self.onChangeThenFindButton)
		self.listBox.bind("<Button-1>",self.onSelectListBox)
		self.listBox.bind("<Map>",self.onMap)
	#@nonl
	#@-node:spell.__init__
	#@+node:getLocalDictionary
	def getLocalDictionary(self):
		
		"""Get the dictionaries containing words not in the standard dictionary from mod_spelling.ini"""
	
		try:
			fileName = os.path.join(app().loadDir,"../","plugins","mod_spelling.ini")
			config = ConfigParser.ConfigParser()
			config.read(fileName)
			return config.get("main","local_leo_dictionary",None)
		except:
			es_exception()
			return None
	#@nonl
	#@-node:getLocalDictionary
	#@+node:getLocalLanguageCode
	def getLocalLanguageCode(self,defaultLanguageCode):
		
		"""Get the dictionaries containing words not in the standard dictionary from mod_spelling.ini"""
	
		try:
			fileName = os.path.join(app().loadDir,"../","plugins","mod_spelling.ini")
			config = ConfigParser.ConfigParser()
			config.read(fileName)
			return config.get("main","local_language_code",defaultLanguageCode)
		except:
			es_exception()
			return defaultLanguageCode
	#@nonl
	#@-node:getLocalLanguageCode
	#@+node:readLocalDictionary
	def readLocalDictionary (self,local_dictionary):
		
		"""Read the dictionary of words which we use as a local dictionary
		
		Although Aspell itself has the functionality to handle this kind of things
		we duplicate it here so that we can also use it for the "ignore" functionality
		and so that in future a Python only solution could be developed."""
	
		try:
			f = open(local_dictionary,"r")
		except IOError:
			es("Unable to open local dictionary '%s' - using a blank one instead" % local_dictionary)
			return None
		
		try:
			# Create the dictionary - there are better ways to do this
			# in later Python's but we stick with this method for compatibility
			dct = {}
			for word in f.readlines():
				dct[word.strip().lower()] = 0
		finally:
			f.close()
	
		return dct
	#@nonl
	#@-node:readLocalDictionary
	#@+node:createFrame
	def createFrame (self):
		
		"""Create the Spelling dialog."""
		
		# Create the find panel...
		outer = Tk.Frame(self.frame,relief="groove",bd=2)
		outer.pack(padx=2,pady=2,expand=1,fill="both")
	
		#@	<< Create the text and suggestion panes >>
		#@+node:<< Create the text and suggestion panes >>
		f = outer
		
		f2 = Tk.Frame(f)
		f2.pack(expand=1,fill="x")
		self.wordLabel = Tk.Label(f2, text="Suggestions for:")
		self.wordLabel.pack(side="left")
		
		fpane = Tk.Frame(f,bd=2)
		fpane.pack(side="top", expand=1, fill="x")
		
		self.listBox = Tk.Listbox(fpane,height=30,selectmode="single")
		self.listBox.pack(side="left", expand=1, fill="both")
		
		listBoxBar = Tk.Scrollbar(fpane,name='listBoxBar')
		
		for bar,txt in ((listBoxBar,self.listBox),):
			txt['yscrollcommand'] = bar.set
			bar['command'] = txt.yview
			bar.pack(side="right", fill="y")
		#@-node:<< Create the text and suggestion panes >>
		#@nl
		#@	<< Create the spelling buttons >>
		#@+node:<< Create the spelling buttons >>
		# Create the button panes
		buttons1  = Tk.Frame(outer,bd=1)
		buttons1.pack (anchor="n",expand=1,fill="x")
		
		buttons2  = Tk.Frame(outer,bd=1)
		buttons2.pack (anchor="n",expand=1,fill="none")
		
		buttonList = []
		for text,command in (
			("Find",self.onFindButton),
			("Change",self.onChangeButton),
			("Change, Find",self.onChangeThenFindButton),
			("Add",self.onAddButton)):
			width = max(6,len(text))
			b=Tk.Button(buttons1,width=width,text=text,command=command)
			b.pack(side="left",fill="none",expand=1)
			buttonList.append(b)
				
		for text,command in (
			("Undo",self.onUndoButton),
			("Redo",self.onRedoButton),
			("Ignore",self.onIgnoreButton),
			("Hide",self.onHideButton)):
			width = max(6,len(text))
			b=Tk.Button(buttons2,width=width,text=text,command=command)
			b.pack(side="left",fill="none",expand=0)
			buttonList.append(b)
		
		# We need these to enable or disable buttons.
		(self.findButton, self.changeButton,
		 self.changeFindButton, self.addButton, 
		 self.undoButton, self.redoButton,
		 self.ignoreButton, self.hideButton) = buttonList
		#@nonl
		#@-node:<< Create the spelling buttons >>
		#@nl
	#@-node:createFrame
	#@+node:onAddButton
	def onAddButton (self):
		
		"""Handle a click in the Add button in the Check Spelling dialog."""
	
		self.add()
		self.closePipes()
	
	#@-node:onAddButton
	#@+node:onIgnoreButton
	def onIgnoreButton (self):
	
		"""Handle a click in the Ignore button in the Check Spelling dialog."""
	
		self.ignore()
		self.closePipes()
	#@nonl
	#@-node:onIgnoreButton
	#@+node:onChangeButton & onChangeThenFindButton
	def onChangeButton (self):
		
		"""Handle a click in the Change button in the Check Spelling dialog."""
	
		self.change()
		self.closePipes()
		self.updateButtons()
		
	# Event needed for double-click event.
	def onChangeThenFindButton (self,event=None): 
			
		"""Handle a click in the "Change, Find" button in the Check Spelling dialog."""
	
		if self.change():
			self.find()
		self.closePipes()
		self.updateButtons()
	#@nonl
	#@-node:onChangeButton & onChangeThenFindButton
	#@+node:onFindButton
	def onFindButton (self):
		
		"""Handle a click in the Find button in the Check Spelling dialog."""
	
		self.find()
		self.updateButtons()
		self.closePipes()
	#@nonl
	#@-node:onFindButton
	#@+node:onHideButton
	def onHideButton (self):
		
		"""Handle a click in the Hide button in the Check Spelling dialog."""
	
		self.closePipes()
		self.top.withdraw()
	#@-node:onHideButton
	#@+node:onRedoButton & onUndoButton
	def onRedoButton (self):
		
		"""Handle a click in the Redo button in the Check Spelling dialog."""
	
		self.c.undoer.redo() # Not a command, so command hook doesn't fire.
		self.update(show=false,fill=false)
		self.c.frame.body.focus_force()
		
	def onUndoButton (self):
		
		"""Handle a click in the Undo button in the Check Spelling dialog."""
	
		self.c.undoer.undo() # Not a command, so command hook doesn't fire.
		self.update(show=false,fill=false)
		self.c.frame.body.focus_force()
	#@nonl
	#@-node:onRedoButton & onUndoButton
	#@+node:add
	def add (self):
	
		"""Add the selected suggestion to the dictionary."""
		
		if not self.local_dictionary_file:
			return
		
		try:
			f = None
			try:
				# Rewrite the dictionary in alphabetical order.
				f = open(self.local_dictionary_file,"r")
				words = f.readlines()
				f.close()
				words = [word.strip() for word in words]
				words.append(self.currentWord)
				words.sort()
				f = open(self.local_dictionary_file,"w")
				for word in words:
					f.write("%s\n" % word)
				f.flush()
				f.close()
				es("Adding ",color="blue",newline=false) ; es('%s' % self.currentWord)
			except IOError:
				es("Can not add %s to dictionary" % self.currentWord,color="red")
		finally:
			if f: f.close()
			
		self.dictionary[self.currentWord.lower()] = 0
		
		# Restart aspell so that it re-reads its dictionary.
		self.aspell.closePipes()
		self.aspell.openPipes()
		
		self.onFindButton()
	#@nonl
	#@-node:add
	#@+node:change
	def change(self):
		
		"""Make the selected change to the text"""
	
		c = self.c ; v = self.v ; t = self.body
		
		selection = self.getSuggestion()
		if selection:
			start,end = oldSel = getTextSelection(t)
			if start:
				if t.compare(start, ">", end):
					start,end = end,start
				t.delete(start,end)
				t.insert(start,selection)
				setTextSelection(t,start,start + "+%dc" % (len(selection)))
				newSel = getTextSelection(t)
	
				# update node, undo status, dirty flag, changed mark & recolor
				c.beginUpdate()
				c.tree.onBodyChanged(v,"Change",oldSel=oldSel,newSel=newSel)
				c.endUpdate(true)
				t.focus_set()
				return true
	
		# The focus must never leave the body pane.
		t.focus_set()
		return false
	#@nonl
	#@-node:change
	#@+node:checkSpelling
	def checkSpelling (self,event=None):
		
		"""Open the Check Spelling dialog."""
	
		self.top.deiconify()
		self.top.lift()
		self.update(show=true,fill=false)
	#@nonl
	#@-node:checkSpelling
	#@+node:find
	def find(self):
		
		"""Find the next unknown word."""
		
		# Reload the work pane from the present node.
		s = self.body.get("1.0","end").rstrip()
		self.work.delete("1.0","end")
		self.work.insert("end",s)
		
		# Reset the insertion point of the work widget.
		ins = self.body.index("insert")
		self.work.mark_set("insert",ins)
	
		alts,word = self.findNextMisspelledWord()
		self.currentWord = word # Need to remember this for 'add' and 'ignore'
		
		if alts:
			self.fillbox(alts,word)
			self.body.focus_set()
						
			# Copy the working selection range to the body pane
			start,end = getTextSelection (self.work)
			setTextSelection (self.body,start,end)
		else:
			es("no more misspellings")
			self.fillbox([])
	#@nonl
	#@-node:find
	#@+node:ignore
	def ignore (self):
		
		"""Ignore the incorrect word for the duration of this spell check session."""
		
		es("Ignoring ",color="blue",newline=false) ; es('%s' % self.currentWord)
		self.dictionary[self.currentWord.lower()] = 0
		self.onFindButton()
	#@nonl
	#@-node:ignore
	#@+node:closePipes
	def closePipes(self):
		
		self.aspell.closePipes()
	#@nonl
	#@-node:closePipes
	#@+node:fillbox
	def fillbox(self,alts,word=None):
	
		"""Update the suggestions listbox in the Check Spelling dialog."""
		
		self.suggestions = alts
		
		if not word:
			word = ""
	
		self.wordLabel.configure(text = "Suggestions for: " + word)
		self.listBox.delete(0,"end")
	
		for i in xrange(len(self.suggestions)):
			self.listBox.insert(i,self.suggestions[i])
		
		if len(self.suggestions):
			self.listBox.select_set(1) # This doesn't show up because we don't have focus.
	#@nonl
	#@-node:fillbox
	#@+node:findNextMisspelledWord
	def findNextMisspelledWord(self):
		
		"""Find the next unknown word."""
		
		aspell = self.aspell ; alts = None ; word = None
		c = self.c ; v = self.v
		try:
			aspell.openPipes()
			try:
				while 1:
					v,word = self.findNextWord(v) 
					if not v or not word:
						alts = None
						break
					#@				<< Skip word if ignored or in local dictionary >>
					#@+node:<< Skip word if ignored or in local dictionary >>
					#@+at 
					#@nonl
					# We don't bother to call apell if the word is in our 
					# dictionary. The dictionary contains both locally 
					# 'allowed' words and 'ignored' words. We put the test 
					# before aspell rather than after aspell because the cost 
					# of checking aspell is higher than the cost of checking 
					# our local dictionary. For small local dictionaries this 
					# is probably not true and this code could easily be 
					# located after the aspell call
					#@-at
					#@@c
					
					if self.dictionary.has_key(word.lower()):
						
						# print "Ignored", word
						continue
						
					# print "Didn't ignore '%s'" % word
					#@nonl
					#@-node:<< Skip word if ignored or in local dictionary >>
					#@nl
					alts = aspell.processWord(word)
					if alts:
						self.v = v
						c.beginUpdate()
						c.tree.expandAllAncestors(v)
						c.selectVnode(v)
						c.endUpdate()
						break
			except:
				es_exception()
		finally:
			aspell.closePipes()
			return alts, word
	#@nonl
	#@-node:findNextMisspelledWord
	#@+node:findNextWord
	def findNextWord (self,v):
		
		"""Scan for the next word, leaving the result in the work widget"""
	
		t = self.work
		word_start = string.letters + '_'
		t.mark_set("insert","insert wordend + 1c")
		while 1:
			# print `t.index("insert")`,`t.index("end-1c")`
			if t.compare("insert",">=","end - 1c"):
				v = v.threadNext()
				if not v: return None,None
				t.delete("1.0","end")
				t.insert("end",v.bodyString())
				t.mark_set("insert","1.0")
			elif t.compare("insert",">=","insert lineend - 1c"):
				t.mark_set("insert","insert lineend + 1line")
			else:
				ch = t.get("insert")
				if ch in word_start:
					word = t.get("insert wordstart","insert wordend")
					setTextSelection(t,"insert wordstart","insert wordend")
					# print "findNextWord:",`word`
					return v,word
				elif ch:
					t.mark_set("insert","insert + 1c")
	#@nonl
	#@-node:findNextWord
	#@+node:getSuggestion
	def getSuggestion (self):
		
		"""Return the selected suggestion from the listBox."""
		
		# Work around an old Python bug.  Convert strings to ints.
		items = self.listBox.curselection()
		try:
			items = map(int, items)
		except ValueError: pass
	
		if items:
			n = items[0]
			suggestion = self.suggestions[n]
			return suggestion
		else:
			return None
	#@nonl
	#@-node:getSuggestion
	#@+node:onMap
	def onMap (self,event=None):
		
		"""Respond to a Tk <Map> event."""
		
		self.update(show=false,fill=false)
	#@nonl
	#@-node:onMap
	#@+node:onSelectListBox
	def onSelectListBox (self,event=None):
		
		"""Respond to a click in the selection listBox."""
		
		self.updateButtons()
		self.body.focus_set()
	#@-node:onSelectListBox
	#@+node:update
	def update (self,show=true,fill=false):
		
		"""Update the Spell Check dialog."""
		
		# print "update(show=%d,fill=%d)" % (show,fill)
		
		# Always assume that the user has changed text.
		self.c = c = top()
		self.v = c.currentVnode()
		self.body = c.frame.body
		if fill:
			self.fillbox([])
		self.updateButtons()
		if show:
			self.top.deiconify()
			# Don't interfere with Edit Headline commands.
			self.body.focus_set()
			
		# Give the signon if it hasn't been given yet.
		if not self.aspell.signonGiven:
			self.aspell.openPipes()
			self.aspell.closePipes()
	#@nonl
	#@-node:update
	#@+node:updateButtons
	def updateButtons (self):
		
		"""Enable or disable buttons in the Check Spelling dialog."""
		
		start,end = getTextSelection(self.body)
		state = choose(self.suggestions and start,"normal","disabled")
		
		self.changeButton.configure(state=state)
		self.changeFindButton.configure(state=state)
	
		state = choose(self.c.undoer.canRedo(),"normal","disabled")
		self.redoButton.configure(state=state)
		
		state = choose(self.c.undoer.canUndo(),"normal","disabled")
		self.undoButton.configure(state=state)
		
		state = choose(self.local_dictionary_file,"normal","disabled")
		self.addButton.configure(state=state)
	
		self.ignoreButton.configure(state="normal")
	#@nonl
	#@-node:updateButtons
	#@-others
#@nonl
#@-node:class spellDialog
#@-others

if Tkinter and 0: # Register the handlers...

	if app.gui is None:
		app.createTkGui(__file__)

	if app.gui.guiName() == "tkinter":

		spellFrame = spellDialog()
		spellFrame.top.withdraw()
		app().globalWindows.append(spellFrame)
		
		registerHandler("create-optional-menus",createSpellMenu)
		registerHandler("select2",onSelect)
		registerHandler("command2",onCommand) # For any command that might change the text.
		registerHandler("bodykey2",onSelect) # For updating buttons.
		registerHandler(("bodyclick2","bodydclick2","bodyrclick2"),onSelect) # These affect selection.
		
		__version__ = "0.2.0"
		plugin_signon(__name__)
#@nonl
#@-node:@file mod_spelling.py
#@-leo
