30 changes: 15 additions & 15 deletions frescobaldi_app/completionmodel.py
Expand Up @@ -35,11 +35,11 @@

def model(key):
"""Returns the model for the given settings key.
A Model is instantiated if necessary.
The model remains alive until the application exits, at which
moment the data is saved.
"""
try:
return _models[key]
Expand All @@ -51,25 +51,25 @@ def model(key):

def complete(lineedit, key):
"""A convenience function that installs a completer on the QLineEdit.
The key is the QSettings key used to store the completions persistently.
By default, the function tries to add the text in the line edit to
By default, the function tries to add the text in the line edit to
the stored completions when the window() of the lineedit is a Dialog
and its accepted signal is fired.
Returned is a callable a signal can be connected to, that stores the text
in the line edit in the completions. (You don't have to use that if your
widget is in a QDialog that has an accepted() signal.)
"""
m = model(key)
c = QCompleter(m, lineedit)
lineedit.setCompleter(c)
def store(completer = weakref.ref(c)):
"""Stores the contents of the line edit in the completer's model.
Does not keep a reference to any object.
"""
c = completer()
if c:
Expand All @@ -81,9 +81,9 @@ def store(completer = weakref.ref(c)):
model.addString(text)
def connect():
"""Try to connect the store() function to the accepted() signal of the parent QDialog.
Return True if that succeeds, else False.
"""
dlg = lineedit.window()
try:
Expand All @@ -97,22 +97,22 @@ def connect():

class Model(QStringListModel):
"""A simple model providing a list of strings for a QCompleter.
Instantiate the model with a QSettings key, e.g. 'somegroup/names'.
Use the addString() method to add a string.
"""
def __init__(self, key):
super(Model, self).__init__()
super().__init__()
self.key = key
self._changed = False
self.load()

def load(self):
strings = qsettings.get_string_list(QSettings(), self.key)
self.setStringList(sorted(strings))
self._changed = False

def save(self):
if self._changed:
QSettings().setValue(self.key, self.stringList())
Expand Down
16 changes: 10 additions & 6 deletions frescobaldi_app/contextmenu.py
Expand Up @@ -28,6 +28,7 @@
from PyQt5.QtCore import QTimer, QUrl
from PyQt5.QtWidgets import QAction

import app
import icons
import util
import browseriface
Expand All @@ -40,12 +41,12 @@ def contextmenu(view):

# create the actions in the actions list
actions = []

actions.extend(open_files(cursor, menu, mainwindow))

actions.extend(jump_to_definition(cursor, menu, mainwindow))


if cursor.hasSelection():
import panelmanager
actions.append(mainwindow.actionCollection.edit_copy_colored_html)
Expand All @@ -54,7 +55,7 @@ def contextmenu(view):
ac = documentactions.get(mainwindow).actionCollection
actions.append(ac.edit_cut_assign)
actions.append(ac.edit_move_to_include_file)

# now add the actions to the standard menu
if actions:
first_action = menu.actions()[0] if menu.actions() else None
Expand All @@ -63,6 +64,10 @@ def contextmenu(view):
menu.insertActions(first_action, actions)
else:
menu.addActions(actions)
menu.addSeparator()
extensions = app.extensions().menu('editor')
if not extensions.isEmpty():
menu.addMenu(extensions)
return menu


Expand Down Expand Up @@ -107,4 +112,3 @@ def activate():
QTimer.singleShot(0, complete)
return [a]
return []

131 changes: 60 additions & 71 deletions frescobaldi_app/convert_ly.py
Expand Up @@ -25,7 +25,7 @@
import difflib
import textwrap
import os
import subprocess
import platform
import sys

from PyQt5.QtCore import QSettings, QSize
Expand All @@ -35,6 +35,7 @@
QGridLayout, QLabel, QLineEdit, QTabWidget, QTextBrowser, QVBoxLayout)

import app
import job
import util
import qutil
import icons
Expand Down Expand Up @@ -67,64 +68,64 @@ def convert(mainwindow):

class Dialog(QDialog):
def __init__(self, parent=None):
super(Dialog, self).__init__(parent)
super().__init__(parent)

self._info = None
self._text = ''
self._convertedtext = ''
self._encoding = None
self.mainwindow = parent

self.fromVersionLabel = QLabel()
self.fromVersion = QLineEdit()
self.reason = QLabel()
self.toVersionLabel = QLabel()
self.toVersion = QLineEdit()
self.lilyChooser = lilychooser.LilyChooser()
self.lilyChooser = lilychooser.LilyChooser(toolcommand='convert-ly')
self.messages = QTextBrowser()
self.diff = QTextBrowser(lineWrapMode=QTextBrowser.NoWrap)
self.uni_diff = QTextBrowser(lineWrapMode=QTextBrowser.NoWrap)
self.copyCheck = QCheckBox(checked=
QSettings().value('convert_ly/copy_messages', True, bool))
self.tabw = QTabWidget()

self.tabw.addTab(self.messages, '')
self.tabw.addTab(self.diff, '')
self.tabw.addTab(self.uni_diff, '')

self.buttons = QDialogButtonBox(
QDialogButtonBox.Reset | QDialogButtonBox.Save |
QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.buttons.button(QDialogButtonBox.Ok).clicked .connect(self.accept)
self.buttons.rejected.connect(self.reject)
self.buttons.button(QDialogButtonBox.Reset).clicked.connect(self.run)
self.buttons.button(QDialogButtonBox.Save).clicked.connect(self.saveFile)

layout = QVBoxLayout()
self.setLayout(layout)

grid = QGridLayout()
grid.addWidget(self.fromVersionLabel, 0, 0)
grid.addWidget(self.fromVersion, 0, 1)
grid.addWidget(self.reason, 0, 2, 1, 3)
grid.addWidget(self.toVersionLabel, 1, 0)
grid.addWidget(self.toVersion, 1, 1)
grid.addWidget(self.lilyChooser, 1, 3, 1, 2)

layout.addLayout(grid)
layout.addWidget(self.tabw)
layout.addWidget(self.copyCheck)
layout.addWidget(widgets.Separator())
layout.addWidget(self.buttons)

app.translateUI(self)
qutil.saveDialogSize(self, 'convert_ly/dialog/size', QSize(600, 300))
app.settingsChanged.connect(self.readSettings)
self.readSettings()
self.finished.connect(self.saveCopyCheckSetting)
self.lilyChooser.currentIndexChanged.connect(self.slotLilyPondVersionChanged)
self.slotLilyPondVersionChanged()

def translateUI(self):
self.fromVersionLabel.setText(_("From version:"))
self.toVersionLabel.setText(_("To version:"))
Expand All @@ -138,33 +139,35 @@ def translateUI(self):
self.buttons.button(QDialogButtonBox.Reset).setText(_("Run Again"))
self.buttons.button(QDialogButtonBox.Save).setText(_("Save as file"))
self.setCaption()

def saveCopyCheckSetting(self):
QSettings().setValue('convert_ly/copy_messages', self.copyCheck.isChecked())

def readSettings(self):
font = textformats.formatData('editor').font
self.diff.setFont(font)
diffFont = QFont("Monospace")
diffFont.setStyleHint(QFont.TypeWriter)
self.uni_diff.setFont(diffFont)

def slotLilyPondVersionChanged(self):
self.setLilyPondInfo(self.lilyChooser.lilyPondInfo())

def setCaption(self):
version = self._info and self._info.versionString() or _("<unknown>")
title = _("Convert-ly from LilyPond {version}").format(version=version)
self.setWindowTitle(app.caption(title))

def setLilyPondInfo(self, info):
if not info:
return
self._info = info
self.setCaption()
self.toVersion.setText(info.versionString())
self.setConvertedText()
self.setDiffText()
self.messages.clear()

def setConvertedText(self, text=''):
self._convertedtext = text
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(text))
Expand All @@ -175,23 +178,23 @@ def setConvertedText(self, text=''):
wrapcolumn=100))
else:
self.diff.clear()

def setDiffText(self, text=''):
if text:
from_filename = "current" # TODO: maybe use real filename here
to_filename = "converted" # but difflib can choke on non-ascii characters,
# see https://github.com/wbsoft/frescobaldi/issues/674
# see https://github.com/frescobaldi/frescobaldi/issues/674
difflist = list(difflib.unified_diff(
self._text.split('\n'), text.split('\n'),
self._text.split('\n'), text.split('\n'),
from_filename, to_filename))
diffHLstr = self.diffHighl(difflist)
self.uni_diff.setHtml(diffHLstr)
else:
self.uni_diff.clear()

def convertedText(self):
return self._convertedtext or ''

def setDocument(self, doc):
v = documentinfo.docinfo(doc).version_string()
if v:
Expand All @@ -203,7 +206,7 @@ def setDocument(self, doc):
self._encoding = doc.encoding() or 'UTF-8'
self.setConvertedText()
self.setDiffText()

def run(self):
"""Runs convert-ly (again)."""
fromVersion = self.fromVersion.text()
Expand All @@ -213,52 +216,40 @@ def run(self):
"Both 'from' and 'to' versions need to be set."))
return
info = self._info
command = info.toolcommand(info.ly_tool('convert-ly'))
command = info.toolcommand('convert-ly')
command += ['-f', fromVersion, '-t', toVersion, '-']

# if the user wants english messages, do it also here: LANGUAGE=C
env = None
if os.name == "nt":
# Python 2.7 subprocess on Windows chokes on unicode in env
env = util.bytes_environ()
else:
env = dict(os.environ)
if sys.platform.startswith('darwin'):
try:
del env['PYTHONHOME']
except KeyError:
pass
try:
del env['PYTHONPATH']
except KeyError:
pass

self.job = j = job.Job(command, encoding='utf-8')
if QSettings().value("lilypond_settings/no_translation", False, bool):
if os.name == "nt":
# Python 2.7 subprocess on Windows chokes on unicode in env
env[b'LANGUAGE'] = b'C'
else:
env['LANGUAGE'] = 'C'

