Edit Book: Highlight the closest surrounding tag when editing HTML/XML

This commit is contained in:
Kovid Goyal 2014-02-03 22:07:47 +05:30
parent e1660d26b9
commit a2c49bbca0
6 changed files with 182 additions and 20 deletions

View File

@ -0,0 +1,16 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
class NullSmarts(object):
def __init__(self, editor):
pass
def get_extra_selections(self, editor):
return ()

View File

@ -0,0 +1,128 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
import sys
from operator import itemgetter
from . import NullSmarts
from PyQt4.Qt import QTextEdit
get_offset = itemgetter(0)
class Tag(object):
def __init__(self, start_block, tag_start, end_block, tag_end, self_closing=False):
self.start_block, self.end_block = start_block, end_block
self.start_offset, self.end_offset = tag_start.offset, tag_end.offset
tag = tag_start.name or tag_start.prefix
if tag_start.name and tag_start.prefix:
tag = tag_start.prefix + ':' + tag
self.name = tag
self.self_closing = self_closing
def next_tag_boundary(block, offset, forward=True):
while block.isValid():
ud = block.userData()
if ud is not None:
tags = sorted(ud.tags, key=get_offset, reverse=not forward)
for boundary in tags:
if forward and boundary.offset > offset:
return block, boundary
if not forward and boundary.offset < offset:
return block, boundary
block = block.next() if forward else block.previous()
offset = -1 if forward else sys.maxint
return None, None
def find_closest_containing_tag(block, offset, max_tags=2000):
''' Find the closest containing tag. To find it, we search for the first
opening tag that does not have a matching closing tag before the specified
position. Search through at most max_tags. '''
prev_tag_boundary = lambda b, o: next_tag_boundary(b, o, forward=False)
block, boundary = prev_tag_boundary(block, offset)
if block is None:
return None
if boundary.is_start:
# We are inside a tag, therefore the containing tag is the parent tag of
# this tag
return find_closest_containing_tag(block, boundary.offset)
stack = []
block, tag_end = block, boundary
while block is not None and max_tags > 0:
sblock, tag_start = prev_tag_boundary(block, tag_end.offset)
if sblock is None or not tag_start.is_start:
break
if tag_start.closing: # A closing tag of the form </a>
stack.append((tag_start.prefix, tag_start.name))
elif tag_end.self_closing: # A self closing tag of the form <a/>
pass # Ignore it
else: # An opening tag, hurray
try:
prefix, name = stack.pop()
except IndexError:
prefix = name = None
if (prefix, name) != (tag_start.prefix, tag_start.name):
# Either we have an unbalanced opening tag or a syntax error, in
# either case terminate
return Tag(sblock, tag_start, block, tag_end)
block, tag_end = prev_tag_boundary(sblock, tag_start.offset)
max_tags -= 1
return None # Could not find a containing tag
def find_closing_tag(tag, max_tags=4000):
''' Find the closing tag corresponding to the specified tag. To find it we
search for the first closing tag after the specified tag that does not
match a previous opening tag. Search through at most max_tags. '''
stack = []
block, offset = tag.end_block, tag.end_offset
while block.isValid() and max_tags > 0:
block, tag_start = next_tag_boundary(block, offset)
if block is None or not tag_start.is_start:
break
endblock, tag_end = next_tag_boundary(block, tag_start.offset)
if block is None or tag_end.is_start:
break
if tag_start.closing:
try:
prefix, name = stack.pop()
except IndexError:
prefix = name = None
if (prefix, name) != (tag_start.prefix, tag_start.name):
return Tag(block, tag_start, endblock, tag_end)
elif tag_end.self_closing:
pass
else:
stack.append((tag_start.prefix, tag_start.name))
block, offset = endblock, tag_end.offset
max_tags -= 1
return None
class HTMLSmarts(NullSmarts):
def get_extra_selections(self, editor):
ans = []
def add_tag(tag):
a = QTextEdit.ExtraSelection()
a.cursor, a.format = editor.textCursor(), editor.match_paren_format
a.cursor.setPosition(tag.start_block.position() + tag.start_offset)
a.cursor.setPosition(tag.end_block.position() + tag.end_offset + 1, a.cursor.KeepAnchor)
ans.append(a)
c = editor.textCursor()
block, offset = c.block(), c.positionInBlock()
tag = find_closest_containing_tag(block, offset)
if tag is not None:
add_tag(tag)
tag = find_closing_tag(tag)
if tag is not None:
add_tag(tag)
return ans

