mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-06-23 15:30:45 -04:00
Edit Book: Highlight the closest surrounding tag when editing HTML/XML
This commit is contained in:
parent
e1660d26b9
commit
a2c49bbca0
16
src/calibre/gui2/tweak_book/editor/smart/__init__.py
Normal file
16
src/calibre/gui2/tweak_book/editor/smart/__init__.py
Normal 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 ()
|
||||
|
128
src/calibre/gui2/tweak_book/editor/smart/html.py
Normal file
128
src/calibre/gui2/tweak_book/editor/smart/html.py
Normal 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
|
||||
|
@ -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):
|
||||
|
@ -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 {{{
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user