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.parse = self.bold = self.italic = self.css = 0
|
||||||
self.tag = self.UNKNOWN_TAG
|
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):
|
def css(state, text, i, formats):
|
||||||
' Inside a <style> tag '
|
' Inside a <style> tag '
|
||||||
pat = cdata_close_pats['style']
|
pat = cdata_close_pats['style']
|
||||||
@ -93,6 +104,7 @@ def css(state, text, i, formats):
|
|||||||
if m is not None:
|
if m is not None:
|
||||||
state.clear()
|
state.clear()
|
||||||
state.parse = State.IN_CLOSING_TAG
|
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'])])
|
ans.extend([(2, formats['end_tag']), (len(m.group()) - 2, formats['tag_name'])])
|
||||||
return ans
|
return ans
|
||||||
|
|
||||||
@ -105,6 +117,7 @@ def cdata(state, text, i, formats):
|
|||||||
return [(len(text) - i, fmt)]
|
return [(len(text) - i, fmt)]
|
||||||
state.parse = State.IN_CLOSING_TAG
|
state.parse = State.IN_CLOSING_TAG
|
||||||
num = m.start() - i
|
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'])]
|
return [(num, fmt), (2, formats['end_tag']), (len(m.group()) - 2, formats['tag_name'])]
|
||||||
|
|
||||||
def mark_nbsp(state, text, nbsp_format):
|
def mark_nbsp(state, text, nbsp_format):
|
||||||
@ -123,23 +136,12 @@ def mark_nbsp(state, text, nbsp_format):
|
|||||||
ans = [(len(text), fmt)]
|
ans = [(len(text), fmt)]
|
||||||
return ans
|
return ans
|
||||||
|
|
||||||
TagStart = namedtuple('TagStart', 'prefix name closing offset')
|
|
||||||
TagEnd = namedtuple('TagEnd', 'self_closing offset')
|
|
||||||
|
|
||||||
class HTMLUserData(QTextBlockUserData):
|
class HTMLUserData(QTextBlockUserData):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
QTextBlockUserData.__init__(self)
|
QTextBlockUserData.__init__(self)
|
||||||
self.tags = []
|
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):
|
def normal(state, text, i, formats):
|
||||||
' The normal state in between tags '
|
' The normal state in between tags '
|
||||||
ch = text[i]
|
ch = text[i]
|
||||||
@ -171,7 +173,7 @@ def normal(state, text, i, formats):
|
|||||||
if prefix and name:
|
if prefix and name:
|
||||||
ans.append((len(prefix)+1, formats['nsprefix']))
|
ans.append((len(prefix)+1, formats['nsprefix']))
|
||||||
ans.append((len(name or prefix), formats['tag_name']))
|
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
|
return ans
|
||||||
|
|
||||||
if ch == '&':
|
if ch == '&':
|
||||||
@ -197,8 +199,9 @@ def opening_tag(cdata_tags, state, text, i, formats):
|
|||||||
return [(1, formats['/'])]
|
return [(1, formats['/'])]
|
||||||
state.parse = state.NORMAL
|
state.parse = state.NORMAL
|
||||||
state.tag = State.UNKNOWN_TAG
|
state.tag = State.UNKNOWN_TAG
|
||||||
add_tag_data(state, TagEnd(True, i))
|
l = len(m.group())
|
||||||
return [(len(m.group()), formats['tag'])]
|
add_tag_data(state, TagEnd(i + l - 1, True, False))
|
||||||
|
return [(l, formats['tag'])]
|
||||||
if ch == '>':
|
if ch == '>':
|
||||||
state.parse = state.NORMAL
|
state.parse = state.NORMAL
|
||||||
tag = state.tag.lower()
|
tag = state.tag.lower()
|
||||||
@ -209,7 +212,7 @@ def opening_tag(cdata_tags, state, text, i, formats):
|
|||||||
state.parse = state.CSS
|
state.parse = state.CSS
|
||||||
state.bold += int(tag in bold_tags)
|
state.bold += int(tag in bold_tags)
|
||||||
state.italic += int(tag in italic_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'])]
|
return [(1, formats['tag'])]
|
||||||
m = attribute_name_pat.match(text, i)
|
m = attribute_name_pat.match(text, i)
|
||||||
if m is None:
|
if m is None:
|
||||||
@ -276,7 +279,7 @@ def closing_tag(state, text, i, formats):
|
|||||||
if num > 1:
|
if num > 1:
|
||||||
ans.insert(0, (num - 1, formats['bad-closing']))
|
ans.insert(0, (num - 1, formats['bad-closing']))
|
||||||
state.tag = State.UNKNOWN_TAG
|
state.tag = State.UNKNOWN_TAG
|
||||||
add_tag_data(state, TagEnd(False, pos))
|
add_tag_data(state, TagEnd(pos, False, False))
|
||||||
return ans
|
return ans
|
||||||
|
|
||||||
def in_comment(state, text, i, formats):
|
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 import prepare_string_for_xml, xml_entity_to_unicode
|
||||||
from calibre.gui2.tweak_book import tprefs, TOP
|
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
|
||||||
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.base import SyntaxHighlighter
|
||||||
from calibre.gui2.tweak_book.editor.syntax.html import HTMLHighlighter, XMLHighlighter
|
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.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'
|
PARAGRAPH_SEPARATOR = '\u2029'
|
||||||
entity_pat = re.compile(r'&(#{0,1}[a-zA-Z0-9]{1,8});')
|
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):
|
def __init__(self, parent=None):
|
||||||
PlainTextEdit.__init__(self, parent)
|
PlainTextEdit.__init__(self, parent)
|
||||||
|
self.smarts = NullSmarts(self)
|
||||||
self.current_cursor_line = None
|
self.current_cursor_line = None
|
||||||
self.current_search_mark = None
|
self.current_search_mark = None
|
||||||
self.highlighter = SyntaxHighlighter(self)
|
self.highlighter = SyntaxHighlighter(self)
|
||||||
@ -164,6 +167,7 @@ class TextEdit(PlainTextEdit):
|
|||||||
pal.setColor(pal.Base, theme_color(theme, 'LineNr', 'bg'))
|
pal.setColor(pal.Base, theme_color(theme, 'LineNr', 'bg'))
|
||||||
pal.setColor(pal.Text, theme_color(theme, 'LineNr', 'fg'))
|
pal.setColor(pal.Text, theme_color(theme, 'LineNr', 'fg'))
|
||||||
pal.setColor(pal.BrightText, theme_color(theme, 'LineNrC', 'fg'))
|
pal.setColor(pal.BrightText, theme_color(theme, 'LineNrC', 'fg'))
|
||||||
|
self.match_paren_format = theme_format(theme, 'MatchParen')
|
||||||
font = self.font()
|
font = self.font()
|
||||||
ff = tprefs['editor_font_family']
|
ff = tprefs['editor_font_family']
|
||||||
if ff is None:
|
if ff is None:
|
||||||
@ -186,6 +190,9 @@ class TextEdit(PlainTextEdit):
|
|||||||
self.highlighter = get_highlighter(syntax)(self)
|
self.highlighter = get_highlighter(syntax)(self)
|
||||||
self.highlighter.apply_theme(self.theme)
|
self.highlighter.apply_theme(self.theme)
|
||||||
self.highlighter.setDocument(self.document())
|
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))
|
self.setPlainText(unicodedata.normalize('NFC', text))
|
||||||
if process_template and QPlainTextEdit.find(self, '%CURSOR%'):
|
if process_template and QPlainTextEdit.find(self, '%CURSOR%'):
|
||||||
c = self.textCursor()
|
c = self.textCursor()
|
||||||
@ -232,6 +239,7 @@ class TextEdit(PlainTextEdit):
|
|||||||
sel.append(self.current_cursor_line)
|
sel.append(self.current_cursor_line)
|
||||||
if self.current_search_mark is not None:
|
if self.current_search_mark is not None:
|
||||||
sel.append(self.current_search_mark)
|
sel.append(self.current_search_mark)
|
||||||
|
sel.extend(self.smarts.get_extra_selections(self))
|
||||||
self.setExtraSelections(sel)
|
self.setExtraSelections(sel)
|
||||||
|
|
||||||
# Search and replace {{{
|
# Search and replace {{{
|
||||||
|
@ -35,7 +35,7 @@ SOLARIZED = \
|
|||||||
CursorColumn bg={base02}
|
CursorColumn bg={base02}
|
||||||
ColorColumn bg={base02}
|
ColorColumn bg={base02}
|
||||||
HighlightRegion bg={base00}
|
HighlightRegion bg={base00}
|
||||||
MatchParen fg={red} bg={base01} bold
|
MatchParen bg={base02} fg={magenta}
|
||||||
Pmenu fg={base0} bg={base02}
|
Pmenu fg={base0} bg={base02}
|
||||||
PmenuSel fg={base01} bg={base2}
|
PmenuSel fg={base01} bg={base2}
|
||||||
|
|
||||||
@ -76,7 +76,7 @@ THEMES = {
|
|||||||
CursorColumn bg={cursor_loc}
|
CursorColumn bg={cursor_loc}
|
||||||
ColorColumn bg={cursor_loc}
|
ColorColumn bg={cursor_loc}
|
||||||
HighlightRegion bg=3d3d3d
|
HighlightRegion bg=3d3d3d
|
||||||
MatchParen fg=f6f3e8 bg=857b6f bold
|
MatchParen bg=444444
|
||||||
Pmenu fg=f6f3e8 bg=444444
|
Pmenu fg=f6f3e8 bg=444444
|
||||||
PmenuSel fg=yellow bg={identifier}
|
PmenuSel fg=yellow bg={identifier}
|
||||||
Tooltip fg=black bg=ffffed
|
Tooltip fg=black bg=ffffed
|
||||||
@ -121,7 +121,7 @@ THEMES = {
|
|||||||
CursorColumn bg={cursor_loc}
|
CursorColumn bg={cursor_loc}
|
||||||
ColorColumn bg={cursor_loc}
|
ColorColumn bg={cursor_loc}
|
||||||
HighlightRegion bg=E3F988
|
HighlightRegion bg=E3F988
|
||||||
MatchParen fg=white bg=80a090 bold
|
MatchParen bg=cfcfcf
|
||||||
Pmenu fg=white bg=808080
|
Pmenu fg=white bg=808080
|
||||||
PmenuSel fg=white bg=808080
|
PmenuSel fg=white bg=808080
|
||||||
Tooltip fg=black bg=ffffed
|
Tooltip fg=black bg=ffffed
|
||||||
@ -245,3 +245,9 @@ def theme_color(theme, name, attr):
|
|||||||
except (KeyError, AttributeError):
|
except (KeyError, AttributeError):
|
||||||
return getattr(THEMES[default_theme()][name], attr).color()
|
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.copyAvailable.disconnect()
|
||||||
self.editor.cursorPositionChanged.disconnect()
|
self.editor.cursorPositionChanged.disconnect()
|
||||||
self.editor.setPlainText('')
|
self.editor.setPlainText('')
|
||||||
|
self.editor.smarts = None
|
||||||
|
|
||||||
def _modification_state_changed(self):
|
def _modification_state_changed(self):
|
||||||
self.is_synced_to_container = self.is_modified
|
self.is_synced_to_container = self.is_modified
|
||||||
|
Loading…
x
Reference in New Issue
Block a user