View File

@ -77,6 +77,17 @@ class State(object):
self.parse = self.bold = self.italic = self.css = 0
self.tag = self.UNKNOWN_TAG
TagStart = namedtuple('TagStart', 'offset prefix name closing is_start')
TagEnd = namedtuple('TagEnd', 'offset self_closing is_start')
def add_tag_data(state, tag):
ud = q = state.get_user_data()
if ud is None:
ud = HTMLUserData()
ud.tags.append(tag)
if q is None:
state.set_user_data(ud)
def css(state, text, i, formats):
' Inside a <style> tag '
pat = cdata_close_pats['style']
@ -93,6 +104,7 @@ def css(state, text, i, formats):
if m is not None:
state.clear()
state.parse = State.IN_CLOSING_TAG
add_tag_data(state, TagStart(m.start(), 'style', '', True, True))
ans.extend([(2, formats['end_tag']), (len(m.group()) - 2, formats['tag_name'])])
return ans
@ -105,6 +117,7 @@ def cdata(state, text, i, formats):
return [(len(text) - i, fmt)]
state.parse = State.IN_CLOSING_TAG
num = m.start() - i
add_tag_data(state, TagStart(m.start(), state.tag, '', True, True))
return [(num, fmt), (2, formats['end_tag']), (len(m.group()) - 2, formats['tag_name'])]
def mark_nbsp(state, text, nbsp_format):
@ -123,23 +136,12 @@ def mark_nbsp(state, text, nbsp_format):
ans = [(len(text), fmt)]
return ans
TagStart = namedtuple('TagStart', 'prefix name closing offset')
TagEnd = namedtuple('TagEnd', 'self_closing offset')
class HTMLUserData(QTextBlockUserData):
def __init__(self):
QTextBlockUserData.__init__(self)
self.tags = []
def add_tag_data(state, tag):
ud = q = state.get_user_data()
if ud is None:
ud = HTMLUserData()
ud.tags.append(tag)
if q is None:
state.set_user_data(ud)
def normal(state, text, i, formats):
' The normal state in between tags '
ch = text[i]
@ -171,7 +173,7 @@ def normal(state, text, i, formats):
if prefix and name:
ans.append((len(prefix)+1, formats['nsprefix']))
ans.append((len(name or prefix), formats['tag_name']))
add_tag_data(state, TagStart(prefix, name, closing, i))
add_tag_data(state, TagStart(i, prefix, name, closing, True))
return ans
if ch == '&':
@ -197,8 +199,9 @@ def opening_tag(cdata_tags, state, text, i, formats):
return [(1, formats['/'])]
state.parse = state.NORMAL
state.tag = State.UNKNOWN_TAG
add_tag_data(state, TagEnd(True, i))
return [(len(m.group()), formats['tag'])]
l = len(m.group())
add_tag_data(state, TagEnd(i + l - 1, True, False))
return [(l, formats['tag'])]
if ch == '>':
state.parse = state.NORMAL
tag = state.tag.lower()
@ -209,7 +212,7 @@ def opening_tag(cdata_tags, state, text, i, formats):
state.parse = state.CSS
state.bold += int(tag in bold_tags)
state.italic += int(tag in italic_tags)
add_tag_data(state, TagEnd(False, i))
add_tag_data(state, TagEnd(i, False, False))
return [(1, formats['tag'])]
m = attribute_name_pat.match(text, i)
if m is None:
@ -276,7 +279,7 @@ def closing_tag(state, text, i, formats):
if num > 1:
ans.insert(0, (num - 1, formats['bad-closing']))
state.tag = State.UNKNOWN_TAG
add_tag_data(state, TagEnd(False, pos))
add_tag_data(state, TagEnd(pos, False, False))
return ans
def in_comment(state, text, i, formats):

