Edit Book: Add support for spell checking int he code view. Now spelling errors are highlighted in the code view for convenient correction as you type. This can be turned off via Preferences->Editor.

This commit is contained in:
Kovid Goyal 2014-05-13 21:23:22 +05:30
parent d723bbc253
commit 5b24a497cd
12 changed files with 223 additions and 16 deletions

View File

@ -157,12 +157,16 @@ def group_sort(locations):
order[loc.file_name] = len(order)
return sorted(locations, key=lambda l:(order[l.file_name], l.sourceline))
def get_all_words(container, book_locale):
words = defaultdict(list)
def get_checkable_file_names(container):
file_names = [name for name, linear in container.spine_names] + [container.opf_name]
toc = find_existing_toc(container)
if toc is not None and container.exists(toc):
file_names.append(toc)
return file_names, toc
def get_all_words(container, book_locale):
words = defaultdict(list)
file_names, toc = get_checkable_file_names(container)
for file_name in file_names:
if not container.exists(file_name):
continue

View File

@ -46,6 +46,7 @@ d['disable_completion_popup_for_search'] = False
d['saved_searches'] = []
d['insert_tag_mru'] = ['p', 'div', 'li', 'h1', 'h2', 'h3', 'h4', 'em', 'strong', 'td', 'tr']
d['spell_check_case_sensitive_sort'] = False
d['inline_spell_check'] = True
del d

View File

@ -39,7 +39,7 @@ from calibre.gui2.tweak_book.editor import editor_from_syntax, syntax_from_mime
from calibre.gui2.tweak_book.editor.insert_resource import get_resource_data, NewBook
from calibre.gui2.tweak_book.preferences import Preferences
from calibre.gui2.tweak_book.search import validate_search_request, run_search
from calibre.gui2.tweak_book.spell import find_next as find_next_word
from calibre.gui2.tweak_book.spell import find_next as find_next_word, find_next_error
from calibre.gui2.tweak_book.widgets import (
RationalizeFolders, MultiSplit, ImportForeign, QuickOpen, InsertLink,
InsertSemantics, BusyCursor, InsertTag, FilterCSS)
@ -116,10 +116,12 @@ class Boss(QObject):
self.gui.spell_check.find_word.connect(self.find_word)
self.gui.spell_check.refresh_requested.connect(self.commit_all_editors_to_container)
self.gui.spell_check.word_replaced.connect(self.word_replaced)
self.gui.spell_check.word_ignored.connect(self.word_ignored)
def preferences(self):
p = Preferences(self.gui)
ret = p.exec_()
orig_spell = tprefs['inline_spell_check']
if p.dictionaries_changed:
dictionaries.clear_caches()
dictionaries.initialize(force=True) # Reread user dictionaries
@ -129,6 +131,12 @@ class Boss(QObject):
if ret == p.Accepted or p.dictionaries_changed:
for ed in editors.itervalues():
ed.apply_settings(dictionaries_changed=p.dictionaries_changed)
if orig_spell != tprefs['inline_spell_check']:
for ed in editors.itervalues():
try:
ed.editor.highlighter.rehighlight()
except AttributeError:
pass
def mark_requested(self, name, action):
self.commit_dirty_opf()
@ -740,10 +748,28 @@ class Boss(QObject):
break
find_next_word(word, locations, ed, name, self.gui, self.show_editor, self.edit_file)
def next_spell_error(self):
' Go to the next spelling error '
ed = self.gui.central.current_editor
name = None
for n, x in editors.iteritems():
if x is ed:
name = n
break
find_next_error(ed, name, self.gui, self.show_editor, self.edit_file)
def word_replaced(self, changed_names):
self.set_modified()
self.update_editors_from_container(names=set(changed_names))
def word_ignored(self, word, locale):
if tprefs['inline_spell_check']:
for ed in editors.itervalues():
try:
ed.editor.recheck_word(word, locale)
except AttributeError:
pass
def saved_searches(self):
self.gui.saved_searches.show(), self.gui.saved_searches.raise_()
@ -1042,6 +1068,8 @@ class Boss(QObject):
editor.copy_available_state_changed.connect(self.editor_copy_available_state_changed)
editor.cursor_position_changed.connect(self.sync_preview_to_editor)
editor.cursor_position_changed.connect(self.update_cursor_position)
if hasattr(editor, 'word_ignored'):
editor.word_ignored.connect(self.word_ignored)
if data is not None:
if use_template:
editor.init_from_template(data)

View File