with qutil.busyCursor():
try:
proc = subprocess.Popen(command,
env = env,
stdin = subprocess.PIPE,
stdout = subprocess.PIPE,
stderr = subprocess.PIPE)
out, err = proc.communicate(util.platform_newlines(self._text).encode(self._encoding))
except OSError as e:
self.messages.setPlainText(_(
"Could not start {convert_ly}:\n\n"
"{message}\n").format(convert_ly = command[0], message = e))
return
out = util.universal_newlines(out.decode('UTF-8'))
err = util.universal_newlines(err.decode('UTF-8'))
self.messages.setPlainText(err)
self.setConvertedText(out)
self.setDiffText(out)
if not out or self._convertedtext == self._text:
self.messages.append('\n' + _("The document has not been changed."))
j.environment['LC_MESSAGES'] = 'C'
else:
j.environment.pop('LC_MESSAGES', None)
if platform.system() == "Darwin":
import macos
if macos.inside_app_bundle():
j.environment['PYTHONPATH'] = None
j.environment['PYTHONHOME'] = None

j.done.connect(self.slotJobDone)
app.job_queue().add_job(j, 'generic')
j._process.write(self._text.encode('utf-8'))
j._process.closeWriteChannel()

def slotJobDone(self):
j = self.job
if not j.success and j.failed_to_start():
self.messages.setPlainText(_(
"Could not start {convert_ly}:\n\n"
"{message}\n").format(convert_ly = j.command[0],
message = j.error))
return
out = j.stdout()
err = j.stderr()
self.messages.setPlainText(err)
self.setConvertedText(out)
self.setDiffText(out)
if not out or self._convertedtext == self._text:
self.messages.append('\n' + _("The document has not been changed."))

def saveFile(self):
"""Save content in tab as file"""
Expand All @@ -267,7 +258,7 @@ def saveFile(self):
orgname = doc.url().toLocalFile()
filename = os.path.splitext(orgname)[0] + '['+tabdata.filename+']'+'.'+tabdata.ext
caption = app.caption(_("dialog title", "Save File"))
filetypes = '{0} (*.txt);;{1} (*.htm);;{2} (*)'.format(_("Text Files"), _("HTML Files"), _("All Files"))
filetypes = f'{_("Text Files")} (*.txt);;{_("HTML Files")} (*.htm);;{_("All Files")} (*)'
filename = QFileDialog.getSaveFileName(self.mainwindow, caption, filename, filetypes)[0]
if not filename:
return False # cancelled
Expand All @@ -282,7 +273,7 @@ def getTabData(self, index):
return FileInfo('html-diff', 'html', self.diff.toHtml())
elif index == 2:
return FileInfo('uni-diff', 'diff', self.uni_diff.toPlainText())

def diffHighl(self, difflist):
"""Return highlighted version of input."""
result = []
Expand All @@ -304,5 +295,3 @@ def __init__(self, filename, ext, text):
self.filename = filename
self.ext = ext
self.text = text


326 changes: 193 additions & 133 deletions frescobaldi_app/copy2image.py

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions frescobaldi_app/cursordiff.py
Expand Up @@ -33,29 +33,29 @@

def insert_text(cursor, text):
"""Replaces selected text of a QTextCursor.
This is done without erasing all the other QTextCursor instances that could
exist in the selected range. It works by making a diff between the
existing selection and the replacement text, and applying that diff.
"""
if not cursor.hasSelection() or text == "":
cursor.insertText(text)
return

start = cursor.selectionStart()
new_pos = start + len(text)

old = cursor.selection().toPlainText()
diff = difflib.SequenceMatcher(None, old, text).get_opcodes()

# make a list of edits
edits = sorted(
((start + i1, start + i2, text[j1:j2])
for tag, i1, i2, j1, j2 in diff
if tag != 'equal'),
reverse = True)

# perform the edits
with cursortools.compress_undo(cursor):
for pos, end, text in edits:
Expand Down
36 changes: 18 additions & 18 deletions frescobaldi_app/cursortools.py
Expand Up @@ -30,16 +30,16 @@

def block(cursor):
"""Returns the cursor's block.
If the cursor has a selection, returns the block the selection starts in
(regardless of the cursor's position()).
"""
if cursor.hasSelection():
return cursor.document().findBlock(cursor.selectionStart())
return cursor.block()


def blocks(cursor):
"""Yields the block(s) containing the cursor or selection."""
d = cursor.document()
Expand All @@ -50,7 +50,7 @@ def blocks(cursor):
if block == end:
break
block = block.next()


def contains(c1, c2):
"""Returns True if cursor2's selection falls inside cursor1's."""
Expand All @@ -60,9 +60,9 @@ def contains(c1, c2):

def forwards(block, until=QTextBlock()):
"""Yields the block and all following blocks.
If until is a valid block, yields the blocks until the specified block.
"""
if until.isValid():
while block.isValid() and block <= until:
Expand All @@ -76,9 +76,9 @@ def forwards(block, until=QTextBlock()):

def backwards(block, until=QTextBlock()):
"""Yields the block and all preceding blocks.
If until is a valid block, yields the blocks until the specified block.
"""
if until.isValid():
while block.isValid() and block >= until:
Expand All @@ -89,21 +89,21 @@ def backwards(block, until=QTextBlock()):
yield block
block = block.previous()


def all_blocks(document):
"""Yields all blocks of the document."""
return forwards(document.firstBlock())


def partition(cursor):
"""Returns a three-tuple of strings (before, selection, after).
'before' is the text before the cursor's position or selection start,
'after' is the text after the cursor's position or selection end,
'selection' is the selected text.
before and after never contain a newline.
"""
start = cursor.document().findBlock(cursor.selectionStart())
end = cursor.document().findBlock(cursor.selectionEnd())
Expand All @@ -126,14 +126,14 @@ def compress_undo(cursor, join_previous = False):
@contextlib.contextmanager
def keep_selection(cursor, edit=None):
"""Performs operations inside the selection and restore the selection afterwards.
If edit is given, call setTextCursor(cursor) on the Q(Plain)TextEdit afterwards.
"""
start, end, pos = cursor.selectionStart(), cursor.selectionEnd(), cursor.position()
cur2 = QTextCursor(cursor)
cur2.setPosition(end)

try:
yield
finally:
Expand All @@ -149,10 +149,10 @@ def keep_selection(cursor, edit=None):

def strip_selection(cursor, chars=None):
"""Adjusts the selection of the cursor just like Python's strip().
If there is no selection or the selection would vanish completely,
nothing is done.
"""
if not cursor.hasSelection():
return
Expand Down Expand Up @@ -232,7 +232,7 @@ def previous_blank(block):


def data(block):
"""Get the block's QTextBlockUserData, creating it if necessary."""
"""Get the block's QTextBlockUserData, creating it if necessary."""
data = block.userData()
if not data:
data = QTextBlockUserData()
Expand Down
18 changes: 9 additions & 9 deletions frescobaldi_app/cut_assign.py
Expand Up @@ -47,15 +47,15 @@ def cut_assign(cursor):
"text to:"), regexp="[A-Za-z]+")
if not name:
return

cursortools.strip_selection(cursor)

# determine state at cursor
block = cursortools.block(cursor)
state = tokeniter.state(block)
for t in tokeniter.partition(cursor).left:
state.follow(t)

mode = ""
for p in state.parsers():
if isinstance(p, ly.lex.lilypond.ParseInputMode):
Expand Down Expand Up @@ -102,13 +102,13 @@ def cut_assign(cursor):

def move_to_include_file(cursor, parent_widget=None):
"""Opens a dialog to save the cursor's selection to a file.
The cursor's selection is then replaced with an \\include statement.
This function does its best to supply a good default filename and
use it correctly in a relative \\include statement.
Of course it only works well if the document already has a filename.
"""
doc = cursor.document()
text = cursor.selection().toPlainText()
Expand All @@ -120,7 +120,7 @@ def move_to_include_file(cursor, parent_widget=None):
ext = ".ily"
version = documentinfo.docinfo(doc).version_string()
if version:
text = '\\version "{0}"\n\n{1}'.format(version, text)
text = f'\\version "{version}"\n\n{text}'
docname = name + "-include" + ext
dirname = os.path.dirname(doc.url().toLocalFile()) or app.basedir()
filename = os.path.join(dirname, docname)
Expand All @@ -131,15 +131,15 @@ def move_to_include_file(cursor, parent_widget=None):
try:
with open(filename, "wb") as f:
f.write(data)
except IOError as e:
except OSError as e:
msg = _("{message}\n\n{strerror} ({errno})").format(
message = _("Could not write to: {url}").format(url=filename),
strerror = e.strerror,
errno = e.errno)
QMessageBox.critical(parent_widget, app.caption(_("Error")), msg)
return
filename = os.path.relpath(filename, dirname)
command = '\\include "{0}"\n'.format(filename)
command = f'\\include "{filename}"\n'
cursor.insertText(command)


21 changes: 9 additions & 12 deletions frescobaldi_app/debug.py
Expand Up @@ -6,25 +6,26 @@
# signals to debug-print functions, and imports the most important modules such
# as app.

from __future__ import print_function


import sys

from frescobaldi_app.__main__ import main

from . import toplevel
toplevel.install()

import main
import app
import document


def doc_repr(self):
index = app.documents.index(self)
return '<Document #{0} "{1}">'.format(index, self.url().toString())
return f'<Document #{index} "{self.url().toString()}">'
document.Document.__repr__ = doc_repr

@app.documentCreated.connect
def f(doc):
def f(doc):
print("created:", doc)

@app.documentLoaded.connect
Expand All @@ -47,8 +48,8 @@ def f(doc, job, success):


# more to add...


# delete unneeded stuff
del f, doc_repr

Expand All @@ -61,12 +62,8 @@ def modules():
sys.displayhook = app.displayhook

# instantiate app and create a mainwindow, etc
app.instantiate()
main.main()
app.appStarted()
main(debug=True)

# be friendly and import Qt stuff
from PyQt5.QtCore import *
from PyQt5.QtGui import *


64 changes: 44 additions & 20 deletions frescobaldi_app/debuginfo.py
Expand Up @@ -23,9 +23,10 @@


import functools
import sys
import platform
import os

import app
import appinfo


Expand All @@ -45,11 +46,6 @@ def app_version():
import appinfo
return appinfo.version

@_catch_unknown
def sip_version():
import sip
return sip.SIP_VERSION_STR

@_catch_unknown
def pyqt_version():
import PyQt5.QtCore
Expand All @@ -62,19 +58,31 @@ def qt_version():

@_catch_unknown
def python_version():
import platform
return platform.python_version()