View File

@ -19,10 +19,12 @@ 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.themes import THEMES, default_theme, theme_color
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
from calibre.gui2.tweak_book.editor.syntax.css import CSSHighlighter
from calibre.gui2.tweak_book.editor.smart import NullSmarts
from calibre.gui2.tweak_book.editor.smart.html import HTMLSmarts
PARAGRAPH_SEPARATOR = '\u2029'
entity_pat = re.compile(r'&(#{0,1}[a-zA-Z0-9]{1,8});')
@ -113,6 +115,7 @@ class TextEdit(PlainTextEdit):
def __init__(self, parent=None):
PlainTextEdit.__init__(self, parent)
self.smarts = NullSmarts(self)
self.current_cursor_line = None
self.current_search_mark = None
self.highlighter = SyntaxHighlighter(self)
@ -164,6 +167,7 @@ class TextEdit(PlainTextEdit):
pal.setColor(pal.Base, theme_color(theme, 'LineNr', 'bg'))
pal.setColor(pal.Text, theme_color(theme, 'LineNr', 'fg'))
pal.setColor(pal.BrightText, theme_color(theme, 'LineNrC', 'fg'))
self.match_paren_format = theme_format(theme, 'MatchParen')
font = self.font()
ff = tprefs['editor_font_family']
if ff is None:
@ -186,6 +190,9 @@ class TextEdit(PlainTextEdit):
self.highlighter = get_highlighter(syntax)(self)
self.highlighter.apply_theme(self.theme)
self.highlighter.setDocument(self.document())
sclass = {'html':HTMLSmarts, 'xml':HTMLSmarts}.get(syntax, None)
if sclass is not None:
self.smarts = sclass(self)
self.setPlainText(unicodedata.normalize('NFC', text))
if process_template and QPlainTextEdit.find(self, '%CURSOR%'):
c = self.textCursor()
@ -232,6 +239,7 @@ class TextEdit(PlainTextEdit):
sel.append(self.current_cursor_line)
if self.current_search_mark is not None:
sel.append(self.current_search_mark)
sel.extend(self.smarts.get_extra_selections(self))
self.setExtraSelections(sel)
# Search and replace {{{

View File

@ -35,7 +35,7 @@ SOLARIZED = \
CursorColumn bg={base02}
ColorColumn bg={base02}
HighlightRegion bg={base00}
MatchParen fg={red} bg={base01} bold
MatchParen bg={base02} fg={magenta}
Pmenu fg={base0} bg={base02}
PmenuSel fg={base01} bg={base2}
@ -76,7 +76,7 @@ THEMES = {
CursorColumn bg={cursor_loc}
ColorColumn bg={cursor_loc}
HighlightRegion bg=3d3d3d
MatchParen fg=f6f3e8 bg=857b6f bold
MatchParen bg=444444
Pmenu fg=f6f3e8 bg=444444
PmenuSel fg=yellow bg={identifier}
Tooltip fg=black bg=ffffed
@ -121,7 +121,7 @@ THEMES = {
CursorColumn bg={cursor_loc}
ColorColumn bg={cursor_loc}
HighlightRegion bg=E3F988
MatchParen fg=white bg=80a090 bold
MatchParen bg=cfcfcf
Pmenu fg=white bg=808080
PmenuSel fg=white bg=808080
Tooltip fg=black bg=ffffed
@ -245,3 +245,9 @@ def theme_color(theme, name, attr):
except (KeyError, AttributeError):
return getattr(THEMES[default_theme()][name], attr).color()
def theme_format(theme, name):
try:
h = theme[name]
except KeyError:
h = THEMES[default_theme()][name]
return highlight_to_char_format(h)

View File

@ -190,6 +190,7 @@ class Editor(QMainWindow):
self.editor.copyAvailable.disconnect()
self.editor.cursorPositionChanged.disconnect()
self.editor.setPlainText('')
self.editor.smarts = None
def _modification_state_changed(self):
self.is_synced_to_container = self.is_modified