# -*- coding: utf-8 -*- # Copyright: Damien Elmes # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html from PyQt4.QtGui import * from PyQt4.QtCore import * from PyQt4.QtSvg import * # fixme: obsolete? from PyQt4.QtWebKit import QWebView import re, os, sys, tempfile, urllib2, ctypes, simplejson from anki.utils import stripHTML from anki.sound import play from anki.hooks import addHook, removeHook, runHook, runFilter from aqt.sound import getAudio from aqt.webview import AnkiWebView from aqt.utils import shortcut, showInfo, showWarning, getBase import anki.js _html = """ %s
""" # fixme: use shortcut() for mac shortcuts if sys.platform.startswith("win32"): ActivateKeyboardLayout = ctypes.windll.user32.ActivateKeyboardLayout ActivateKeyboardLayout.restype = ctypes.c_void_p ActivateKeyboardLayout.argtypes = [ctypes.c_void_p, ctypes.c_uint] GetKeyboardLayout = ctypes.windll.user32.GetKeyboardLayout GetKeyboardLayout.restype = ctypes.c_void_p GetKeyboardLayout.argtypes = [ctypes.c_uint] class Editor(object): def __init__(self, mw, widget): self.widget = widget self.mw = mw self.fact = None self.onChange = None self._loaded = False # to be handled js side #self.lastFocusedEdit = None self.changeTimer = None # current card, for card layout self.card = None addHook("deckClosed", self.deckClosedHook) addHook("guiReset", self.refresh) addHook("colourChanged", self.colourChanged) self.setupOuter() self.setupButtons() self.setupWeb() self.setupTags() def close(self): removeHook("deckClosed", self.deckClosedHook) removeHook("guiReset", self.refresh) removeHook("colourChanged", self.colourChanged) # Initial setup ############################################################ def setupOuter(self): l = QVBoxLayout()#self.widget) l.setMargin(0) l.setSpacing(3) self.widget.setLayout(l) self.outerLayout = l def setupWeb(self): self.web = EditorWebView(self.widget, self) self.web.allowDrops = True self.web.setBridge(self.bridge) self.outerLayout.addWidget(self.web) # pick up the window colour p = self.web.palette() p.setBrush(QPalette.Base, Qt.transparent) self.web.page().setPalette(p) self.web.setAttribute(Qt.WA_OpaquePaintEvent, False) # Top buttons ###################################################################### def _addButton(self, name, func, key=None, tip=None, size=True, text="", check=False): b = QPushButton(text) if check: b.connect(b, SIGNAL("clicked(bool)"), func) else: b.connect(b, SIGNAL("clicked()"), func) if size: b.setFixedHeight(20) b.setFixedWidth(20) b.setStyle(self.plastiqueStyle) b.setFocusPolicy(Qt.NoFocus) if not text: b.setIcon(QIcon(":/icons/%s.png" % name)) if key: b.setShortcut(key) if tip: b.setToolTip(tip) if check: b.setCheckable(True) self.iconsBox.addWidget(b) self._buttons[name] = b return b def setupButtons(self): self._buttons = {} # button styles for mac self.plastiqueStyle = QStyleFactory.create("plastique") self.widget.setStyle(self.plastiqueStyle) # icons self.iconsBox = QHBoxLayout() self.iconsBox.setMargin(0) self.iconsBox.setSpacing(0) self.outerLayout.addLayout(self.iconsBox) # align to right self.iconsBox.addItem(QSpacerItem(20,1, QSizePolicy.Expanding)) b = self._addButton b("layout", self.onCardLayout, "Ctrl+l", shortcut(_("Layout (Ctrl+l)")), size=False, text=_("Layout...")) b("text_bold", self.toggleBold, "Ctrl+b", _("Bold text (Ctrl+b)"), check=True) b("text_italic", self.toggleItalic, "Ctrl+i", _("Italic text (Ctrl+i)"), check=True) b("text_under", self.toggleUnderline, "Ctrl+u", _("Underline text (Ctrl+u)"), check=True) b("text_super", self.toggleSuper, "Ctrl+=", _("Superscript (Ctrl+=)"), check=True) b("text_sub", self.toggleSub, "Ctrl+Shift+=", _("Subscript (Ctrl+Shift+=)"), check=True) b("text_remove", self.removeFormat, "Ctrl+r", _("Subscript (Ctrl+r)")) but = b("foreground", self.onForeground, "F7", text=" ") self.setupForegroundButton(but) but = b("cloze", self.onCloze, "F9", _("Cloze (F9)"), text="[...]") but.setFixedWidth(24) # fixme: better image names but = b("colors", self.onAddPicture, "F3", _("Add picture (F3)")) but = b("text-speak", self.onAddSound, "F3", _("Add audio/video (F4)")) but = b("media-record", self.onRecSound, "F5", _("Record audio (F5)")) but = b("tex", self.latexMenu, "Ctrl+t", _("LaTeX (Ctrl+t)")) # insertLatex, insertLatexEqn, insertLatexMathEnv but = b("text-xml", self.onHtmlEdit, "Ctrl+x", _("Source (Ctrl+x)")) def setupForegroundButton(self, but): self.foregroundFrame = QFrame() self.foregroundFrame.setAutoFillBackground(True) self.colourChanged() hbox = QHBoxLayout() hbox.addWidget(self.foregroundFrame) hbox.setMargin(5) but.setLayout(hbox) def enableButtons(self, val=True): self.bold.setEnabled(val) self.italic.setEnabled(val) self.underline.setEnabled(val) self.foreground.setEnabled(val) self.addPicture.setEnabled(val) self.addSound.setEnabled(val) self.latex.setEnabled(val) self.latexEqn.setEnabled(val) self.latexMathEnv.setEnabled(val) self.cloze.setEnabled(val) self.htmlEdit.setEnabled(val) self.recSound.setEnabled(val) def disableButtons(self): self.enableButtons(False) def onCardLayout(self): from aqt.clayout import CardLayout if self.card: type = 1; ord = self.card.ord else: type = 0; ord = 0 CardLayout(self.mw, self.fact, type=type, ord=ord, parent=self.widget) # JS->Python bridge ###################################################################### def bridge(self, str): print str if str.startswith("focus") or str.startswith("key"): (type, num, txt) = str.split(":", 2) self.fact._fields[int(num)] = txt if type == "focus": runHook("editor.focusLost", self.fact) else: runHook("editor.keyPressed", self.fact) self.fact.flush() elif str.startswith("state"): (cmd, txt) = str.split(":", 1) r = simplejson.loads(txt) self._buttons['text_bold'].setChecked(r['bold']) self._buttons['text_italic'].setChecked(r['italic']) self._buttons['text_under'].setChecked(r['under']) self._buttons['text_super'].setChecked(r['super']) self._buttons['text_sub'].setChecked(r['sub']) elif str.startswith("cloze"): (cmd, num, txt) = str.split(":", 2) if not txt: showInfo(_("Please select some text first."), help="ClozeDeletion") return # check that the model is set up for cloze deletion ok = False for t in self.fact.model().templates: if "cloze" in t['qfmt'] or "cloze" in t['afmt']: ok = True break if not ok: showInfo(_("Please add a cloze deletion model."), help="ClozeDeletion") return num = int(num) f = self.fact._fields[num] # find the highest existing cloze m = re.findall("\{\{c(\d+)::", f) if m: next = sorted([int(x) for x in m])[-1] + 1 else: next = 1 self.fact._fields[num] = f.replace( txt, "{{c%d::%s}}" % (next, txt)) self.loadFact() # Setting/unsetting the current fact ###################################################################### def _loadFinished(self, w): self._loaded = True if self.fact: self.loadFact() def setFact(self, fact): "Make FACT the current fact." self.fact = fact if self.changeTimer: self.changeTimer.stop() self.changeTimer = None if self.fact: self.web.setHtml(_html % (getBase(self.mw.deck), anki.js.all), loadCB=self._loadFinished) self.updateTags() else: self.widget.hide() def loadFact(self): if not self._loaded: # will be loaded when page is ready return # fixme: focus on first widget self.web.eval("setFields(%s);" % simplejson.dumps(self.fact.items())) self.widget.show() def refresh(self): if self.fact: self.fact.load() # fixme: what if fact is deleted? self.setFact(self.fact) def deckClosedHook(self): self.setFact(None) # if field.fieldModel.features: # w.setLayoutDirection(Qt.RightToLeft) # else: # w.setLayoutDirection(Qt.LeftToRight) # catch changes w.connect(w, SIGNAL("lostFocus"), lambda w=w: self.onFocusLost(w)) w.connect(w, SIGNAL("textChanged()"), self.onTextChanged) w.connect(w, SIGNAL("currentCharFormatChanged(QTextCharFormat)"), lambda w=w: self.formatChanged(w)) return w if check: self.checkValid() def saveFieldsNow(self): "Must call this before adding cards, closing dialog, etc." if not self.fact: return # disable timer if self.changeTimer: self.changeTimer.stop() self.changeTimer = None if self.onChange: self.onChange('field') # save fields and run features w = self.focusedEdit() if w: self.onFocusLost(w) self.onTagChange() # ensure valid self.checkValid() def checkValid(self): empty = [] dupe = [] for field in self.fact.fields: p = QPalette() p.setColor(QPalette.Text, QColor("#000000")) if not self.fieldValid(field): empty.append(field) p.setColor(QPalette.Base, QColor("#ffffcc")) self.fields[field.name][1].setPalette(p) elif not self.fieldUnique(field): dupe.append(field) p.setColor(QPalette.Base, QColor("#ffcccc")) self.fields[field.name][1].setPalette(p) else: p.setColor(QPalette.Base, QColor("#ffffff")) self.fields[field.name][1].setPalette(p) def onHtmlEdit(self): def helpRequested(): aqt.openHelp("HtmlEditor") w = self.focusedEdit() if w: self.saveFields() d = QDialog(self.widget) form = aqt.forms.edithtml.Ui_Dialog() form.setupUi(d) d.connect(form.buttonBox, SIGNAL("helpRequested()"), helpRequested) form.textEdit.setPlainText(self.widgets[w].value) form.textEdit.moveCursor(QTextCursor.End) d.exec_() w.setHtml(unicode(form.textEdit.toPlainText()).\ replace("\n", "")) self.saveFields() # Tag and group handling ###################################################################### def setupTags(self): import aqt.tagedit g = QGroupBox(self.widget) tb = QGridLayout() tb.setSpacing(12) tb.setMargin(6) # group l = QLabel(_("Group")) tb.addWidget(l, 0, 0) self.group = aqt.tagedit.TagEdit(self.widget, type=1) self.group.connect(self.group, SIGNAL("lostFocus"), self.onGroupChange) tb.addWidget(self.group, 0, 1) # tags l = QLabel(_("Tags")) tb.addWidget(l, 1, 0) self.tags = aqt.tagedit.TagEdit(self.widget) self.tags.connect(self.tags, SIGNAL("lostFocus"), self.onTagChange) tb.addWidget(self.tags, 1, 1) g.setLayout(tb) self.outerLayout.addWidget(g) def updateTags(self): if self.tags.deck != self.mw.deck: self.tags.setDeck(self.mw.deck) self.group.setDeck(self.mw.deck) self.group.setText(self.mw.deck.groupName( self.fact.model().conf['gid'])) def onGroupChange(self): pass def onTagChange(self): if not self.fact: return old = self.fact.tags self.fact.tags = canonifyTags(unicode(self.tags.text())) if old != self.fact.tags: self.deck.db.flush() self.deck.updateFactTags([self.fact.id]) self.fact.setModified(textChanged=True, deck=self.deck) self.deck.flushMod() self.mw.reset(runHooks=False) if self.onChange: self.onChange('tag') # Format buttons ###################################################################### def toggleBold(self, bool): self.web.eval("setFormat('bold');") def toggleItalic(self, bool): self.web.eval("setFormat('italic');") def toggleUnderline(self, bool): self.web.eval("setFormat('underline');") def toggleSuper(self, bool): self.web.eval("setFormat('superscript');") def toggleSub(self, bool): self.web.eval("setFormat('subscript');") def removeFormat(self): self.web.eval("setFormat('removeFormat');") def onCloze(self): self.removeFormat() self.web.eval("cloze();") # Foreground colour ###################################################################### def _updateForegroundButton(self, txtcol): self.foregroundFrame.setPalette(QPalette(QColor(txtcol))) self.foregroundFrame.setStyleSheet("* {background-color: %s}" % txtcol) def colourChanged(self): recent = self.mw.config['recentColours'] self._updateForegroundButton(recent[-1]) def onForeground(self): class ColourPopup(QDialog): def __init__(self, parent): QDialog.__init__(self, parent, Qt.FramelessWindowHint) def event(self, evt): if evt.type() == QEvent.WindowDeactivate: self.close() return QDialog.event(self, evt) p = ColourPopup(self.widget) p.move(self.foregroundFrame.mapToGlobal(QPoint(0,0))) g = QGridLayout(p) g.setMargin(4) g.setSpacing(0) p.setLayout(g) lastWidget = None self.colourNext = QShortcut(QKeySequence("F7"), p) p.connect(self.colourNext, SIGNAL("activated()"), self.onNextColour) self.colourChoose = QShortcut(QKeySequence("F6"), p) p.connect(self.colourChoose, SIGNAL("activated()"), self.onChooseColourKey) for n, c in enumerate(reversed(self.mw.config['recentColours'])): col = QToolButton() col.setAutoRaise(True) col.setFixedWidth(64) col.setFixedHeight(16) col.setAutoFillBackground(True) col.setPalette(QPalette(QColor(c))) col.setStyleSheet("* {background-color: %s}" % c) col.connect(col, SIGNAL("clicked()"), lambda c=c: self.onChooseColour(c)) g.addWidget(col, n, 0) if lastWidget: p.setTabOrder(lastWidget, col) lastWidget = col but = QPushButton("X") but.setFixedWidth(16) but.setFixedHeight(16) but.setAutoDefault(False) but.connect(but, SIGNAL("clicked()"), lambda c=c: self.onRemoveColour(c)) g.addWidget(but, n, 1) spc = QSpacerItem(5,10, QSizePolicy.Fixed) g.addItem(spc, n+1, 0) cb = QPushButton(_("+")) cb.connect(cb, SIGNAL("clicked()"), self.onNewColour) cb.setFixedWidth(80) cb.setFixedHeight(16) cb.setAutoDefault(False) g.addWidget(cb, n+2, 0, 1, 2) self.colourDiag = p p.show() def onRemoveColour(self, colour): recent = self.mw.config['recentColours'] recent.remove(colour) if not recent: recent.append("#000000") self.colourDiag.close() self.onForeground() runHook("colourChanged") def onNextColour(self): try: self.colourDiag.focusWidget().nextInFocusChain().setFocus() except: ui.utils.showInfo("Your Qt version is too old to support this.") def onChooseColourKey(self): try: self.colourDiag.focusWidget().click() except: # dialog focused pass def onChooseColour(self, colour): recent = self.mw.config['recentColours'] recent.remove(colour) recent.append(colour) self.web.eval("setFormat('forecolor', '%s')" % colour) self.colourDiag.close() runHook("colourChanged") def onNewColour(self): new = QColorDialog.getColor(Qt.white, self.widget) self.widget.raise_() recent = self.mw.config['recentColours'] if new.isValid(): txtcol = unicode(new.name()) if txtcol not in recent: recent.append(txtcol) runHook("colourChanged") self.onChooseColour(txtcol) # Audio/video/images ###################################################################### def initMedia(self): os.chdir(self.deck.mediaDir(create=True)) def onAddPicture(self): # get this before we open the dialog w = self.focusedEdit() key = (_("Images") + " (*.jpg *.png *.gif *.tiff *.svg *.tif *.jpeg)") file = ui.utils.getFile(self.widget, _("Add an image"), "picture", key) if not file: return if file.lower().endswith(".svg"): # convert to a png s = QSvgRenderer(file) i = QImage(s.defaultSize(), QImage.Format_ARGB32_Premultiplied) p = QPainter() p.begin(i) s.render(p) p.end() (fd, name) = tempfile.mkstemp(prefix="anki", suffix=".png") file = unicode(name, sys.getfilesystemencoding()) i.save(file) self._addPicture(file, widget=w) def _addPicture(self, file, widget=None): self.initMedia() if widget: w = widget else: w = self.focusedEdit() path = self._addMedia(file) self.maybeDelete(path, file) w.insertHtml('' % path) def _addMedia(self, file): try: return self.deck.addMedia(file) except (IOError, OSError), e: ui.utils.showWarning(_("Unable to add media: %s") % unicode(e), parent=self.widget) def onAddSound(self): # get this before we open the dialog w = self.focusedEdit() key = (_("Sounds/Videos") + " (*.mp3 *.ogg *.wav *.avi *.ogv *.mpg *.mpeg *.mov *.mp4 " + "*.mkv *.ogx *.ogv *.oga *.flv *.swf *.flac)") file = ui.utils.getFile(self.widget, _("Add audio"), "audio", key) if not file: return self._addSound(file, widget=w) def _addSound(self, file, widget=None, copy=True): self.initMedia() if widget: w = widget else: w = self.focusedEdit() if copy: path = self._addMedia(file) self.maybeDelete(path, file) else: path = file anki.sound.play(path) w.insertHtml('[sound:%s]' % path) def maybeDelete(self, new, old): if not self.mw.config['deleteMedia']: return if new == os.path.basename(old): return try: os.unlink(old) except: pass def onRecSound(self): self.initMedia() w = self.focusedEdit() try: file = getAudio(self.widget) except: if sys.platform.startswith("darwin"): ui.utils.showInfo(_('''\ Please install lame to enable recording.'''), parent=self.widget) return raise if file: self._addSound(file, w, copy=False) # LaTeX ###################################################################### def latexMenu(self): pass def insertLatex(self): w = self.focusedEdit() if w: selected = w.textCursor().selectedText() self.deck.mediaDir(create=True) cur = w.textCursor() pos = cur.position() w.insertHtml("[latex]%s[/latex]" % selected) cur.setPosition(pos+7) w.setTextCursor(cur) def insertLatexEqn(self): w = self.focusedEdit() if w: selected = w.textCursor().selectedText() self.deck.mediaDir(create=True) cur = w.textCursor() pos = cur.position() w.insertHtml("[$]%s[/$]" % selected) cur.setPosition(pos+3) w.setTextCursor(cur) def insertLatexMathEnv(self): w = self.focusedEdit() if w: selected = w.textCursor().selectedText() self.deck.mediaDir(create=True) cur = w.textCursor() pos = cur.position() w.insertHtml("[$$]%s[/$$]" % selected) cur.setPosition(pos+4) w.setTextCursor(cur) # Pasting, drag & drop, and keyboard layouts ###################################################################### class EditorWebView(AnkiWebView): pics = ("jpg", "jpeg", "png", "tif", "tiff", "gif") audio = ("wav", "mp3", "ogg", "flac") def __init__(self, parent, editor): AnkiWebView.__init__(self, parent) self.editor = editor self.__tmpDir = None self.errtxt = _("An error occured while opening %s") # if sys.platform.startswith("win32"): # self._ownLayout = None # after the drop/copy, make sure data updated? def keyPressEvent(self, evt): self._curKey = True if evt.matches(QKeySequence.Paste): self.onPaste() return QWebView.keyPressEvent(self, evt) def contextMenuEvent(self, evt): # adjust in case the user is going to paste self.onPaste() QWebView.contextMenuEvent(self, evt) def dropEvent(self, evt): oldmime = evt.mimeData() # coming from us? if evt.source() == self: # if they're copying just an image, we need to turn it into html # again txt = "" if not oldmime.hasHtml() and oldmime.hasUrls(): # qt gives it to us twice txt += '' % os.path.basename( unicode(oldmime.urls()[0].toString())) mime = QMimeData() mime.setHtml(txt) else: mime = self._processMime(oldmime) # create a new event with the new mime data new = QDropEvent(evt.pos(), evt.possibleActions(), mime, evt.mouseButtons(), evt.keyboardModifiers()) evt.accept() QWebView.dropEvent(self, new) def onPaste(self): clip = self.editor.mw.app.clipboard() mime = clip.mimeData() mime = self._processMime(mime) clip.setMimeData(mime) def _processMime(self, mime): print "html=%s image=%s urls=%s txt=%s" % ( mime.hasHtml(), mime.hasImage(), mime.hasUrls(), mime.hasText()) print "html", mime.html() print "urls", mime.urls() print "text", mime.text() if mime.hasUrls(): return self._processUrls(mime) if mime.hasText() and (self.mw.config['stripHTML'] or not mime.hasHtml()): txt = unicode(mime.text()) l = txt.lower() if l.startswith("http://") or l.startswith("file://"): hadN = False if "\n" in txt: txt = txt.split("\n")[0] hadN = True if "\r" in txt: txt = txt.split("\r")[0] hadN = True if not mime.hasImage() or hadN: # firefox on linux just gives us a url ext = txt.split(".")[-1].lower() try: if ext in pics: name = self._retrieveURL(txt) self.parent._addPicture(name, widget=self) elif ext in audio: name = self._retrieveURL(txt) self.parent._addSound(name, widget=self) else: # not image or sound, treat as plain text self.insertPlainText(mime.text()) return True except urllib2.URLError, e: ui.utils.showWarning(errtxt % e) else: self.insertPlainText(mime.text()) return True if mime.hasImage(): im = QImage(mime.imageData()) if im.hasAlphaChannel(): (fd, name) = tempfile.mkstemp(prefix="paste", suffix=".png") uname = unicode(name, sys.getfilesystemencoding()) im.save(uname) else: (fd, name) = tempfile.mkstemp(prefix="paste", suffix=".jpg") uname = unicode(name, sys.getfilesystemencoding()) im.save(uname, None, 95) self.parent._addPicture(uname, widget=self) return True if mime.hasHtml(): self.insertHtml(self.simplifyHTML(unicode(mime.html()))) return True def _processUrls(self, mime): links = [] for url in mime.urls(): url = unicode(url.toString()) link = self._retrieveURL(url) if link: links.append(link) mime = QMimeData() mime.setHtml("".join(links)) return mime def _retrieveURL(self, url): # fetch it into a temporary folder try: req = urllib2.Request(url, None, { 'User-Agent': 'Mozilla/5.0 (compatible; Anki)'}) filecontents = urllib2.urlopen(req).read() except urllib2.URLError, e: showWarning(self.errtxt % e) return path = os.path.join(self._tmpDir(), os.path.basename(url)) file = open(path, "wb") file.write(filecontents) file.close() # copy to media folder name = self.editor.mw.deck.media.addFile(path) print "name was", name # return a local html link ext = name.split(".")[-1].lower() if ext in self.pics: return '' % name else: # FIXME: should also autoplay audio return '[sound:%s]' % name def _tmpDir(self): if not self.__tmpDir: self.__tmpDir = tempfile.mkdtemp(prefix="anki") return self.__tmpDir def simplifyHTML(self, html): "Remove all style information and P tags." # fixme if not self.mw.config['stripHTML']: return html html = stripHTML(html) return html # def focusOutEvent(self, evt): # if self.mw.config['preserveKeyboard'] and sys.platform.startswith("win32"): # self._ownLayout = GetKeyboardLayout(0) # ActivateKeyboardLayout(self._programLayout, 0) # self.emit(SIGNAL("lostFocus")) # def focusInEvent(self, evt): # if self.mw.config['preserveKeyboard'] and sys.platform.startswith("win32"): # self._programLayout = GetKeyboardLayout(0) # if self._ownLayout == None: # self._ownLayout = self._programLayout # ActivateKeyboardLayout(self._ownLayout, 0)