@_catch_unknown
def operating_system():
import platform
return platform.platform()
plat = platform.platform()
if platform.system() == "Linux":
try:
distro = platform.freedesktop_os_release()["PRETTY_NAME"]
except OSError:
# play it safe
distro = "unknown distribution"
return f"{plat} ({distro})"
else:
return plat

@_catch_unknown
def ly_version():
import ly.pkginfo
return ly.pkginfo.version

@_catch_unknown
def qpageview_version():
import qpageview
return qpageview.version_string

@_catch_unknown
def poppler_version():
import popplerqt5
Expand All @@ -85,36 +93,52 @@ def python_poppler_version():
import popplerqt5
return '.'.join(format(n) for n in popplerqt5.version())

if sys.platform.startswith('darwin'):
if platform.system() == "Darwin":
@_catch_unknown
def mac_installation_kind():
import macosx
if macosx.inside_app_bundle():
if os.path.islink(os.getcwd() + '/../MacOS/python'):
return 'lightweight app bundle'
else:
return 'standalone app bundle'
import macos
if macos.inside_lightweight_app_bundle():
return 'lightweight .app bundle'
elif macos.inside_app_bundle():
return 'standalone .app bundle'
else:
return 'command line'

elif platform.system() == "Linux":
@_catch_unknown
def linux_installation_kind():
import linux
if linux.inside_flatpak():
return "Flatpak"
else:
return "distro package or installed from source"

def version_info_named():
"""Yield all the relevant names and their version string."""
yield appinfo.appname, appinfo.version
yield "Extension API", appinfo.extension_api
yield "Python", python_version()
if app.is_git_controlled():
import vcs
repo = vcs.app_repo
yield "Git branch", repo.active_branch()
commit = repo.run_command(
'log',
['-n', '1', '--format=format:%h'])
yield "on commit", commit[0]
yield "python-ly", ly_version()
yield "Qt", qt_version()
yield "PyQt", pyqt_version()
yield "sip", sip_version()
yield "qpageview", qpageview_version()
yield "poppler", poppler_version()
yield "python-poppler-qt", python_poppler_version()
yield "OS", operating_system()
if sys.platform.startswith('darwin'):
if platform.system() == 'Darwin':
yield "installation kind", mac_installation_kind()
elif platform.system() == "Linux":
yield "Installation kind", linux_installation_kind()


def version_info_string(separator='\n'):
"""Return all version names as a string, joint with separator."""
return separator.join(map("{0[0]}: {0[1]}".format, version_info_named()))


4 changes: 2 additions & 2 deletions frescobaldi_app/definition.py
Expand Up @@ -54,9 +54,9 @@ def target(node):

