1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308
|
# This file is part of the Frescobaldi project, http://www.frescobaldi.org/
#
# Copyright (c) 2008 - 2014 by Wilbert Berendsen
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
# See http://www.gnu.org/licenses/ for more information.
"""
Generic Point and Click handling.
"""
import os
import collections
from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QTextCursor
import app
import scratchdir
import ly.lex.lilypond
import ly.document
import lydocument
class Links(object):
"""Stores point and click links grouped by filename."""
def __init__(self):
self._links = collections.defaultdict(lambda: collections.defaultdict(list))
self._docs = {}
def add_link(self, filename, line, column, destination):
"""Add a link.
filename, line and column, describe the position in the source file.
destination can be any object that describes where the link points to.
"""
self._links[filename][(line, column)].append(destination)
def finish(self):
"""Call this when you are done with adding links.
This method tries to bind() already loaded documents and starts
monitoring document open/close events.
You can also use the links as a context manager and then add links.
On exit, finish() is automatically called.
"""
for filename in self._links:
d = scratchdir.findDocument(filename)
if d:
self.bind(filename, d)
app.documentLoaded.connect(self.slotDocumentLoaded)
app.documentClosed.connect(self.slotDocumentClosed)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.finish()
def bind(self, filename, doc):
"""Binds the given filename to the given document.
When the document disappears, the binding is removed automatically.
While a document is bound, textedit links are stored as QTextCursors,
so they keep their position even if the user changes the document.
"""
if filename not in self._docs:
self._docs[filename] = BoundLinks(doc, self._links[filename])
def slotDocumentLoaded(self, doc):
"""Called when a new document is loaded, it maybe possible to bind to it."""
filename = doc.url().toLocalFile()
if filename in self._links:
self.bind(filename, doc)
def slotDocumentClosed(self, doc):
"""Called when a document is closed, removes the bound links."""
for filename, b in self._docs.items():
if b.document == doc:
break
else:
return
del self._docs[filename]
def cursor(self, filename, line, column, load=False):
"""Returns the destination of a link as a QTextCursor of the destination document.
If load (defaulting to False) is True, the document is loaded if it is not yet loaded.
Returns None if the document could not be loaded.
"""
bound = self._docs.get(filename)
if bound:
return bound.cursor(line, column)
elif load and os.path.isfile(filename):
# this also calls bind(), via app.documentLoaded
app.openUrl(QUrl.fromLocalFile(filename))
bound = self._docs.get(filename)
if bound:
return bound.cursor(line, column)
def boundLinks(self, doc):
"""Returns the Bound links object for the given text document."""
for b in self._docs.values():
if b.document == doc:
return b
class BoundLinks(object):
"""Stores links as QTextCursors for a document."""
def __init__(self, doc, links):
"""Creates QTextCursor instances for every link, keeps a reference to the document."""
self.document = doc
# make a sorted list of cursors with their [destination, ...] destinations list
self._cursor_dict = d = {} # mapping from (line, col) to QTextCursor
self._cursors = cursors = [] # sorted list of the cursors
self._destinations = destinations = [] # corresponding list of destinations
for pos, dest in sorted(links.items()):
line, column = pos
b = doc.findBlockByNumber(line - 1)
if b.isValid():
c = d[pos] = QTextCursor(doc)
c.setPosition(b.position() + column)
cursors.append(c)
destinations.append(dest)
def cursor(self, line, column):
"""Returns the QTextCursor for the give line/col."""
return self._cursor_dict.get((line, column))
def cursors(self):
"""Return the list of cursors, sorted on cursor position."""
return self._cursors
def destinations(self):
"""Return the list of destination lists.
Each destination corresponds with the cursor at the same index in
the cursors() list. Each destination is a list of destination items
that were originally added using Links.add_link, because many
point-and-click objects can point to the same place in the text
document.
"""
return self._destinations
def indices(self, cursor):
"""Return a Python slice object or None or False.
If a slice, it specifies the range of destinations (in the destinations() list)
that the given QTextCursor points to. The cursor must of course belong to our document.
If None or False, it means that there is no object in the cursors neighbourhood.
If False, it means that it is e.g. preferred to clear earlier highlighted objects.
This method performs quite a bit trickery: it also returns the destination when a cursor
points to the _ending_ point of a slur, beam or phrasing slur.
"""
cursors = self._cursors
def findlink(pos):
# binary search in list of cursors
lo, hi = 0, len(cursors)
while lo < hi:
mid = (lo + hi) // 2
if pos < cursors[mid].position():
hi = mid
else:
lo = mid + 1
return lo - 1
if cursor.hasSelection():
end = findlink(cursor.selectionEnd() - 1)
if end >= 0:
start = findlink(cursor.selectionStart())
if start < 0 or cursors[start].position() < cursor.selectionStart():
start += 1
if start <= end:
return slice(start, end+1)
return False
index = findlink(cursor.position())
if index < 0:
return # before all other links
cur2 = cursors[index]
if cur2.position() < cursor.position():
# is the cursor at an ending token like a slur end?
prevcol = -1
if cur2.block() == cursor.block():
prevcol = cur2.position() - cur2.block().position()
col = cursor.position() - cursor.block().position()
found = False
tokens = ly.document.Runner(lydocument.Document(cursor.document()))
tokens.move_to_block(cursor.block(), True)
for token in tokens.backward_line():
if token.pos <= prevcol:
break
elif token.pos <= col:
if isinstance(token, ly.lex.MatchEnd) and token.matchname in (
'slur', 'phrasingslur', 'beam'):
# YES! now go backwards to find the opening token
nest = 1
name = token.matchname
for token in tokens.backward():
if isinstance(token, ly.lex.MatchStart) and token.matchname == name:
nest -= 1
if nest == 0:
found = True
break
elif isinstance(token, ly.lex.MatchEnd) and token.matchname == name:
nest += 1
break
if found:
index = findlink(tokens.block.position() + token.pos)
if index < 0 or cursors[index].block() != tokens.block:
return
elif cur2.block() != cursor.block():
return False
# highlight it!
return slice(index, index+1)
def positions(cursor):
"""Return a list of QTextCursors describing the grob the cursor points at.
When the cursor point at e.g. a slur, the returned cursors describe both
ends of the slur.
The returned list may contain zero to two cursors.
"""
c = lydocument.cursor(cursor)
c.end = None
source = lydocument.Source(c, True)
for token in source.tokens:
break
else:
return []
cur = source.cursor(token, end=0)
cursors = [cur]
# some heuristic to find the relevant range(s) the linked grob represents
if isinstance(token, ly.lex.lilypond.Direction):
# a _, - or ^ is found; find the next token
for token in source:
if not isinstance(token, (ly.lex.Space, ly.lex.Comment)):
break
end = token.end + source.block.position()
if token == '\\markup':
# find the end of the markup expression
depth = source.state.depth()
for token in source:
if source.state.depth() < depth:
end = token.end + source.block.position()
break
elif token == '"':
if isinstance(token, ly.lex.StringEnd):
# a bug in LilyPond can cause the texedit url to point at the
# closing quote of a string, rather than the starting quote
end = token.end + source.block.position()
r = lydocument.Runner.at(c, False, True)
for token in r.backward():
if isinstance(token, ly.lex.StringStart):
cur.setPosition(token.pos)
break
else:
# find the end of the string
for token in source:
if isinstance(token, ly.lex.StringEnd):
end = token.end + source.block.position()
break
elif isinstance(token, ly.lex.MatchStart):
# find the end of slur, beam. ligature, phrasing slur, etc.
name = token.matchname
nest = 1
for token in source:
if isinstance(token, ly.lex.MatchEnd) and token.matchname == name:
nest -= 1
if nest == 0:
cursors.append(source.cursor(token))
break
elif isinstance(token, ly.lex.MatchStart) and token.matchname == name:
nest += 1
cur.setPosition(end, QTextCursor.KeepAnchor)
return cursors
|