-
Notifications
You must be signed in to change notification settings - Fork 0
/
__init__.py
584 lines (508 loc) · 24.2 KB
/
__init__.py
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
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
# **********************************************************
# __init__.py - The HTML, Markdown, and reST preview package
# **********************************************************
# The Preview plugin provides an HTML-based rendering of the
# file currently being edited. This file implements the
# Plugin interface; other modules are given below.
"""Preview plugin for HTML based documents, like HTML, Markdown, reStructuredText, Sphinx and CodeChat"""
__pluginname__ = "Preview"
__author__ = "Marco Laspe"
__credits__ = ["Andrei Kopats", "Bryan A. Jones"]
__license__ = "GPL3"
__version__ = "0.1.0"
__maintainer__ = "Marco Laspe"
__email__ = "marco@rockiger.com"
__status__ = "Beta"
# This plugin is a copy of the Enki preview plugin
# https://github.com/andreikop/enki/tree/master/enki/plugins/preview
#
# Imports
# =======
# These are listed in the order prescribed by `PEP 8
# <http://www.python.org/dev/peps/pep-0008/#imports>`_.
#
# Library imports
# ---------------
import sys
import os.path
import fnmatch
import types
#
# Third-party imports
# -------------------
from PyQt5.QtCore import QObject, Qt
from PyQt5.QtWidgets import QAction, QWidget, QFileDialog, QLabel
from PyQt5.QtGui import QIcon, QKeySequence, QPalette
from PyQt5 import uic
import sip
#
# Local imports
# -------------
from enki.core.core import core
from enki.core.uisettings import CheckableOption, TextOption, ChoiseOption
from enki.lib.get_console_output import get_console_output
from enki.widgets.dockwidget import DockWidget
# Constants
PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
# Import CodeChat if possible; otherwise, indicate it wasn't available.
try:
from CodeChat import CodeToRest
from CodeChat.CommentDelimiterInfo import SUPPORTED_GLOBS
except ImportError:
CodeToRest = None
# Utilities
# =========
def isHtmlFile(document):
"""Return True if document refers to an HTML file; return False otherwise.
"""
return (document is not None and
document.qutepart.language() is not None and
'html' in document.qutepart.language().lower() and
# Return True if this is a Django HTML template;
# return False for HTML (PHP).
(not 'php' in document.qutepart.language().lower()))
def canUseCodeChat(filePath):
"""Return True if CodeChat can be used with ``filePath``; return False
otherwise.
"""
# CodeChat can preview a file if it's enabled and if that file's
# name/extension is supported.
if (CodeToRest is not None and core.config()['CodeChat']['Enabled']
and filePath):
filename = os.path.basename(filePath)
for glob in SUPPORTED_GLOBS:
if fnmatch.fnmatch(filename, glob):
return True
return False
def sphinxEnabledForFile(filePath):
"""Based on Sphinx settings under core.config()['Sphinx'], this function
determines whether Sphinx can be applied to filePath. It can't know if
Sphinx actually processes the file or not, since this is based on conf.py
settings.
"""
sphinxProjectPath = core.config()['Sphinx']['ProjectPath']
return (filePath and
core.config()['Sphinx']['Enabled'] and
os.path.exists(core.config()['Sphinx']['ProjectPath']) and
os.path.normcase(sphinxProjectPath) ==
commonPrefix(filePath, sphinxProjectPath))
def commonPrefix(*dirs):
"""This function provides a platform-independent path commonPrefix. It
returns the common path between all directories in input list dirs, assuming
that any relative paths are rooted in the current directory. While `this post
<http://stackoverflow.com/questions/21498939/how-to-circumvent-the-fallacy-of-pythons-os-path-commonprefix>`_
has two solutions, neither are correct; hence, the following code.
Parameters: dirs - Directory list.
Return value: the common path prefix shared by all input directories.
"""
# corner case (empty input list)
if not dirs:
return ''
# Path cleaning toolset:
#
# - **realpath** follows symbolic links, so they will be compared in
# terms of what they refer to. realpath will also evaluate directory
# traversals.
#
# #. get Canonical path.
# #. eliminate symbolic links.
#
# - **normcase** makes Windows filenames all lowercase, since the
# following code uses case-sensitive string comparions. Windows
# uses case-insensitive naming for its files.
#
# #. converts path to lower case for case-insensitive filesystem.
# #. converts forward slashes to backward slashes (Windows only)
# - **abspath** collapses and evaluates directory traversals like
# ``./../subdir``, to correctly compare absolute and relative paths,
# and normalizes the os.path.sep for the current platform
# (i.e. no `\a/b` paths). Similar to ``normpath(join(os.getcwd(),
# path))``.
fullPathList = [os.path.normcase(os.path.abspath(os.path.realpath(d)))
for d in dirs]
# Now use ``commonprefix`` on absolute paths.
prefix = os.path.commonprefix(fullPathList)
# commonprefix stops at the first dissimilar character, leaving an
# incomplete path name. For example, ``commonprefix(('aa', 'ab')) == 'a'``.
# Fix this by removing this ending incomplete path if necessary.
for d in fullPathList:
# ``commonPrefix`` contains a complete path if the character in
# ``d`` after its end is an os.path.sep or the end of the path name.
if len(d) > len(prefix) and d[len(prefix)] != os.path.sep:
# This is an incomplete path. Remove it.
prefix = os.path.dirname(prefix)
break
# If any input directory is absolute path, then commonPrefix will return
# an absolute path.
if any((os.path.isabs(d)) for d in dirs):
return prefix
# If not, we will use the assumption that all relative paths
# are rooted in the current directory. Test whether ``prefix`` starts with
# the current working directory. If not, return an absolute path.
cwd = os.path.normcase(os.getcwd())
return prefix if not prefix.startswith(cwd) \
else prefix[len(cwd) + len(os.path.sep):]
def _getSphinxVersion(path):
"""Return the Sphinx version as a list of integer items.
Raise OSError if not found, or
ValueError if failed to parse.
"""
stdout, stderr = get_console_output([path, "--version"])
# Command "Sphinx-build --version" will only output sphinx version info.
# Typical output looks like: ``Sphinx (sphinx-build) 1.2.3`` or
# ``Sphinx v1.2.3``
# But the problem is sometimes version info goes to stdout(version 1.2.3),
# while sometimes it goes to stderr(version 1.1.3). Thus combining stdout
# and stderr is necessary.
out = stdout + '\n' + stderr
for line in out.split('\n'):
if line.startswith("Sphinx"):
# Split on space, take the last segment, if it starts with character
# 'v', strip the 'v'. Then split on dot. returning the version as a
# tuple.
version = line.split(' ')[-1]
version = version[1:] if version.startswith('v') else version
return [int(num) for num in version.split('.')]
raise ValueError
# GUIs
# ====
# This class implements the GUI for a combined CodeChat settings page.
class CodeChatSettingsWidget(QWidget):
"""Insert the preview plugin as a page of the UISettings dialog.
"""
def __init__(self, settingsPage, dialog):
# Initialize the dialog, loading in the literate programming settings
# GUI.
QWidget.__init__(self, dialog)
uic.loadUi(os.path.join(os.path.dirname(__file__),
'CodeChat_Settings.ui'), self)
if CodeToRest is None:
# If the CodeChat module can't be loaded, then disable the
# associated checkbox and show the "not installed" message.
self.cbCodeChat.setEnabled(False)
self.labelCodeChatNotInstalled.setVisible(True)
self.labelCodeChatNotInstalled.setEnabled(True)
else:
# Hide the "not installed" message.
self.labelCodeChatNotInstalled.setVisible(False)
# Add this GUI to the settings dialog box.
settingsPage.addWidget(QLabel("<h3>Literate programming</h3>"))
settingsPage.addWidget(self)
# Next, have the setting UI auto-update the corresponding CodeChat and
# config entries.
dialog.appendOption(CheckableOption(dialog, core.config(),
"CodeChat/Enabled",
self.cbCodeChat))
# This class implements the GUI for a combined CodeChat / Sphinx settings page.
class SphinxSettingsWidget(QWidget):
"""Insert the preview plugin as a page of the UISettings dialog.
"""
def __init__(self, settingsPage, dialog):
# Initialize the dialog, loading in the literate programming settings
# GUI.
QWidget.__init__(self, dialog)
uic.loadUi(os.path.join(os.path.dirname(__file__),
'Sphinx_Settings.ui'), self)
# Make links gray when they are disabled
palette = self.palette()
palette.setColor(QPalette.Disabled,
QPalette.Link,
palette.color(QPalette.Disabled, QPalette.Text))
self.lbSphinxReference.setPalette(palette)
palette = self.palette()
palette.setColor(QPalette.Active,
QPalette.WindowText,
palette.color(QPalette.Normal, QPalette.Link))
self.lbSphinxEnableAdvMode.setPalette(palette)
# Clicking on advanced mode label triggers either advanced mode or
# normal mode.
self.lbSphinxEnableAdvMode.mousePressEvent = self.on_ToggleSphinxSettingModeClicked
# Update misc pieces of the GUI that can't be stored in the .ui file.
self._updateSphinxSettingMode()
# Add this GUI to the settings dialog box.
settingsPage.addWidget(QLabel("<h3>Sphinx</h3>"))
settingsPage.addWidget(self)
# Next, have the setting UI auto-update the corresponding CodeChat and
# config entries.
dialog.appendOption(CheckableOption(dialog, core.config(),
"Sphinx/Enabled",
self.gbSphinxProject))
dialog.appendOption(ChoiseOption(dialog, core.config(),
"Sphinx/BuildOnSave",
{self.rbBuildOnlyOnSave: True,
self.rbBuildOnFileChange: False}))
dialog.appendOption(TextOption(dialog, core.config(),
"Sphinx/ProjectPath",
self.leSphinxProjectPath))
dialog.appendOption(TextOption(dialog, core.config(),
"Sphinx/SourcePath",
self.leSphinxSourcePath))
dialog.appendOption(TextOption(dialog, core.config(),
"Sphinx/OutputPath",
self.leSphinxOutputPath))
dialog.appendOption(TextOption(dialog, core.config(),
"Sphinx/Executable",
self.leSphinxExecutable))
dialog.appendOption(TextOption(dialog, core.config(),
"Sphinx/Cmdline",
self.leSphinxCmdline))
# Run this after the appendOption calls, since these fields must be set
# up before _updateleValidateSphinxExecutable can run.
self._updateleValidateSphinxExecutable()
def _updateleValidateSphinxExecutable(self):
""" Check if Sphinx is installed. Sphinx version is not important
Update leValidateSphinxExecutable based on Sphinx status.
"""
path = self.leSphinxExecutable.text()
try:
_getSphinxVersion(path)
except OSError as ex:
self.leValidateSphinxExecutable.setText('Failed to execute {}: {}'.format(path, ex))
except ValueError:
self.leValidateSphinxExecutable.setText('Failed to parse {} version. Does sphinx work?'.format(path))
else:
self.leValidateSphinxExecutable.setText('Sphinx is found!')
def on_pbSphinxProjectPath_clicked(self):
"""Provide a directory chooser for the user to select a project path.
"""
path = QFileDialog.getExistingDirectory(core.mainWindow(),
'Project path', self.leSphinxProjectPath.text())
if path:
self.leSphinxProjectPath.setText(path)
# Automatically set the builder output path to '_build\\html' under
# builder root path.
#
# Since relative paths are supported, we will only set
# leSphinxOutputPath if the path was none or was absolute (and
# therefore presumabely wrong). If it's a relative path such as
# ``_build\html``, then it's probably OK without changing.
if (not self.leSphinxOutputPath.text()
or os.path.isabs(self.leSphinxOutputPath.text())):
self.leSphinxOutputPath.setText(os.path.join(path, '_build',
'html'))
def on_tbSphinxSourcePath_clicked(self):
"""Provide a directory chooser for the user to select a source path.
"""
path = QFileDialog.getExistingDirectory(core.mainWindow(), 'Source path', self.leSphinxSourcePath.getText())
if path:
self.leSphinxSourcePath.setText(path)
def on_pbSphinxOutputPath_clicked(self):
"""Provide a directory chooser for the user to select an output path.
"""
path = QFileDialog.getExistingDirectory(core.mainWindow(), 'Output path', self.leSphinxOutputPath.getText())
if path:
self.leSphinxOutputPath.setText(path)
# The Sphinx executable can be selected by the user. A filter is needed
# such that non-executable files will not be selected by the user.
def on_pbSphinxExecutable_clicked(self):
fltr = "sphinx-build" + (".exe" if sys.platform.startswith("win") else "") \
+ ";; All files (*)"
path, _ = QFileDialog.getOpenFileName(self,
"Select Sphinx executable",
filter=fltr)
if path:
self.leSphinxExecutable.setText(path)
self._updateleValidateSphinxExecutable()
def on_ToggleSphinxSettingModeClicked(self, *args):
core.config()['Sphinx']['AdvancedMode'] = not core.config()['Sphinx']['AdvancedMode']
core.config().flush()
self._updateSphinxSettingMode()
# The project path and Sphinx executable directory must be absolute;
# the source and output paths may be relative to the project path or absolute.
# Use abspath or normpath as appropriate to guarantee this is true.
def on_leSphinxProjectPath_editingFinished(self):
self.leSphinxProjectPath.setText(os.path.abspath(self.leSphinxProjectPath.text()))
def on_leSphinxOutputPath_editingFinished(self):
self.leSphinxOutputPath.setText(os.path.normpath(self.leSphinxOutputPath.text()))
def on_leSphinxExecutable_editingFinished(self):
self._updateleValidateSphinxExecutable()
def _updateSphinxSettingMode(self):
"""Update the Sphinx settings mode by hiding/revealing the appropriate
controls.
"""
if core.config()['Sphinx']['AdvancedMode']:
# Switch to advanced setting mode:
# hide all path setting line edit boxes and buttons.
self.gbSphinxExecutable.setVisible(False)
# Enable advanced setting mode items
self.lbSphinxEnableAdvMode.setText('<html><head/><body><p>' +
'<span style="text-decoration: underline;">Switch to Normal Mode' +
'</span></p></body></html>')
self.lbSphinxCmdline.setVisible(True)
self.leSphinxCmdline.setVisible(True)
self.lbSphinxReference.setVisible(True)
else:
# Reenable all path setting line edit boxes and buttons
self.gbSphinxExecutable.setVisible(True)
# Hide all advanced mode entries.
self.lbSphinxEnableAdvMode.setText('<html><head/><body><p>' +
'<span style="text-decoration: underline;">Switch to Advanced Mode' +
'</span></p></body></html>')
self.lbSphinxCmdline.setVisible(False)
self.leSphinxCmdline.setVisible(False)
self.lbSphinxReference.setVisible(False)
class NoWebkitDock(DockWidget):
def __init__(self):
DockWidget.__init__(self, core.mainWindow(), "Previe&w", QIcon.fromTheme('applications-internet'), "Alt+W")
self._widget = QLabel("Qt5 WebEngine not found. Preview is not available.<br/>"
"Run <tt>pip install PyQt5</tt>.")
self.setFocusProxy(self._widget)
self.setWidget(self._widget)
def terminate(self):
pass
try:
# See if this supports the ``scrollPosition`` method introduced in Qt 5.7.
from PyQt5.QtWebEngineWidgets import QWebEnginePage
assert type(QWebEnginePage.scrollPosition) == types.BuiltinMethodType
haveWebEngine = True
except (ImportError, AssertionError):
haveWebEngine = False
# Plugin
# ======
# This class integrates the preview dock into Enki. Specifically, it:
#
# #. Adds the GUI defined above to the Settings dialog box
class Plugin(QObject):
"""Plugin interface implementation.
"""
def __init__(self):
"""Create and install the plugin
"""
QObject.__init__(self)
self._dock = None
self._saveAction = None
self._dockInstalled = False
core.workspace().currentDocumentChanged.connect(self._onDocumentChanged)
core.workspace().languageChanged.connect(self._onDocumentChanged)
# Install our CodeChat page into the settings dialog.
core.uiSettingsManager().aboutToExecute.connect(self._onSettingsDialogAboutToExecute)
# Update preview dock when the settings dialog (which contains the
# CodeChat enable checkbox) is changed.
core.uiSettingsManager().dialogAccepted.connect(self._onDocumentChanged)
# Provide a "Set Sphinx Path" menu item.
core.uiSettingsManager().dialogAccepted.connect(self._setSphinxActionVisibility)
self._sphinxAction = QAction('Set Sphinx path', self._dock)
self._sphinxAction.setShortcut(QKeySequence('Alt+Shift+S'))
core.actionManager().addAction('mPlugins/aSetSphinxPath',
self._sphinxAction)
self._sphinxAction.triggered.connect(self.onSphinxPath)
# Only enable this command if the File browser has a valid path.
self.onFileBrowserPathChanged()
core.project().changed.connect(self.onFileBrowserPathChanged)
# If user's config .json file lacks it, populate CodeChat's default
# config keys and Sphinx's default config keys.
c = core.config()
c.setdefault('CodeChat', {})
c.setdefault('CodeChat/Enabled', False)
c.setdefault('Sphinx', {})
c.setdefault('Sphinx/Enabled', False)
c.setdefault('Sphinx/Executable', 'sphinx-build')
c.setdefault('Sphinx/ProjectPath', '')
c.setdefault('Sphinx/SourcePath', '.')
c.setdefault('Sphinx/BuildOnSave', False)
c.setdefault('Sphinx/OutputPath', os.path.join('_build',
'html'))
c.setdefault('Sphinx/AdvancedMode', False)
c.setdefault('Sphinx/Cmdline', ('sphinx-build -d ' +
os.path.join('_build', 'doctrees') + ' . ' +
os.path.join('_build', 'html')))
core.config().flush()
self._setSphinxActionVisibility()
def terminate(self):
"""Uninstall the plugin
"""
core.actionManager().removeAction('mPlugins/aSetSphinxPath')
if self._dockInstalled:
self._removeDock()
if self._dock is not None:
self._dock.terminate()
sip.delete(self)
def _onDocumentChanged(self):
"""Document or Language changed.
Create dock, if necessary
"""
if self._canPreview(core.workspace().currentDocument()):
if not self._dockInstalled:
self._createDock()
else:
if self._dockInstalled:
self._removeDock()
def _canPreview(self, document):
"""Check if the given document can be shown in the Preview dock.
"""
if document is None:
return False
if document.qutepart.language() in ('Markdown', 'reStructuredText') or \
isHtmlFile(document):
return True
if canUseCodeChat(document.filePath()):
return True
if sphinxEnabledForFile(document.filePath()):
return True
return False
def _createDock(self):
"""Install dock
"""
# create dock
if self._dock is None:
if haveWebEngine:
from .preview import PreviewDock
self._dock = PreviewDock()
self._saveAction = QAction(QIcon(':enkiicons/save.png'),
'Save Preview as HTML', self._dock)
self._saveAction.setShortcut(QKeySequence("Alt+Shift+P"))
self._saveAction.triggered.connect(self._dock.onPreviewSave)
else:
self._dock = NoWebkitDock()
if haveWebEngine:
core.actionManager().addAction("mFile/aSavePreview", self._saveAction)
self._dock.closed.connect(self._onDockClosed)
self._dock.shown.connect(self._onDockShown)
core.mainWindow().addDockWidget(Qt.RightDockWidgetArea, self._dock)
core.actionManager().addAction("mView/aPreview",
self._dock.showAction())
self._dockInstalled = True
if core.config()['Preview']['Enabled']:
self._dock.show()
def _onDockClosed(self):
"""Dock has been closed by user. Change Enabled option
"""
if core.config()['Preview']['Enabled']:
core.config()['Preview']['Enabled'] = False
core.config().flush()
def _onDockShown(self):
"""Dock has been shown by user. Change Enabled option
"""
if not core.config()['Preview']['Enabled']:
core.config()['Preview']['Enabled'] = True
core.config().flush()
def _removeDock(self):
"""Remove dock from GUI
"""
if haveWebEngine:
core.actionManager().removeAction("mFile/aSavePreview")
core.actionManager().removeAction("mView/aPreview")
core.mainWindow().removeDockWidget(self._dock)
self._dockInstalled = False
def _onSettingsDialogAboutToExecute(self, dialog):
"""The UI settings dialog is about to execute. Install preview-related
settings."""
settingsPage = dialog.createSettingsPage("Preview", dialog)
iconPath = os.path.join(PLUGIN_DIR, 'view-preview.svg')
dialog.appendPage("Preview", settingsPage,
QIcon.fromTheme("view-preview", QIcon(iconPath)))
CodeChatSettingsWidget(settingsPage, dialog)
SphinxSettingsWidget(settingsPage, dialog)
def _setSphinxActionVisibility(self):
self._sphinxAction.setVisible(core.config()['Sphinx']['Enabled'])
def onFileBrowserPathChanged(self):
"""Enable the onSphinxPath command only when there's a valid project
path."""
self._sphinxAction.setEnabled(bool(core.project().path()))
def onSphinxPath(self):
"""Set the Sphinx path to the current project path."""
assert core.project().path()
core.config()['Sphinx']['ProjectPath'] = core.project().path()
core.config().flush()
if core.config()['Preview']['Enabled'] and self._dock is not None:
self._dock._scheduleDocumentProcessing()