@ -34,6 +34,7 @@ def editor_from_syntax(syntax, parent=None):
SYNTAX_PROPERTY = QTextCharFormat.UserProperty
SPELL_PROPERTY = SYNTAX_PROPERTY + 1
class SyntaxTextCharFormat(QTextCharFormat):

View File

@ -151,6 +151,10 @@ class SyntaxHighlighter(object):
finally:
doc.contentsChange.connect(self.reformat_blocks)
def reformat_block(self, block):
if block.isValid():
self.reformat_blocks(block.position(), 0, 1)
def apply_format_changes(self, doc, block, formats):
layout = block.layout()
preedit_start = layout.preeditAreaPosition()

View File

@ -14,7 +14,9 @@ from PyQt4.Qt import QFont, QTextBlockUserData
from calibre.ebooks.oeb.polish.spell import html_spell_tags, xml_spell_tags
from calibre.spell.dictionary import parse_lang_code
from calibre.gui2.tweak_book.editor import SyntaxTextCharFormat
from calibre.spell.break_iterator import split_into_words_and_positions
from calibre.gui2.tweak_book import dictionaries, tprefs
from calibre.gui2.tweak_book.editor import SyntaxTextCharFormat, SPELL_PROPERTY
from calibre.gui2.tweak_book.editor.syntax.base import SyntaxHighlighter, run_loop
from calibre.gui2.tweak_book.editor.syntax.css import (
create_formats as create_css_formats, state_map as css_state_map, CSSState, CSSUserData)
@ -170,6 +172,16 @@ class HTMLUserData(QTextBlockUserData):
self.tags, self.attributes = [], []
self.state = State() if state is None else state
@classmethod
def tag_ok_for_spell(cls, name):
return name not in html_spell_tags
class XMLUserData(HTMLUserData):
@classmethod
def tag_ok_for_spell(cls, name):
return name in xml_spell_tags
def add_tag_data(user_data, tag):
user_data.tags.append(tag)
@ -211,7 +223,7 @@ def cdata(state, text, i, formats, user_data):
add_tag_data(user_data, TagStart(m.start(), '', name, True, True))
return [(num, fmt), (2, formats['end_tag']), (len(m.group()) - 2, formats['tag_name'])]
def mark_nbsp(state, text, nbsp_format):
def process_text(state, text, nbsp_format, spell_format, user_data):
ans = []
fmt = None
if state.is_bold or state.is_italic:
@ -226,6 +238,36 @@ def mark_nbsp(state, text, nbsp_format):
last = m.end()
if not ans:
ans = [(len(text), fmt)]
if tprefs['inline_spell_check'] and state.tags and user_data.tag_ok_for_spell(state.tags[-1].name):
split_ans = []
locale = state.current_lang or dictionaries.default_locale
sfmt = SyntaxTextCharFormat(spell_format)
if fmt is not None:
sfmt.merge(fmt)
tpos = 0
for tlen, fmt in ans:
if fmt is nbsp_format:
split_ans.append((tlen, fmt))
else:
ctext = text[tpos:tpos+tlen]
ppos = 0
for start, length in split_into_words_and_positions(ctext, lang=locale.langcode):
if start > ppos:
split_ans.append((start - ppos, fmt))
ppos = start + length
recognized = dictionaries.recognized(ctext[start:ppos], locale)
if not recognized:
wsfmt = SyntaxTextCharFormat(sfmt)
wsfmt.setProperty(SPELL_PROPERTY, (ctext[start:ppos], locale))
split_ans.append((length, fmt if recognized else wsfmt))
if ppos == 0:
split_ans.append((tlen, fmt))
tpos += tlen
ans = split_ans
return ans
def normal(state, text, i, formats, user_data):
@ -277,7 +319,7 @@ def normal(state, text, i, formats, user_data):
return [(1, formats['>'])]
t = normal_pat.search(text, i).group()
return mark_nbsp(state, t, formats['nbsp'])
return process_text(state, t, formats['nbsp'], formats['spell'], user_data)
def opening_tag(cdata_tags, state, text, i, formats, user_data):
'An opening tag, like <a>'
@ -417,6 +459,7 @@ def create_formats(highlighter, add_css=True):
'nsprefix': t['Constant'],
'preproc': t['PreProc'],
'nbsp': t['SpecialCharacter'],
'spell': t['SpellError'],
}
for name, msg in {
'<': _('An unescaped < is not allowed. Replace it with &lt;'),
@ -445,18 +488,19 @@ class HTMLHighlighter(SyntaxHighlighter):
user_data_factory = HTMLUserData
def tag_ok_for_spell(self, name):
return name not in html_spell_tags
return HTMLUserData.tag_ok_for_spell(name)
class XMLHighlighter(HTMLHighlighter):
state_map = xml_state_map
spell_attributes = ('opf:file-as',)
user_data_factory = XMLUserData
def create_formats_func(self):
return create_formats(self, add_css=False)
def tag_ok_for_spell(self, name):
return name in xml_spell_tags
return XMLUserData.tag_ok_for_spell(name)
if __name__ == '__main__':
from calibre.gui2.tweak_book.editor.widget import launch_editor

View File

@ -18,7 +18,7 @@ from PyQt4.Qt import (
from calibre import prepare_string_for_xml, xml_entity_to_unicode
from calibre.gui2.tweak_book import tprefs, TOP
from calibre.gui2.tweak_book.editor import SYNTAX_PROPERTY
from calibre.gui2.tweak_book.editor import SYNTAX_PROPERTY, SPELL_PROPERTY
from calibre.gui2.tweak_book.editor.themes import THEMES, default_theme, theme_color, theme_format
from calibre.gui2.tweak_book.editor.syntax.base import SyntaxHighlighter
from calibre.gui2.tweak_book.editor.syntax.html import HTMLHighlighter, XMLHighlighter
@ -231,6 +231,11 @@ class TextEdit(PlainTextEdit):
self.setTextCursor(c)
self.ensureCursorVisible()
def simple_replace(self, text):
c = self.textCursor()
c.insertText(unicodedata.normalize('NFC', text))
self.setTextCursor(c)
def go_to_line(self, lnum, col=None):
lnum = max(1, min(self.blockCount(), lnum))
c = self.textCursor()
@ -383,7 +388,7 @@ class TextEdit(PlainTextEdit):
self.saved_matches[save_match] = (pat, m)
return True
def find_spell_word(self, original_words, lang, from_cursor=True):
def find_spell_word(self, original_words, lang, from_cursor=True, center_on_cursor=True):
c = self.textCursor()
c.setPosition(c.position())
if not from_cursor:
@ -407,13 +412,30 @@ class TextEdit(PlainTextEdit):
c.setPosition(c.position() + string_length(word), c.KeepAnchor)
if self.smarts.verify_for_spellcheck(c, self.highlighter):
self.setTextCursor(c)
self.centerCursor()
if center_on_cursor:
self.centerCursor()
return True
c.setPosition(c.position())
c.movePosition(c.End, c.KeepAnchor)
return False
def find_next_spell_error(self, from_cursor=True):
c = self.textCursor()
if not from_cursor:
c.movePosition(c.Start)
block = c.block()
while block.isValid():
for r in block.layout().additionalFormats():
if r.format.property(SPELL_PROPERTY).toPyObject() is not None:
if not from_cursor or block.position() + r.start + r.length > c.position():
c.setPosition(block.position() + r.start)
c.setPosition(c.position() + r.length, c.KeepAnchor)
self.setTextCursor(c)
return True
block = block.next()
return False
def replace(self, pat, template, saved_match='gui'):
c = self.textCursor()
raw = unicode(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0')
@ -546,6 +568,18 @@ class TextEdit(PlainTextEdit):
return False
return QPlainTextEdit.event(self, ev)
def recheck_word(self, word, locale):
c = self.textCursor()
c.movePosition(c.Start)
block = c.block()
while block.isValid():
for r in block.layout().additionalFormats():
x = r.format.property(SPELL_PROPERTY).toPyObject()
if x is not None and word == x[0]:
self.highlighter.reformat_block(block)
break
block = block.next()
# Tooltips {{{
def syntax_format_for_cursor(self, cursor):
if cursor.isNull():

View File

@ -60,7 +60,7 @@ SOLARIZED = \
SpecialCharacter bg={base02}
Error us=wave uc={red}
SpellError us=wave uc={orange}
SpellError us=spell uc={orange}
Tooltip fg=black bg=ffffed
DiffDelete bg={base02} fg={red}
@ -101,7 +101,7 @@ THEMES = {
Keyword fg={keyword}
Special fg=e7f6da
Error us=wave uc=red
SpellError us=wave uc=orange
SpellError us=spell uc=orange
SpecialCharacter bg={cursor_loc}
DiffDelete bg=341414 fg=642424
@ -148,7 +148,7 @@ THEMES = {
Special fg=70a0d0 italic
SpecialCharacter bg={cursor_loc}
Error us=wave uc=red
SpellError us=wave uc=orange
SpellError us=spell uc=magenta
DiffDelete bg=rgb(255,180,200) fg=rgb(200,80,110)
DiffInsert bg=rgb(180,255,180) fg=rgb(80,210,80)

View File

@ -14,8 +14,10 @@ from PyQt4.Qt import (
QImage, QColor, QIcon, QPixmap, QToolButton)
from calibre.gui2 import error_dialog
from calibre.gui2.tweak_book import actions, current_container, tprefs
from calibre.gui2.tweak_book import actions, current_container, tprefs, dictionaries
from calibre.gui2.tweak_book.editor import SPELL_PROPERTY
from calibre.gui2.tweak_book.editor.text import TextEdit
from calibre.utils.icu import utf16_length
def create_icon(text, palette=None, sz=32, divider=2):
if palette is None:
@ -79,6 +81,7 @@ class Editor(QMainWindow):
copy_available_state_changed = pyqtSignal(object)
data_changed = pyqtSignal(object)
cursor_position_changed = pyqtSignal()
word_ignored = pyqtSignal(object, object)
def __init__(self, syntax, parent=None):
QMainWindow.__init__(self, parent)
@ -258,6 +261,10 @@ class Editor(QMainWindow):
self.modification_state_changed.disconnect()
except TypeError:
pass # in case this signal was never connected
try:
self.word_ignored.disconnect()
except TypeError:
pass # in case this signal was never connected
self.undo_redo_state_changed.disconnect()
self.copy_available_state_changed.disconnect()
self.cursor_position_changed.disconnect()
@ -344,6 +351,43 @@ class Editor(QMainWindow):
def show_context_menu(self, pos):
m = QMenu(self)
a = m.addAction
c = self.editor.cursorForPosition(pos)
fmt = self.editor.syntax_format_for_cursor(c)
spell = fmt.property(SPELL_PROPERTY).toPyObject() if fmt is not None else None
if spell is not None:
word, locale = spell
orig_pos = c.position()
c.setPosition(orig_pos - utf16_length(word))
found = False
self.editor.setTextCursor(c)
if self.editor.find_spell_word([word], locale.langcode, center_on_cursor=False):
found = True
fc = self.editor.textCursor()
if fc.position() < c.position():
self.editor.find_spell_word([word], locale.langcode, center_on_cursor=False)
if found:
suggestions = dictionaries.suggestions(word, locale)[:7]
if suggestions:
for suggestion in suggestions:
ac = m.addAction(suggestion, partial(self.editor.simple_replace, suggestion))
f = ac.font()
f.setBold(True), ac.setFont(f)
m.addSeparator()
m.addAction(actions['spell-next'])
m.addAction(_('Ignore this word'), partial(self._nuke_word, None, word, locale))
dics = dictionaries.active_user_dictionaries
if len(dics) > 0:
if len(dics) == 1:
m.addAction(_('Add this word to the dictionary: {0}').format(dics[0].name), partial(
self._nuke_word, dics[0].name, word, locale))
else:
ac = m.addAction(_('Add this word to the dictionary'))
dmenu = QMenu(m)
ac.setMenu(dmenu)
for dic in dics:
dmenu.addAction(dic.name, partial(self._nuke_word, dic.name, word, locale))
m.addSeparator()
for x in ('undo', 'redo'):
a(actions['editor-%s' % x])
m.addSeparator()
@ -356,6 +400,12 @@ class Editor(QMainWindow):
m.addAction(actions['multisplit'])
m.exec_(self.editor.mapToGlobal(pos))
def _nuke_word(self, dic, word, locale):
if dic is None:
dictionaries.ignore_word(word, locale)
else:
dictionaries.add_to_user_dictionary(dic, word, locale)
self.word_ignored.emit(word, locale)
def launch_editor(path_to_edit, path_is_raw=False, syntax='html'):
from calibre.gui2.tweak_book.main import option_parser

View File

@ -194,6 +194,13 @@ class EditorSettings(BasicSettings):
' time you open a HTML/CSS/etc. file for editing.'))
l.addRow(lw)
lw = self('inline_spell_check')
lw.setText(_('Show misspelled words underlined in the code view'))
lw.setToolTip('<p>' + _(
'This will cause spelling errors to be highlighted in the code view'
' for easy correction as you type.'))
l.addRow(lw)
self.dictionaries = d = QPushButton(_('Manage &spelling dictionaries'), self)
d.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
d.clicked.connect(self.manage_dictionaries)

View File

@ -19,7 +19,7 @@ from PyQt4.Qt import (
QComboBox, QListWidget, QListWidgetItem, QInputDialog, QPlainTextEdit, QKeySequence)
from calibre.constants import __appname__, plugins
from calibre.ebooks.oeb.polish.spell import replace_word, get_all_words, merge_locations
from calibre.ebooks.oeb.polish.spell import replace_word, get_all_words, merge_locations, get_checkable_file_names
from calibre.gui2 import choose_files, error_dialog
from calibre.gui2.complete2 import LineEdit
from calibre.gui2.languages import LanguagesEdit
@ -585,6 +585,8 @@ class ManageDictionaries(Dialog): # {{{
# Spell Check Dialog {{{
class WordsModel(QAbstractTableModel):
word_ignored = pyqtSignal(object, object)
def __init__(self, parent=None):
QAbstractTableModel.__init__(self, parent)
self.counts = (0, 0)
@ -705,6 +707,7 @@ class WordsModel(QAbstractTableModel):
(dictionaries.unignore_word if ignored else dictionaries.ignore_word)(*w)
self.spell_map[w] = dictionaries.recognized(*w)
self.update_word(w)
self.word_ignored.emit(*w)
def ignore_words(self, rows):
words = {self.word_for_row(r) for r in rows}
@ -714,6 +717,7 @@ class WordsModel(QAbstractTableModel):
(dictionaries.unignore_word if ignored else dictionaries.ignore_word)(*w)
self.spell_map[w] = dictionaries.recognized(*w)
self.update_word(w)
self.word_ignored.emit(*w)
def add_word(self, row, udname):
w = self.word_for_row(row)
@ -721,6 +725,7 @@ class WordsModel(QAbstractTableModel):
if dictionaries.add_to_user_dictionary(udname, *w):
self.spell_map[w] = dictionaries.recognized(*w)
self.update_word(w)
self.word_ignored.emit(*w)
def add_words(self, dicname, rows):
words = {self.word_for_row(r) for r in rows}
@ -730,6 +735,7 @@ class WordsModel(QAbstractTableModel):
dictionaries.remove_from_user_dictionary(dicname, [w])
self.spell_map[w] = dictionaries.recognized(*w)
self.update_word(w)
self.word_ignored.emit(*w)
def remove_word(self, row):
w = self.word_for_row(row)
@ -855,6 +861,7 @@ class SpellCheck(Dialog):
find_word = pyqtSignal(object, object)
refresh_requested = pyqtSignal()
word_replaced = pyqtSignal(object)
word_ignored = pyqtSignal(object, object)
def __init__(self, parent=None):
self.__current_word = None
@ -922,6 +929,7 @@ class SpellCheck(Dialog):
w.setModel(m)
m.dataChanged.connect(self.current_word_changed)
m.modelReset.connect(self.current_word_changed)
m.word_ignored.connect(self.word_ignored)
if state is not None:
hh.restoreState(state)
# Sort by the restored state, if any
@ -1249,6 +1257,30 @@ def find_next(word, locations, current_editor, current_editor_name,
show_editor(file_name)
return True
return False
def find_next_error(current_editor, current_editor_name, gui_parent, show_editor, edit_file):
files = get_checkable_file_names(current_container())[0]
if current_editor_name not in files:
current_editor_name = None
else:
idx = files.index(current_editor_name)
before, after = files[:idx], files[idx+1:]
files = [current_editor_name] + after + before + [current_editor_name]
for file_name in files:
from_cursor = False
if file_name == current_editor_name:
from_cursor = True
current_editor_name = None
ed = editors.get(file_name, None)
if ed is None:
edit_file(file_name)
ed = editors[file_name]
if ed.editor.find_next_spell_error(from_cursor=from_cursor):
show_editor(file_name)
return True
return False
# }}}
if __name__ == '__main__':

View File

@ -410,6 +410,8 @@ class Main(MainWindow):
self.check_book.next_error, delta=1), 'check-book-next', ('Ctrl+F7'), _('Show next error'))
self.action_check_book_previous = reg('back.png', _('&Previous error'), partial(
self.check_book.next_error, delta=-1), 'check-book-previous', ('Ctrl+Shift+F7'), _('Show previous error'))
self.action_spell_check_next = reg('forward.png', _('&Next spelling mistake'),
self.boss.next_spell_error, 'spell-next', ('F8'), _('Go to next spelling mistake'))
# Miscellaneous actions
group = _('Miscellaneous')