def goto_definition(mainwindow, cursor=None):
"""Go to the definition of the item the mainwindow's cursor is at.
Return True if there was a definition.
"""
if cursor is None:
cursor = mainwindow.textCursor()
Expand Down
28 changes: 19 additions & 9 deletions frescobaldi_app/docbrowser/__init__.py
Expand Up @@ -22,6 +22,8 @@
"""


import importlib.util

from PyQt5.QtCore import Qt
from PyQt5.QtGui import QKeySequence
from PyQt5.QtWidgets import QAction
Expand All @@ -34,7 +36,7 @@

class HelpBrowser(panel.Panel):
def __init__(self, mainwindow):
super(HelpBrowser, self).__init__(mainwindow)
super().__init__(mainwindow)
self.toggleViewAction().setShortcut(QKeySequence("Meta+Alt+D"))
self.hide()
mainwindow.addDockWidget(Qt.RightDockWidgetArea, self)
Expand All @@ -45,44 +47,52 @@ def __init__(self, mainwindow):
def translateUI(self):
self.setWindowTitle(_("Documentation Browser"))
self.toggleViewAction().setText(_("&Documentation Browser"))

def createWidget(self):
if not importlib.util.find_spec('PyQt5.QtWebEngineWidgets'):
import webenginedummy
return webenginedummy.WebEngineDummy(self)
from . import browser
return browser.Browser(self)

def activate(self):
super(HelpBrowser, self).activate()
super().activate()
self.widget().webview.setFocus()


class Actions(actioncollection.ActionCollection):
name = "docbrowser"

def title(self):
return _("Documentation Browser")

def createActions(self, parent=None):
self.help_back = QAction(parent)
self.help_forward = QAction(parent)
self.help_home = QAction(parent)
self.help_web_browser = QAction(parent)
self.help_web_browser_homepage = QAction(parent)
self.help_print = QAction(parent)
self.help_lilypond_doc= QAction(parent)
self.help_lilypond_context = QAction(parent)

self.help_back.setIcon(icons.get("go-previous"))
self.help_forward.setIcon(icons.get("go-next"))
self.help_home.setIcon(icons.get("go-home"))
self.help_web_browser.setIcon(icons.get("internet-web-browser"))
self.help_web_browser_homepage.setIcon(icons.get("internet-web-browser"))
self.help_lilypond_doc.setIcon(icons.get("lilypond-run"))
self.help_print.setIcon(icons.get("document-print"))

self.help_lilypond_doc.setShortcut(QKeySequence("F9"))
self.help_lilypond_context.setShortcut(QKeySequence("Shift+F9"))

def translateUI(self):
self.help_back.setText(_("Back"))
self.help_forward.setText(_("Forward"))
# L10N: Home page of the LilyPond manual
self.help_home.setText(_("Home"))
self.help_web_browser.setText(_("Open Current Page in Web Browser"))
self.help_web_browser_homepage.setText(_("Open Homepage in Web Browser"))
self.help_print.setText(_("Print..."))
self.help_lilypond_doc.setText(_("&LilyPond Documentation"))
self.help_lilypond_context.setText(_("&Contextual LilyPond Help"))
Expand Down
145 changes: 82 additions & 63 deletions frescobaldi_app/docbrowser/browser.py
Expand Up @@ -27,14 +27,12 @@
from PyQt5.QtCore import QSettings, Qt, QUrl
from PyQt5.QtGui import QKeySequence
from PyQt5.QtPrintSupport import QPrintDialog, QPrinter
from PyQt5.QtWebKit import QWebSettings
from PyQt5.QtWebKitWidgets import QWebPage, QWebView
from PyQt5.QtWidgets import QComboBox, QMenu, QToolBar, QVBoxLayout, QWidget
from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineView, QWebEngineSettings
from PyQt5.QtWidgets import QComboBox, QLineEdit, QMenu, QToolBar, QVBoxLayout, QWidget

import app
import icons
import helpers
import widgets.lineedit
import lilypondinfo
import lilydoc.manager
import lilydoc.network
Expand All @@ -44,89 +42,91 @@
class Browser(QWidget):
"""LilyPond documentation browser widget."""
def __init__(self, dockwidget):
super(Browser, self).__init__(dockwidget)
super().__init__(dockwidget)

layout = QVBoxLayout(spacing=0)
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)

self.toolbar = tb = QToolBar()
self.webview = QWebView(contextMenuPolicy=Qt.CustomContextMenu)
self.webview = QWebEngineView(self, contextMenuPolicy=Qt.CustomContextMenu)
self.webview.setPage(WebEnginePage(self.webview))
self.chooser = QComboBox(sizeAdjustPolicy=QComboBox.AdjustToContents)
self.search = SearchEntry(maximumWidth=200)
self.search = SearchEntry(maximumWidth=200, clearButtonEnabled=True)

layout.addWidget(self.toolbar)
layout.addWidget(self.webview)

ac = dockwidget.actionCollection
ac.help_back.triggered.connect(self.webview.back)
ac.help_forward.triggered.connect(self.webview.forward)
ac.help_home.triggered.connect(self.showHomePage)
ac.help_web_browser_homepage.triggered.connect(self.openWebBrowserHomePage)
ac.help_web_browser.triggered.connect(self.openWebBrowser)
ac.help_print.triggered.connect(self.slotPrint)

self.webview.page().setNetworkAccessManager(lilydoc.network.accessmanager())
self.webview.page().setLinkDelegationPolicy(QWebPage.DelegateAllLinks)
self.webview.page().linkClicked.connect(self.openUrl)
self.webview.page().setForwardUnsupportedContent(True)
self.webview.page().unsupportedContent.connect(self.slotUnsupported)

self.webview.urlChanged.connect(self.slotUrlChanged)
self.webview.customContextMenuRequested.connect(self.slotShowContextMenu)

tb.addAction(ac.help_back)
tb.addAction(ac.help_forward)
tb.addSeparator()
tb.addAction(ac.help_home)
tb.addAction(ac.help_web_browser)
w = tb.widgetForAction(ac.help_web_browser)
w.addAction(ac.help_web_browser_homepage)
tb.addAction(ac.help_print)
tb.addSeparator()
tb.addWidget(self.chooser)
tb.addWidget(self.search)

self.chooser.activated[int].connect(self.showHomePage)
self.search.textEdited.connect(self.slotSearchChanged)
self.search.textChanged.connect(self.slotSearchChanged)
self.search.returnPressed.connect(self.slotSearchReturnPressed)
dockwidget.mainwindow().iconSizeChanged.connect(self.updateToolBarSettings)
dockwidget.mainwindow().toolButtonStyleChanged.connect(self.updateToolBarSettings)

app.settingsChanged.connect(self.readSettings)
self.readSettings()
self.loadDocumentation()
self.showInitialPage()
app.settingsChanged.connect(self.loadDocumentation)
app.translateUI(self)

def readSettings(self):
s = QSettings()
s.beginGroup("documentation")
ws = self.webview.page().settings()
family = s.value("fontfamily", self.font().family(), str)
size = s.value("fontsize", 16, int)
ws.setFontFamily(QWebSettings.StandardFont, family)
ws.setFontSize(QWebSettings.DefaultFontSize, size)
ws.setFontFamily(QWebEngineSettings.StandardFont, family)
ws.setFontSize(QWebEngineSettings.DefaultFontSize, size)
fixed = textformats.formatData('editor').font
ws.setFontFamily(QWebSettings.FixedFont, fixed.family())
ws.setFontSize(QWebSettings.DefaultFixedFontSize, fixed.pointSizeF() * 96 / 72)

ws.setFontFamily(QWebEngineSettings.FixedFont, fixed.family())
ws.setFontSize(QWebEngineSettings.DefaultFixedFontSize, int(fixed.pointSizeF() * 96 / 72))
self.webview.page().profile().setHttpAcceptLanguage(','.join(lilydoc.network.langs()))

def keyPressEvent(self, ev):
if ev.text() == "/":
self.search.setFocus()
else:
super(Browser, self).keyPressEvent(ev)
super().keyPressEvent(ev)

def translateUI(self):
try:
self.search.setPlaceholderText(_("Search..."))
except AttributeError:
pass # not in Qt 4.6

def showInitialPage(self):
"""Shows the preferred start page.
If a local documentation instance already has a suitable version,
just loads it. Otherwise connects to the allLoaded signal, that is
emitted when all the documentation instances have loaded their version
information and then shows the start page (if another page wasn't yet
loaded).
"""
if self.webview.url().isEmpty():
docs = lilydoc.manager.docs()
Expand All @@ -146,7 +146,7 @@ def showInitialPage(self):
index = len(docs) - 1
self.chooser.setCurrentIndex(index)
self.showHomePage()

def loadDocumentation(self):
"""Puts the available documentation instances in the combobox."""
i = self.chooser.currentIndex()
Expand All @@ -157,62 +157,62 @@ def loadDocumentation(self):
t = _("(local)")
else:
t = _("({hostname})").format(hostname=doc.url().host())
self.chooser.addItem("{0} {1}".format(v or _("<unknown>"), t))
self.chooser.addItem("{} {}".format(v or _("<unknown>"), t))
self.chooser.setCurrentIndex(i)
if not lilydoc.manager.loaded():
lilydoc.manager.allLoaded.connect(self.loadDocumentation, -1)
return

def updateToolBarSettings(self):
mainwin = self.parentWidget().mainwindow()
self.toolbar.setIconSize(mainwin.iconSize())
self.toolbar.setToolButtonStyle(mainwin.toolButtonStyle())

def showManual(self):
"""Invoked when the user presses F1."""
self.slotHomeFrescobaldi() # TEMP

def slotUrlChanged(self):
ac = self.parentWidget().actionCollection
ac.help_back.setEnabled(self.webview.history().canGoBack())
ac.help_forward.setEnabled(self.webview.history().canGoForward())

def openUrl(self, url):
if url.path().endswith(('.ily', '.lyi', '.ly')):
self.sourceViewer().showReply(lilydoc.network.get(url))
else:
self.webview.load(url)

def slotUnsupported(self, reply):
helpers.openUrl(reply.url())

def slotSearchChanged(self):
text = self.search.text()
if not text.startswith(':'):
self.webview.page().findText(text, QWebPage.FindWrapsAroundDocument)
self.webview.page().findText(text)

def slotSearchReturnPressed(self):
text = self.search.text()
if not text.startswith(':'):
self.slotSearchChanged()
else:
pass # TODO: implement full doc search

def sourceViewer(self):
try:
return self._sourceviewer
except AttributeError:
from . import sourceviewer
self._sourceviewer = sourceviewer.SourceViewer(self)
return self._sourceviewer
def showHomePage(self):
"""Shows the homepage of the LilyPond documentation."""

def getHomePageUrl(self):
"""Returns the URL of the LilyPond documentation."""
i = self.chooser.currentIndex()
if i < 0:
i = 0
doc = lilydoc.manager.docs()[i]

url = doc.home()
if doc.isLocal():
path = url.toLocalFile()
Expand All @@ -223,43 +223,55 @@ def showHomePage(self):
path += '.' + lang
break
url = QUrl.fromLocalFile(path + '.html')
self.webview.load(url)

return url

def showHomePage(self):
self.webview.load(self.getHomePageUrl())

def openWebBrowser(self):
helpers.openUrl(self.webview.url())

def openWebBrowserHomePage(self):
helpers.openUrl(self.getHomePageUrl())

def slotPrint(self):
printer = QPrinter()
printer = self._printer = QPrinter()
dlg = QPrintDialog(printer, self)
dlg.setWindowTitle(app.caption(_("Print")))
if dlg.exec_():
self.webview.print_(printer)

self.webview.page().print(printer, self.slotPrintingDone)

def slotPrintingDone(self, success):
del self._printer

def slotShowContextMenu(self, pos):
hit = self.webview.page().currentFrame().hitTestContent(pos)
d = self.webview.page().contextMenuData()
menu = QMenu()
if hit.linkUrl().isValid():
a = self.webview.pageAction(QWebPage.CopyLinkToClipboard)
if d.linkUrl().isValid():
a = self.webview.pageAction(QWebEnginePage.CopyLinkToClipboard)
a.setIcon(icons.get("edit-copy"))
a.setText(_("Copy &Link"))
menu.addAction(a)
menu.addSeparator()
a = menu.addAction(icons.get("window-new"), _("Open Link in &New Window"))
a.triggered.connect((lambda url: lambda: self.slotNewWindow(url))(hit.linkUrl()))
a = menu.addAction(icons.get("internet-web-browser"), _("Open Link in Web Browser"))
a.triggered.connect((lambda url: lambda: self.slotNewWindow(url))(d.linkUrl()))
else:
if hit.isContentSelected():
a = self.webview.pageAction(QWebPage.Copy)
if d.selectedText():
a = self.webview.pageAction(QWebEnginePage.Copy)
a.setIcon(icons.get("edit-copy"))
a.setText(_("&Copy"))
menu.addAction(a)
menu.addSeparator()
a = menu.addAction(icons.get("window-new"), _("Open Document in &New Window"))
a = menu.addAction(icons.get("internet-web-browser"), _("Open Current Page in Web Browser"))
a.triggered.connect((lambda url: lambda: self.slotNewWindow(url))(self.webview.url()))
if menu.actions():
menu.exec_(self.webview.mapToGlobal(pos))

def slotNewWindow(self, url):
helpers.openUrl(url)


class SearchEntry(widgets.lineedit.LineEdit):
class SearchEntry(QLineEdit):
"""A line edit that clears itself when ESC is pressed."""
def keyPressEvent(self, ev):
if ev.key() == Qt.Key_Escape:
Expand All @@ -276,6 +288,13 @@ def keyPressEvent(self, ev):
webview = self.parentWidget().parentWidget().webview
webview.keyPressEvent(ev)
else:
super(SearchEntry, self).keyPressEvent(ev)
super().keyPressEvent(ev)


class WebEnginePage(QWebEnginePage):
"""QWebEnginePage that shows LY files using the source viewer."""
def acceptNavigationRequest(self, url, type, isMainFrame):
if url.path().endswith(('.ily', '.lyi', '.ly')):
self.view().parent().sourceViewer().showReply(lilydoc.network.get(url))
return False
return super().acceptNavigationRequest(url, type, isMainFrame)
16 changes: 8 additions & 8 deletions frescobaldi_app/docbrowser/sourceviewer.py
Expand Up @@ -34,42 +34,42 @@

class SourceViewer(QDialog):
def __init__(self, browser):
super(SourceViewer, self).__init__(browser.parentWidget())
super().__init__(browser.parentWidget())

layout = QVBoxLayout()
layout.setContentsMargins(4, 4, 4, 4)
self.setLayout(layout)

self.urlLabel = QLabel(wordWrap=True)
layout.addWidget(self.urlLabel)
self.textbrowser = QTextBrowser()
layout.addWidget(self.textbrowser)

self.urlLabel.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
self.textbrowser.setLineWrapMode(QTextBrowser.NoWrap)

app.settingsChanged.connect(self.readSettings)
self.readSettings()
app.translateUI(self)
qutil.saveDialogSize(self, "helpbrowser/sourceviewer/size", QSize(400, 300))

def translateUI(self):
self.setWindowTitle(app.caption(_("LilyPond Source")))

def readSettings(self):
data = textformats.formatData('editor')
self.textbrowser.setPalette(data.palette())
self.textbrowser.setFont(data.font)
highlighter.highlight(self.textbrowser.document())

def showReply(self, reply):
reply.setParent(self)
self.urlLabel.setText(reply.url().toString())
self._reply = reply
reply.finished.connect(self.loadingFinished)
self.textbrowser.clear()
self.show()

def loadingFinished(self):
data = self._reply.readAll()
self._reply.close()
Expand Down
4 changes: 2 additions & 2 deletions frescobaldi_app/doclist/__init__.py
Expand Up @@ -29,15 +29,15 @@

class DocumentList(panel.Panel):
def __init__(self, mainwindow):
super(DocumentList, self).__init__(mainwindow)
super().__init__(mainwindow)
self.hide()
self.toggleViewAction().setShortcut(QKeySequence("Meta+Alt+F"))
mainwindow.addDockWidget(Qt.LeftDockWidgetArea, self)

def translateUI(self):
self.setWindowTitle(_("Documents"))
self.toggleViewAction().setText(_("Docum&ents"))

def createWidget(self):
from . import widget
w = widget.Widget(self)
Expand Down
54 changes: 19 additions & 35 deletions frescobaldi_app/doclist/widget.py
Expand Up @@ -35,23 +35,9 @@
import engrave


def path(url):
"""Returns the path, as a string, of the url to group documents.
Returns None if the document is nameless.
"""
if url.isEmpty():
return None
elif url.toLocalFile():
return util.homify(os.path.dirname(url.toLocalFile()))
else:
return url.resolved(QUrl('.')).toString(QUrl.RemoveUserInfo)


class Widget(QTreeWidget):
def __init__(self, tool):
super(Widget, self).__init__(tool, headerHidden=True)
super().__init__(tool, headerHidden=True)
self.setRootIsDecorated(False)
self.setSelectionMode(QTreeWidget.ExtendedSelection)
app.documentCreated.connect(self.addDocument)
Expand All @@ -67,7 +53,7 @@ def __init__(self, tool):
self.itemSelectionChanged.connect(self.slotItemSelectionChanged)
app.settingsChanged.connect(self.populate)
self.populate()

def populate(self):
self._group = QSettings().value("document_list/group_by_folder", False, bool)
self.clear()
Expand All @@ -80,11 +66,11 @@ def populate(self):
doc = self.parentWidget().mainwindow().currentDocument()
if doc:
self.selectDocument(doc)

def addDocument(self, doc):
self._items[doc] = QTreeWidgetItem(self)
self.setDocumentStatus(doc)

def removeDocument(self, doc):
i = self._items.pop(doc)
if not self._group:
Expand All @@ -95,7 +81,7 @@ def removeDocument(self, doc):
if parent.childCount() == 0:
self.takeTopLevelItem(self.indexOfTopLevelItem(parent))
del self._paths[parent._path]

def selectDocument(self, doc):
self.setCurrentItem(self._items[doc], 0, QItemSelectionModel.ClearAndSelect)

Expand All @@ -110,17 +96,17 @@ def setDocumentStatus(self, doc):
# set properties according to document
i.setText(0, doc.documentName())
i.setIcon(0, documenticon.icon(doc, self.parentWidget().mainwindow()))
i.setToolTip(0, path(doc.url()))
i.setToolTip(0, util.path(doc.url()))
# handle ordering in groups if desired
if self._group:
self.groupDocument(doc)
else:
self.sortItems(0, Qt.AscendingOrder)

def groupDocument(self, doc):
"""Called, if grouping is enabled, to group the document."""
i = self._items[doc]
p = path(doc.url())
p = util.path(doc.url())
new_parent = self._paths.get(p)
if new_parent is None:
new_parent = self._paths[p] = QTreeWidgetItem(self)
Expand All @@ -142,42 +128,42 @@ def groupDocument(self, doc):
self.takeTopLevelItem(self.indexOfTopLevelItem(i))
new_parent.addChild(i)
new_parent.sortChildren(0, Qt.AscendingOrder)

def document(self, item):
"""Returns the document for item."""
for d, i in self._items.items():
if i == item:
return d

def slotItemSelectionChanged(self):
if len(self.selectedItems()) == 1:
doc = self.document(self.selectedItems()[0])
if doc:
self.parentWidget().mainwindow().setCurrentDocument(doc)

def contextMenuEvent(self, ev):
item = self.itemAt(ev.pos())
if not item:
return

mainwindow = self.parentWidget().mainwindow()

selection = self.selectedItems()
doc = self.document(item)

if len(selection) <= 1 and doc:
# a single document is right-clicked
import documentcontextmenu
menu = documentcontextmenu.DocumentContextMenu(mainwindow)
menu.exec_(doc, ev.globalPos())
menu.deleteLater()
return

menu = QMenu(mainwindow)
save = menu.addAction(icons.get('document-save'), '')
menu.addSeparator()
close = menu.addAction(icons.get('document-close'), '')

if len(selection) > 1:
# multiple documents are selected
save.setText(_("Save selected documents"))
Expand All @@ -193,22 +179,20 @@ def contextMenuEvent(self, ev):
# the "Untitled" group is right-clicked
save.setText(_("Save all untitled documents"))
close.setText(_("Close all untitled documents"))

@save.triggered.connect
def savedocuments():
for d in documents:
if d.url().isEmpty() or d.isModified():
mainwindow.setCurrentDocument(d)
if not mainwindow.saveDocument(d):
break

@close.triggered.connect
def close_documents():
for d in documents:
if not mainwindow.closeDocument(d):
break

menu.exec_(ev.globalPos())
menu.deleteLater()


234 changes: 159 additions & 75 deletions frescobaldi_app/document.py
Expand Up @@ -18,11 +18,18 @@
# See http://www.gnu.org/licenses/ for more information.

"""
A Frescobaldi document.
A (Frescobaldi) document.
This contains the text the user can edit in Frescobaldi. In most cases it will
This contains the text the user can edit in Frescobaldi. In most cases it will
be a LilyPond source file, but other file types can be used as well.
There are two different document Classes: Document and EditorDocument.
Both provide a QTextDocument with additional metadata, but the EditorDocument
provides additional handling of signals that are hooked into the Frescobaldi
GUI environment. That means: use EditorDocument for documents open in the
editor, Document for "abstract" documents, for example to pass a generated
document to a job.lilypond.LilyPondJob without implicitly creating a tab.
"""


Expand All @@ -38,93 +45,82 @@
import signals


class Document(QTextDocument):

urlChanged = signals.Signal() # new url, old url
closed = signals.Signal()
loaded = signals.Signal()
saving = signals.SignalContext()
saved = signals.Signal()
class AbstractDocument(QTextDocument):
"""Base class for a Frescobaldi document. Not intended to be instantiated.
Objects of subclasses can be passed to the functions in documentinfo
or lilypondinfo etc. for additional meta information.
"""

@classmethod
def load_data(cls, url, encoding=None):
"""Class method to load document contents from an url.
This is intended to open a document without instantiating one
if loading the contents fails.
This method returns the text contents of the url as decoded text,
thus a unicode string.
The line separator is always '\\n'.
"""
filename = url.toLocalFile()

# currently, we do not support non-local files
if not filename:
raise IOError("not a local file")
raise OSError("not a local file")
with open(filename, 'rb') as f:
data = f.read()
text = util.decode(data, encoding)
return util.universal_newlines(text)

@classmethod
def new_from_url(cls, url, encoding=None):
"""Create and return a new document, loaded from url.
This is intended to open a new Document without instantiating one
if loading the contents fails.
"""
# Do this first, raises IOError if not found, without creating the document.
if not url.isEmpty():
text = cls.load_data(url, encoding)
data = cls.load_data(url, encoding)
# If this did not raise, proceed to create a new document.
d = cls(url, encoding)
if not url.isEmpty():
d.setPlainText(text)
d.setPlainText(data)
d.setModified(False)
d.loaded()
app.documentLoaded(d)
return d

def __init__(self, url=None, encoding=None):
"""Create a new Document with url and encoding.
Does not load the contents, you should use load() for that, or
use the new_from_url() constructor to instantiate a new Document
with the contents loaded.
"""
if url is None:
url = QUrl()
super(Document, self).__init__()
super().__init__()
self.setDocumentLayout(QPlainTextDocumentLayout(self))
self._encoding = encoding
self._url = url # avoid urlChanged on init
self.setUrl(url)
self.modificationChanged.connect(self.slotModificationChanged)
app.documents.append(self)
app.documentCreated(self)

def slotModificationChanged(self):
app.documentModificationChanged(self)

def close(self):
self.closed()
app.documentClosed(self)
app.documents.remove(self)

def load(self, url=None, encoding=None, keepUndo=False):
"""Load the specified or current url (if None was specified).
Currently only local files are supported. An IOError is raised
when trying to load a nonlocal URL.
If loading succeeds and an url was specified, the url is make the
If loading succeeds and an url was specified, the url is made the
current url (by calling setUrl() internally).
If keepUndo is True, the loading can be undone (with Ctrl-Z).
"""
if url is None:
url = QUrl()
Expand All @@ -139,85 +135,83 @@ def load(self, url=None, encoding=None, keepUndo=False):
self.setModified(False)
if not url.isEmpty():
self.setUrl(url)
self.loaded()
app.documentLoaded(self)


def _save(self, url, filename):
with open(filename, "wb") as f:
f.write(self.encodedText())
f.flush()
os.fsync(f.fileno())
self.setModified(False)
if not url.isEmpty():
self.setUrl(url)

def save(self, url=None, encoding=None):
"""Saves the document to the specified or current url.
Currently only local files are supported. An IOError is raised
when trying to save a nonlocal URL.
If saving succeeds and an url was specified, the url is made the
current url (by calling setUrl() internally).
This method is never called directly but only from the overriding
subclass methods that make further specific use of the modified results.
"""
if url is None:
url = QUrl()
u = url if not url.isEmpty() else self.url()
filename = u.toLocalFile()
# currently, we do not support non-local files
if not filename:
raise IOError("not a local file")
raise OSError("not a local file")
# keep the url if specified when we didn't have one, even if saving
# would fail
if self.url().isEmpty() and not url.isEmpty():
self.setUrl(url)
with self.saving(), app.documentSaving(self):
with open(filename, "wb") as f:
f.write(self.encodedText())
f.flush()
os.fsync(f.fileno())
self.setModified(False)
if not url.isEmpty():
self.setUrl(url)
self.saved()
app.documentSaved(self)
return url, filename

def url(self):
return self._url

def setUrl(self, url):
""" Change the url for this document. """
if url is None:
url = QUrl()
old, self._url = self._url, url
changed = old != url
# number for nameless documents
if self._url.isEmpty():
nums = [0]
nums.extend(doc._num for doc in app.documents if doc is not self)
self._num = max(nums) + 1
else:
self._num = 0
if changed:
self.urlChanged(url, old)
app.documentUrlChanged(self, url, old)

return old

def encoding(self):
return variables.get(self, "coding") or self._encoding

def setEncoding(self, encoding):
self._encoding = encoding

def encodedText(self):
"""Return the text of the document as a bytes string encoded in the
correct encoding.
The line separator is '\\n' on Unix/Linux/Mac OS X, '\\r\\n' on Windows.
The line separator is '\\n' on Unix, '\\r\\n' on Windows.
Useful to save to a file.
"""
text = util.platform_newlines(self.toPlainText())
return util.encode(text, self.encoding())

def documentName(self):
"""Return a suitable name for this document.
This is only to be used for display. If the url of the document is
empty, something like "Untitled" or "Untitled (3)" is returned.
"""
if self._url.isEmpty():
if self._num == 1:
Expand All @@ -228,3 +222,93 @@ def documentName(self):
return os.path.basename(self._url.path())


class Document(AbstractDocument):
"""A Frescobaldi document to be used anywhere except the main editor
viewspace (also non-GUI jobs/operations)."""

def save(self, url=None, encoding=None):
url, filename = super().save(url, encoding)
self._save(url, filename)


class EditorDocument(AbstractDocument):
"""A Frescobaldi document for use in the main editor view.
Basically this is an AbstractDocument with signals added."""

urlChanged = signals.Signal() # new url, old url
closed = signals.Signal()
loaded = signals.Signal()
saving = signals.SignalContext()
saved = signals.Signal()

@classmethod
def new_from_url(cls, url, encoding=None):
d = super().new_from_url(url, encoding)
if not url.isEmpty():
d.loaded()
app.documentLoaded(d)
return d

def __init__(self, url=None, encoding=None):
super().__init__(url, encoding)
self.modificationChanged.connect(self.slotModificationChanged)
app.documents.append(self)
app.documentCreated(self)

def slotModificationChanged(self):
app.documentModificationChanged(self)

def close(self):
self.closed()
app.documentClosed(self)
app.documents.remove(self)

def load(self, url=None, encoding=None, keepUndo=False):
super().load(url, encoding, keepUndo)
self.loaded()
app.documentLoaded(self)

def save(self, url=None, encoding=None):
url, filename = super().save(url, encoding)
with self.saving(), app.documentSaving(self):
self._save(url, filename)
self.saved()
app.documentSaved(self)

def setUrl(self, url):
old = super().setUrl(url)
if url != old:
self.urlChanged(url, old)
app.documentUrlChanged(self, url, old)

def cursorAtPosition(self, line, column=None):
"""Return a new QTextCursor set to the line and column given (each starting at 1).
This method avoids common pitfalls associated with arbitrarily setting the cursor
position via setCursorPosition.
- The cursor will be set at a vaid position in a valid block.
- Reasonable defaults are used for under/over-limit input.
- Character counting based on UTF-8 matches LilyPond and Python conventions.
- The cursor will not be set in the middle of a surrogate pair or composed glyph.
"""
if line < 1:
line = column = 1
elif not column or column < 1:
column = 1
cursor = QTextCursor(self)
block = self.findBlockByNumber(line - 1)
if block.isValid():
line_text = block.text()
if len(line_text) >= column:
qchar_offset = len(line_text[:column - 1].encode('utf_16_le')) // 2
cursor.setPosition(block.position() + qchar_offset)
# Escape to in front of what might be the middle of a composed glyph.
cursor.movePosition(QTextCursor.NextCharacter)
cursor.movePosition(QTextCursor.PreviousCharacter)
else:
cursor.setPosition(block.position())
cursor.movePosition(QTextCursor.EndOfBlock)
else:
cursor.movePosition(QTextCursor.End)
return cursor
81 changes: 52 additions & 29 deletions frescobaldi_app/documentactions.py
Expand Up @@ -62,18 +62,20 @@ def __init__(self, mainwindow):
ac.tools_quick_remove_dynamics.triggered.connect(self.quickRemoveDynamics)
ac.tools_quick_remove_fingerings.triggered.connect(self.quickRemoveFingerings)
ac.tools_quick_remove_markup.triggered.connect(self.quickRemoveMarkup)

ac.tools_directions_force_neutral.triggered.connect(self.forceDirectionsNeutral)
ac.tools_directions_force_up.triggered.connect(self.forceDirectionsUp)
ac.tools_directions_force_down.triggered.connect(self.forceDirectionsDown)
mainwindow.currentDocumentChanged.connect(self.updateDocActions)
mainwindow.selectionStateChanged.connect(self.updateSelectionActions)

def updateDocActions(self, doc):
minfo = metainfo.info(doc)
if minfo.highlighting:
highlighter.highlighter(doc)
ac = self.actionCollection
ac.view_highlighting.setChecked(minfo.highlighting)
ac.tools_indent_auto.setChecked(minfo.auto_indent)

def updateSelectionActions(self, selection):
self.actionCollection.edit_cut_assign.setEnabled(selection)
self.actionCollection.edit_move_to_include_file.setEnabled(selection)
Expand All @@ -87,103 +89,118 @@ def updateSelectionActions(self, selection):
self.actionCollection.tools_quick_remove_dynamics.setEnabled(selection)
self.actionCollection.tools_quick_remove_fingerings.setEnabled(selection)
self.actionCollection.tools_quick_remove_markup.setEnabled(selection)

self.actionCollection.tools_directions_force_up.setEnabled(selection)
self.actionCollection.tools_directions_force_neutral.setEnabled(selection)
self.actionCollection.tools_directions_force_down.setEnabled(selection)

def currentView(self):
return self.mainwindow().currentView()

def currentDocument(self):
return self.mainwindow().currentDocument()

def updateOtherDocActions(self):
"""Calls updateDocActions() in other instances that show same document."""
doc = self.currentDocument()
for i in self.instances():
if i is not self and i.currentDocument() == doc:
i.updateDocActions(doc)

def gotoFileOrDefinition(self):
import open_file_at_cursor
result = open_file_at_cursor.open_file_at_cursor(self.mainwindow())
if not result:
import definition
definition.goto_definition(self.mainwindow())

def cutAssign(self):
import cut_assign
cut_assign.cut_assign(self.currentView().textCursor())

def moveToIncludeFile(self):
import cut_assign
cut_assign.move_to_include_file(self.currentView().textCursor(), self.mainwindow())

def toggleAuto_indent(self):
minfo = metainfo.info(self.currentDocument())
minfo.auto_indent = not minfo.auto_indent
self.updateOtherDocActions()

def re_indent(self):
import indent
indent.re_indent(self.currentView().textCursor())

def reFormat(self):
import reformat
reformat.reformat(self.currentView().textCursor())

def removeTrailingWhitespace(self):
import reformat
reformat.remove_trailing_whitespace(self.currentView().textCursor())

def toggleHighlighting(self):
doc = self.currentDocument()
minfo = metainfo.info(doc)
minfo.highlighting = not minfo.highlighting
highlighter.highlighter(doc).setHighlighting(minfo.highlighting)
self.updateOtherDocActions()

def convertLy(self):
import convert_ly
convert_ly.convert(self.mainwindow())

def quickRemoveComments(self):
import quickremove
quickremove.comments(self.mainwindow().textCursor())

def quickRemoveArticulations(self):
import quickremove
quickremove.articulations(self.mainwindow().textCursor())

def quickRemoveOrnaments(self):
import quickremove
quickremove.ornaments(self.mainwindow().textCursor())

def quickRemoveInstrumentScripts(self):
import quickremove
quickremove.instrument_scripts(self.mainwindow().textCursor())

def quickRemoveSlurs(self):
import quickremove
quickremove.slurs(self.mainwindow().textCursor())

def quickRemoveBeams(self):
import quickremove
quickremove.beams(self.mainwindow().textCursor())

def quickRemoveLigatures(self):
import quickremove
quickremove.ligatures(self.mainwindow().textCursor())

def quickRemoveDynamics(self):
import quickremove
quickremove.dynamics(self.mainwindow().textCursor())

def quickRemoveFingerings(self):
import quickremove
quickremove.fingerings(self.mainwindow().textCursor())

def quickRemoveMarkup(self):
import quickremove
quickremove.markup(self.mainwindow().textCursor())

def forceDirectionsUp(self):
import quickremove
quickremove.force_directions(self.mainwindow().textCursor(), 'up')

def forceDirectionsNeutral(self):
import quickremove
quickremove.force_directions(self.mainwindow().textCursor(), 'neutral')

def forceDirectionsDown(self):
import quickremove
quickremove.force_directions(self.mainwindow().textCursor(), 'down')


class Actions(actioncollection.ActionCollection):
name = "documentactions"
Expand All @@ -209,13 +226,17 @@ def createActions(self, parent):
self.tools_quick_remove_dynamics = QAction(parent)
self.tools_quick_remove_fingerings = QAction(parent)
self.tools_quick_remove_markup = QAction(parent)


self.tools_directions_force_up = QAction(parent)
self.tools_directions_force_neutral = QAction(parent)
self.tools_directions_force_down = QAction(parent)

self.edit_cut_assign.setIcon(icons.get('edit-cut'))
self.edit_move_to_include_file.setIcon(icons.get('edit-cut'))

self.view_goto_file_or_definition.setShortcut(QKeySequence(Qt.ALT + Qt.Key_Return))
self.edit_cut_assign.setShortcut(QKeySequence(Qt.SHIFT + Qt.CTRL + Qt.Key_X))

def translateUI(self):
self.edit_cut_assign.setText(_("Cut and Assign..."))
self.edit_move_to_include_file.setText(_("Move to Include File..."))
Expand All @@ -225,7 +246,7 @@ def translateUI(self):
self.tools_indent_indent.setText(_("Re-&Indent"))
self.tools_reformat.setText(_("&Format"))
self.tools_remove_trailing_whitespace.setText(_("Remove Trailing &Whitespace"))
self.tools_convert_ly.setText(_("&Update with convert-ly..."))
self.tools_convert_ly.setText(_("&Update with convert-ly..."))
self.tools_quick_remove_comments.setText(_("Remove &Comments"))
self.tools_quick_remove_articulations.setText(_("Remove &Articulations"))
self.tools_quick_remove_ornaments.setText(_("Remove &Ornaments"))
Expand All @@ -236,4 +257,6 @@ def translateUI(self):
self.tools_quick_remove_dynamics.setText(_("Remove &Dynamics"))
self.tools_quick_remove_fingerings.setText(_("Remove &Fingerings"))
self.tools_quick_remove_markup.setText(_("Remove Text &Markup (from music)"))

self.tools_directions_force_up.setText(_("Force Directions &Up"))
self.tools_directions_force_neutral.setText(_("Make Directions &Neutral"))
self.tools_directions_force_down.setText(_("Force Directions &Down"))
30 changes: 18 additions & 12 deletions frescobaldi_app/documentcontextmenu.py
Expand Up @@ -29,18 +29,21 @@

import app
import icons
import documentmenu


class DocumentContextMenu(QMenu):
def __init__(self, mainwindow):
super(DocumentContextMenu, self).__init__(mainwindow)
super().__init__(mainwindow)
self._doc = lambda: None

self.createActions()
app.translateUI(self)
self.aboutToShow.connect(self.updateActions)

def createActions(self):
self.addMenu(self.menu_document())
self.addSeparator()
self.doc_save = self.addAction(icons.get('document-save'), '')
self.doc_save_as = self.addAction(icons.get('document-save-as'), '')
self.addSeparator()
Expand All @@ -49,45 +52,48 @@ def createActions(self):
self.addSeparator()
self.doc_toggle_sticky = self.addAction(icons.get('pushpin'), '')
self.doc_toggle_sticky.setCheckable(True)

self.doc_save.triggered.connect(self.docSave)
self.doc_save_as.triggered.connect(self.docSaveAs)
self.doc_close.triggered.connect(self.docClose)
self.doc_close_others.triggered.connect(self.docCloseOther)
self.doc_toggle_sticky.triggered.connect(self.docToggleSticky)

def updateActions(self):
"""Called just before show."""
doc = self._doc()
if doc:
import engrave
engraver = engrave.Engraver.instance(self.mainwindow())
self.doc_toggle_sticky.setChecked(doc is engraver.stickyDocument())

def translateUI(self):
self.doc_save.setText(_("&Save"))
self.doc_save_as.setText(_("Save &As..."))
self.doc_close.setText(_("&Close"))
self.doc_close_others.setText(_("Close Other Documents"))
self.doc_toggle_sticky.setText(_("Always &Engrave This Document"))

def mainwindow(self):
return self.parentWidget()

def exec_(self, document, pos):
self._doc = weakref.ref(document)
super(DocumentContextMenu, self).exec_(pos)

super().exec_(pos)

def menu_document(self):
return documentmenu.DocumentMenu(self.mainwindow())

def docSave(self):
doc = self._doc()
if doc:
self.mainwindow().saveDocument(doc)

def docSaveAs(self):
doc = self._doc()
if doc:
self.mainwindow().saveDocumentAs(doc)

def docClose(self):
doc = self._doc()
if doc:
Expand Down
24 changes: 11 additions & 13 deletions frescobaldi_app/documenticon.py
Expand Up @@ -24,49 +24,47 @@

import plugin
import signals
import jobmanager
import jobattributes
import job
import engrave
import icons


def icon(doc, mainwindow=None):
"""Provides a QIcon for a Document.
If a MainWindow is provided, the sticky icon can be returned, if the
Document is sticky for that main window.
"""
return DocumentIconProvider.instance(doc).icon(mainwindow)


class DocumentIconProvider(plugin.DocumentPlugin):
"""Provides an icon for a Document."""
iconChanged = signals.Signal()

def __init__(self, doc):
doc.modificationChanged.connect(self._send_icon)
mgr = jobmanager.manager(doc)
mgr = job.manager.manager(doc)
mgr.started.connect(self._send_icon)
mgr.finished.connect(self._send_icon)

def _send_icon(self):
self.iconChanged()

def icon(self, mainwindow=None):
doc = self.document()
job = jobmanager.job(doc)
if job and job.is_running() and not jobattributes.get(job).hidden:
j = job.manager.job(doc)
if j and j.is_running() and not job.attributes.get(j).hidden:
icon = 'lilypond-run'
elif mainwindow and doc is engrave.Engraver.instance(mainwindow).stickyDocument():
icon = 'pushpin'
elif doc.isModified():
icon = 'document-save'
elif job and not job.is_running() and not job.is_aborted() and job.success:
elif j and not j.is_running() and not j.is_aborted() and j.success:
icon = 'document-compile-success'
elif job and not job.is_running() and not job.is_aborted():
elif j and not j.is_running() and not j.is_aborted():
icon = 'document-compile-failed'
else:
icon = 'text-plain'
return icons.get(icon)

164 changes: 95 additions & 69 deletions frescobaldi_app/documentinfo.py
Expand Up @@ -30,6 +30,7 @@

from PyQt5.QtCore import QSettings, QUrl

import document
import qsettings
import ly.lex
import lydocinfo
Expand All @@ -40,44 +41,57 @@
import tokeniter
import plugin
import variables
import lilypondinfo


__all__ = ['docinfo', 'info', 'mode']


def info(document):
def info(doc):
"""Returns a DocumentInfo instance for the given Document."""
return DocumentInfo.instance(document)
return DocumentInfo.instance(doc)


def docinfo(document):
def docinfo(doc):
"""Return a LyDocInfo instance for the document."""
return info(document).lydocinfo()
return info(doc).lydocinfo()


def music(document):
def lilyinfo(doc):
"""Return a LilyPondInfo instance for the given document."""
return info(doc).lilypondinfo()


def music(doc):
"""Return a music.Document instance for the document."""
return info(document).music()
return info(doc).music()


def mode(document, guess=True):
def mode(doc, guess=True):
"""Returns the type of the given document. See DocumentInfo.mode()."""
return info(document).mode(guess)
return info(doc).mode(guess)


def defaultfilename(document):
def defaultfilename(doc):
"""Return a default filename that could be used for the document.
The name is based on the score's title, composer etc.
"""
i = info(document)
i = info(doc)
m = i.music()
import ly.music.items as mus

# which fields (in order) to harvest:
fields = ('composer', 'title')

s = QSettings()
custom = s.value("custom_default_filename", False, bool)
template = s.value("default_filename_template", "{composer}-{title}", str)
if custom:
# Retrieve all field names from the template string
fields = [m.group(1) for m in re.finditer(r'\{(.*?)\}', template)]
else:
fields = ('composer', 'title')

d = {}
for h in m.find(mus.Header):
for a in h.find(mus.Assignment):
Expand All @@ -91,34 +105,40 @@ def defaultfilename(document):
# make filenames
for k in d:
d[k] = re.sub(r'\W+', '-', d[k]).strip('-')

filename = '-'.join(d[k] for k in fields if k in d)

if custom:
for k in fields:
template = str.replace(template, '{' + k + '}', d.get(k, 'unknown'))
filename = template
else:
filename = '-'.join(d[k] for k in fields if k in d)
if not filename:
filename = document.documentName()
filename = doc.documentName()
ext = ly.lex.extensions[i.mode()]
return filename + ext


class DocumentInfo(plugin.DocumentPlugin):
"""Computes and caches various information about a Document."""
def __init__(self, document):
document.contentsChanged.connect(self._reset)
document.closed.connect(self._reset)
def __init__(self, doc):
if doc.__class__ == document.EditorDocument:
doc.contentsChanged.connect(self._reset)
doc.closed.connect(self._reset)
self._reset()

def _reset(self):
"""Called when the document is changed."""
self._lydocinfo = None
self._music = None

def lydocinfo(self):
"""Return the lydocinfo instance for our document."""
if self._lydocinfo is None:
doc = lydocument.Document(self.document())
v = variables.manager(self.document()).variables()
self._lydocinfo = lydocinfo.DocInfo(doc, v)
return self._lydocinfo

def music(self):
"""Return the music.Document instance for our document."""
if self._music is None:
Expand All @@ -127,40 +147,40 @@ def music(self):
self._music = music.Document(doc)
self._music.include_path = self.includepath()
return self._music

def mode(self, guess=True):
"""Returns the type of document ('lilypond, 'html', etc.).
The mode can be set using the "mode" document variable.
If guess is True (default), the mode is auto-recognized based on the contents
if not set explicitly using the "mode" variable. In this case, this function
always returns an existing mode.
If guess is False, auto-recognizing is not done and the function returns None
if the mode wasn't set explicitly.
"""
mode = variables.get(self.document(), "mode")
if mode in ly.lex.modes:
return mode
if guess:
return self.lydocinfo().mode()

def includepath(self):
"""Return the configured include path.
A path is a list of directories.
If there is a session specific include path, it is used.
Otherwise the path is taken from the LilyPond preferences.
Currently the document does not matter.
"""
# get the global include path
include_path = qsettings.get_string_list(
QSettings(), "lilypond_settings/include_path")

# get the session specific include path
import sessions
session_settings = sessions.currentSessionGroup()
Expand All @@ -170,74 +190,82 @@ def includepath(self):
include_path = sess_path
else:
include_path = sess_path + include_path

return include_path

def jobinfo(self, create=False):
"""Returns a two-tuple(filename, includepath).
The filename is the file LilyPond shall be run on. This can be the
original filename of the document (if it has a filename and is not
modified), but also the filename of a temporarily saved copy of the
The filename is the file LilyPond shall be run on. This can be the
original filename of the document (if it has a filename and is not
modified), but also the filename of a temporarily saved copy of the
document.
The includepath is the same as self.includepath(), but with the
directory of the original file prepended, only if a temporary
'scratchdir'-area is used and the document does include other files
(and therefore the original folder should be given in the include
The includepath is the same as self.includepath(), but with the
directory of the original file prepended, only if a temporary
'scratchdir'-area is used and the document does include other files
(and therefore the original folder should be given in the include
path to LilyPond).
"""
includepath = self.includepath()
filename = self.document().url().toLocalFile()

# Determine the filename to run the engraving job on
if not filename or self.document().isModified():
# We need to use a scratchdir to save our contents to
import scratchdir
scratch = scratchdir.scratchdir(self.document())
if create:
scratch.saveDocument()
if filename and self.lydocinfo().include_args():
if filename:
includepath.insert(0, os.path.dirname(filename))
if create or (scratch.path() and os.path.exists(scratch.path())):
filename = scratch.path()
return filename, includepath

def includefiles(self):
"""Returns a set of filenames that are included by this document.
The document's own filename is not added to the set.
The configured include path is used to find files.
Included files are checked recursively, relative to our file,
relative to the including file, and if that still yields no file, relative
to the directories in the includepath().
This method uses caching for both the document contents and the other files.
"""
return fileinfo.includefiles(self.lydocinfo(), self.includepath())

def lilypondinfo(self):
"""Returns a LilyPondInfo instance that should be used by default to engrave the document."""
version = self.lydocinfo().version()
if version and QSettings().value("lilypond_settings/autoversion", False, bool):
return lilypondinfo.suitable(version)
return lilypondinfo.preferred()


def child_urls(self):
"""Return a tuple of urls included by the Document.
This only returns urls that are referenced directly, not searching
via an include path. If the Document has no url set, an empty tuple
is returned.
is returned.
"""
url = self.document().url()
if url.isEmpty():
return ()
return tuple(url.resolved(QUrl(arg)) for arg in self.lydocinfo().include_args())

def basenames(self):
"""Returns a list of basenames that our document is expected to create.
The list is created based on include files and the define output-suffix and
\bookOutputName and \bookOutputSuffix commands.
You should add '.ext' and/or '-[0-9]+.ext' to find created files.
"""
# if the file defines an 'output' variable, it is used instead
output = variables.get(self.document(), 'output')
Expand All @@ -246,24 +274,22 @@ def basenames(self):
dirname = os.path.dirname(filename)
return [os.path.join(dirname, name.strip())
for name in output.split(',')]

mode = self.mode()

if mode == "lilypond":
return fileinfo.basenames(self.lydocinfo(), self.includefiles(), filename)

elif mode == "html":
pass

elif mode == "texinfo":
pass

elif mode == "latex":
pass

elif mode == "docbook":
pass

return []


return []
25 changes: 13 additions & 12 deletions frescobaldi_app/documentmenu.py
Expand Up @@ -30,31 +30,33 @@
import plugin
import engrave
import documenticon
import util


class DocumentMenu(QMenu):
def __init__(self, mainwindow):
super(DocumentMenu, self).__init__(mainwindow)
super().__init__(mainwindow)
self.aboutToShow.connect(self.populate)
app.translateUI(self)

self.setToolTipsVisible(True)

def translateUI(self):
self.setTitle(_('menu title', '&Documents'))

def populate(self):
self.clear()
mainwindow = self.parentWidget()
for a in DocumentActionGroup.instance(mainwindow).actions():
self.addAction(a)


class DocumentActionGroup(plugin.MainWindowPlugin, QActionGroup):
"""Maintains a list of actions to set the current document.
The actions are added to the View->Documents menu in the order
of the tabbar. The actions also get accelerators that are kept
during the lifetime of a document.
"""
def __init__(self, mainwindow):
QActionGroup.__init__(self, mainwindow)
Expand All @@ -72,7 +74,7 @@ def __init__(self, mainwindow):
mainwindow.currentDocumentChanged.connect(self.setCurrentDocument)
engrave.engraver(mainwindow).stickyChanged.connect(self.setDocumentStatus)
self.triggered.connect(self.slotTriggered)

def actions(self):
return [self._acts[doc] for doc in self.mainwindow().documents()]

Expand All @@ -83,12 +85,12 @@ def addDocument(self, doc):
a.setChecked(True)
self._acts[doc] = a
self.setDocumentStatus(doc)

def removeDocument(self, doc):
self._acts[doc].deleteLater()
del self._acts[doc]
del self._accels[doc]

def setCurrentDocument(self, doc):
self._acts[doc].setChecked(True)

Expand All @@ -108,15 +110,14 @@ def setDocumentStatus(self, doc):
# L10N: 'always engraved': the document is marked as 'Always Engrave' in the LilyPond menu
name += " " + _("[always engraved]")
self._acts[doc].setText(name)
self._acts[doc].setToolTip(util.path(doc.url()))
icon = documenticon.icon(doc, self.mainwindow())
if icon.name() == "text-plain":
icon = QIcon()
self._acts[doc].setIcon(icon)

def slotTriggered(self, action):
for doc, act in self._acts.items():
if act == action:
self.mainwindow().setCurrentDocument(doc)
break


78 changes: 61 additions & 17 deletions frescobaldi_app/documentstructure.py
Expand Up @@ -29,45 +29,62 @@
import app
import plugin

import lydocument
import ly.document

# default outline patterns that are ignored in comments
default_outline_patterns = [
r"(?P<title>\\(score|book|bookpart))\b",
r"^\\(paper|layout|header)\b",
r"\\(new|context)\s+[A-Z]\w+",
r"(?P<title>BEGIN[^\n]*)[ \t]*$",
r"^[a-zA-Z]+\s*=",
r"^<<",
r"^\{",
r"^\\relative([ \t]+\w+[',]*)?",
]

# default outline patterns that are matched also in comments
default_outline_patterns_comments = [
r"(?P<title>BEGIN[^\n]*)[ \t]*$",
r"\b(?P<alert>(FIXME|HACK|XXX+)\b\W*\w+)",
]


# cache the outline regexp
_outline_re = None
_outline_re_comments = None


def outline_re():
"""Return the expression to look for document outline items."""
global _outline_re
if _outline_re is None:
_outline_re = create_outline_re()
return _outline_re

def outline_re(comments):
"""Return the expression to look for document outline items.
If comments is True it is used to search in the whole document,
if it is False comments are excluded."""
v = '_outline_re'+('_comments' if comments else '')
if globals()[v] is None:
globals()[v] = create_outline_re(comments)
return globals()[v]

def _reset_outline_re():
global _outline_re
global _outline_re_comments
_outline_re = None
_outline_re_comments = None


app.settingsChanged.connect(_reset_outline_re, -999)


def create_outline_re():
"""Create and return the expression to look for document outline items."""
def create_outline_re(comments):
"""Create and return the expression to look for document outline items.
If comments is True it is used to search in the whole document,
if it is False comments are excluded."""
try:
rx = QSettings().value("documentstructure/outline_patterns",
default_outline_patterns, str)
if comments:
rx = QSettings().value("documentstructure/outline_patterns_comments",
default_outline_patterns_comments, str)
else:
rx = QSettings().value("documentstructure/outline_patterns",
default_outline_patterns, str)
except TypeError:
rx = []
# suffix duplicate named groups with a number
Expand All @@ -83,7 +100,7 @@ def create_outline_re():
if name in groups:
groups[name] += 1
new_name = name + format(groups[name])
e = e.replace("(?P<{0}>".format(name), "(?P<{0}>".format(new_name))
e = e.replace(f"(?P<{name}>", f"(?P<{new_name}>")
else:
groups[name] = 0
new_rx.append(e)
Expand All @@ -94,20 +111,47 @@ def create_outline_re():
class DocumentStructure(plugin.DocumentPlugin):
def __init__(self, document):
self._outline = None

def invalidate(self):
"""Called when the document changes or the settings are changed."""
self._outline = None
app.settingsChanged.disconnect(self.invalidate)
self.document().contentsChanged.disconnect(self.invalidate)

def outline(self):
"""Return the document outline as a series of match objects."""
if self._outline is None:
self._outline = list(outline_re().finditer(self.document().toPlainText()))
# match patterns excluding comments
active_code = self.remove_comments()
outline_list = list(outline_re(False).finditer(active_code))
# match patterns including comments
outline_list_comments = list(outline_re(True).finditer(self.document().toPlainText()))
# merge lists and sort by start position
self._outline = outline_list + outline_list_comments
self._outline.sort(key=lambda match: match.start())
self.document().contentsChanged.connect(self.invalidate)
app.settingsChanged.connect(self.invalidate, -999)
return self._outline


def remove_comments(self):
"""Remove Lilypond comments from text"""
def whiteout_section(cursor, start, end):
spaces = ''.join(' ' for x in range(start, end))
with cursor.document as doc:
doc[start:end] = spaces

doc = ly.document.Document(self.document().toPlainText())
cursor = lydocument.Cursor(doc)
source = ly.document.Source(cursor, True, tokens_with_position=True)
start = 0
for token in source:
if isinstance(token, ly.lex.BlockCommentStart):
start = token.pos
elif isinstance(token, ly.lex.BlockCommentEnd):
if start:
whiteout_section(cursor, start, token.end)
start = 0
elif isinstance(token, ly.lex.Comment):
whiteout_section(cursor, token.pos, token.end)
return cursor.document.plaintext()

30 changes: 15 additions & 15 deletions frescobaldi_app/documenttooltip.py
Expand Up @@ -41,12 +41,12 @@

def pixmap(cursor, num_lines=6, scale=0.8):
"""Return a QPixmap displaying the selected lines of the document.
If the cursor has no selection, num_lines are drawn.
By default the text is drawn 0.8 * the normal font size. You can change
that by supplying the scale parameter.
"""
block = cursor.document().findBlock(cursor.selectionStart())
c2 = QTextCursor(block)
Expand All @@ -55,7 +55,7 @@ def pixmap(cursor, num_lines=6, scale=0.8):
c2.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor)
else:
c2.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor, num_lines)

data = textformats.formatData('editor')
doc = QTextDocument()
font = QFont(data.font)
Expand All @@ -73,33 +73,33 @@ def pixmap(cursor, num_lines=6, scale=0.8):

def show(cursor, pos=None, num_lines=6):
"""Displays a tooltip showing part of the cursor's Document.
If the cursor has a selection, those blocks are displayed.
Otherwise, num_lines lines are displayed.
If pos is not given, the global mouse position is used.
"""
pix = pixmap(cursor, num_lines)
label = QLabel()
label.setPixmap(pix)
label.setStyleSheet("QLabel { border: 1px solid #777; }")
label.resize(pix.size())
widgets.customtooltip.show(label, pos)
gadgets.customtooltip.show(label, pos)


def text(cursor):
"""Return basic tooltip text displaying filename, line and column information.
If the position is inside a music expression, the name of the variable the
expression is assigned to is also appended on a new line. If the time
position can be determined it is appended on a third line.
"""
filename = cursor.document().documentName()
line = cursor.blockNumber() + 1
column = cursor.position() - cursor.block().position()
text = "{0} ({1}:{2})".format(filename, line, column)
text = f"{filename} ({line}:{column})"
definition = get_definition(cursor)
if definition:
text += '\n' + definition
Expand All @@ -111,10 +111,10 @@ def text(cursor):

def get_definition(cursor):
"""Return the variable name the cursor's music expression is assigned to.
If the music is in a \\score instead, "\\score" is returned.
Returns None if no variable name can be found.
"""
block = cursor.block()
while block.isValid():
Expand All @@ -130,9 +130,9 @@ def get_definition(cursor):

def time_position(cursor):
"""Returns the time position of the music the cursor points at.
Format the value as "5/1" etc.
"""
import documentinfo
pos = documentinfo.music(cursor.document()).time_position(cursor.position())
Expand Down
10 changes: 5 additions & 5 deletions frescobaldi_app/documenttree.py
Expand Up @@ -44,19 +44,19 @@ class DocumentNode(ly.node.Node):

def tree(urls=False):
"""Return the open documents as a tree structure.
Returned is a ly.node.Node instance having the toplevel documents (documents
that are not included by other open documents) as children. The children of
the nodes are the documents that are included by the toplevel document.
Every node has the Document in its document attribute.
If urls == True, nodes will also be generated for urls that refer to
documents that are not yet open. They will have the QUrl in their url
attribute.
It is not checked whether the referred to urls or files actually exist.
"""
root = ly.node.Node()
nodes = {}
Expand Down
10 changes: 5 additions & 5 deletions frescobaldi_app/documentwatcher.py
Expand Up @@ -54,11 +54,11 @@ class DocumentWatcher(plugin.DocumentPlugin):
"""Maintains if a change was detected for a document."""
def __init__(self, d):
self.changed = False

def isdeleted(self):
"""Return True if some change has occurred, the document has a local
filename, but the file is not existing on disk.
"""
if self.changed:
filename = self.document().url().toLocalFile()
Expand All @@ -80,7 +80,7 @@ def removeUrl(url):
if filename:
watcher.removePath(filename)


def unchange(document):
"""Mark document as not changed (anymore)."""
DocumentWatcher.instance(document).changed = False
Expand All @@ -95,7 +95,7 @@ def documentUrlChanged(document, url, old):
removeUrl(old)
addUrl(url)


def documentClosed(document):
"""Called whenever a document closes."""
for d in app.documents:
Expand All @@ -118,7 +118,7 @@ def whileSaving(document):
finally:
addUrl(document.url())


def fileChanged(filename):
"""Called whenever the global filesystem watcher detects a change."""
url = QUrl.fromLocalFile(filename)
Expand Down