1 /*
2  *  SPDX-FileCopyrightText: 2017 Friedrich W. H. Kossebau <kossebau@kde.org>
3  *
4  *  SPDX-License-Identifier: LGPL-2.0-or-later
5  */
6 
7 #include "previewwidget.h"
8 
9 #include "kpartview.h"
10 #include "ktexteditorpreviewplugin.h"
11 #include <ktexteditorpreview_debug.h>
12 
13 // KF
14 #include <KAboutPluginDialog>
15 #include <KConfigGroup>
16 #include <KGuiItem>
17 #include <KLocalizedString>
18 #include <KParts/PartLoader>
19 #include <KParts/ReadOnlyPart>
20 #include <KPluginMetaData>
21 #include <KService>
22 #include <KSharedConfig>
23 #include <KTextEditor/Document>
24 #include <KTextEditor/MainWindow>
25 #include <KTextEditor/View>
26 #include <KToggleAction>
27 #include <KXMLGUIFactory>
28 
29 // Qt
30 #include <QAction>
31 #include <QDomElement>
32 #include <QIcon>
33 #include <QLabel>
34 #include <QMenu>
35 #include <QToolButton>
36 #include <QWidgetAction>
37 
38 using namespace KTextEditorPreview;
39 
PreviewWidget(KTextEditorPreviewPlugin * core,KTextEditor::MainWindow * mainWindow,QWidget * parent)40 PreviewWidget::PreviewWidget(KTextEditorPreviewPlugin *core, KTextEditor::MainWindow *mainWindow, QWidget *parent)
41     : QStackedWidget(parent)
42     , KXMLGUIBuilder(this)
43     , m_core(core)
44     , m_mainWindow(mainWindow)
45     , m_xmlGuiFactory(new KXMLGUIFactory(this, this))
46 {
47     m_lockAction = new KToggleAction(QIcon::fromTheme(QStringLiteral("object-unlocked")), i18n("Lock Current Document"), this);
48     m_lockAction->setToolTip(i18n("Lock preview to current document"));
49     m_lockAction->setCheckedState(KGuiItem(i18n("Unlock Current View"), QIcon::fromTheme(QStringLiteral("object-locked")), i18n("Unlock current view")));
50     m_lockAction->setChecked(false);
51     connect(m_lockAction, &QAction::triggered, this, &PreviewWidget::toggleDocumentLocking);
52     addAction(m_lockAction);
53 
54     // TODO: better icon(s)
55     const QIcon autoUpdateIcon = QIcon::fromTheme(QStringLiteral("media-playback-start"));
56     m_autoUpdateAction = new KToggleAction(autoUpdateIcon, i18n("Automatically Update Preview"), this);
57     m_autoUpdateAction->setToolTip(i18n("Enable automatic updates of the preview to the current document content"));
58     m_autoUpdateAction->setCheckedState(KGuiItem(i18n("Manually Update Preview"), //
59                                                  autoUpdateIcon,
60                                                  i18n("Disable automatic updates of the preview to the current document content")));
61     m_autoUpdateAction->setChecked(false);
62     connect(m_autoUpdateAction, &QAction::triggered, this, &PreviewWidget::toggleAutoUpdating);
63     addAction(m_autoUpdateAction);
64 
65     m_updateAction = new QAction(QIcon::fromTheme(QStringLiteral("view-refresh")), i18n("Update Preview"), this);
66     m_updateAction->setToolTip(i18n("Update the preview to the current document content"));
67     connect(m_updateAction, &QAction::triggered, this, &PreviewWidget::updatePreview);
68     m_updateAction->setEnabled(false);
69     addAction(m_updateAction);
70 
71     // manually prepare a proper dropdown menu button, because Qt itself does not do what one would expect
72     // when adding a default menu->menuAction() to a QToolbar
73     const auto kPartMenuIcon = QIcon::fromTheme(QStringLiteral("application-menu"));
74     const auto kPartMenuText = i18n("View");
75 
76     // m_kPartMenu may not be a child of this, because otherwise its XMLGUI-menu is deleted when switching views
77     // and therefore closing the tool view, which is a QMainWindow in KDevelop (IdealController::addView).
78     // see KXMLGUIBuilder::createContainer => tagName == d->tagMenu
79     m_kPartMenu = new QMenu;
80 
81     QToolButton *toolButton = new QToolButton();
82     toolButton->setMenu(m_kPartMenu);
83     toolButton->setIcon(kPartMenuIcon);
84     toolButton->setText(kPartMenuText);
85     toolButton->setPopupMode(QToolButton::InstantPopup);
86 
87     m_kPartMenuAction = new QWidgetAction(this);
88     m_kPartMenuAction->setIcon(kPartMenuIcon);
89     m_kPartMenuAction->setText(kPartMenuText);
90     m_kPartMenuAction->setMenu(m_kPartMenu);
91     m_kPartMenuAction->setDefaultWidget(toolButton);
92     m_kPartMenuAction->setEnabled(false);
93     addAction(m_kPartMenuAction);
94 
95     m_aboutKPartAction = new QAction(this);
96     connect(m_aboutKPartAction, &QAction::triggered, this, &PreviewWidget::showAboutKPartPlugin);
97     m_aboutKPartAction->setEnabled(false);
98 
99     auto label = new QLabel(i18n("No preview available."), this);
100     label->setAlignment(Qt::AlignHCenter);
101     addWidget(label);
102 
103     connect(m_mainWindow, &KTextEditor::MainWindow::viewChanged, this, &PreviewWidget::setTextEditorView);
104 
105     setTextEditorView(m_mainWindow->activeView());
106 }
107 
~PreviewWidget()108 PreviewWidget::~PreviewWidget()
109 {
110     delete m_kPartMenu;
111 }
112 
readSessionConfig(const KConfigGroup & configGroup)113 void PreviewWidget::readSessionConfig(const KConfigGroup &configGroup)
114 {
115     // TODO: also store document id/url and see to catch the same document on restoring config
116     m_lockAction->setChecked(configGroup.readEntry("documentLocked", false));
117     m_autoUpdateAction->setChecked(configGroup.readEntry("automaticUpdate", false));
118 }
119 
writeSessionConfig(KConfigGroup & configGroup) const120 void PreviewWidget::writeSessionConfig(KConfigGroup &configGroup) const
121 {
122     configGroup.writeEntry("documentLocked", m_lockAction->isChecked());
123     configGroup.writeEntry("automaticUpdate", m_autoUpdateAction->isChecked());
124 }
125 
setTextEditorView(KTextEditor::View * view)126 void PreviewWidget::setTextEditorView(KTextEditor::View *view)
127 {
128     if ((view && view == m_previewedTextEditorView && view->document() == m_previewedTextEditorDocument
129          && (!m_previewedTextEditorDocument || m_previewedTextEditorDocument->mode() == m_currentMode))
130         || !view || !isVisible() || m_lockAction->isChecked()) {
131         return;
132     }
133 
134     m_previewedTextEditorView = view;
135     m_previewedTextEditorDocument = view ? view->document() : nullptr;
136 
137     resetTextEditorView(m_previewedTextEditorDocument);
138 }
139 
findPreviewPart(const QStringList mimeTypes)140 std::optional<KPluginMetaData> KTextEditorPreview::PreviewWidget::findPreviewPart(const QStringList mimeTypes)
141 {
142     for (const auto &mimeType : qAsConst(mimeTypes)) {
143         const auto offers = KParts::PartLoader::partsForMimeType(mimeType);
144 
145         if (offers.isEmpty()) {
146             continue;
147         }
148 
149         const KPluginMetaData service = offers.first();
150         qCDebug(KTEPREVIEW) << "Found preferred kpart named" << service.name() << "with library" << service.fileName() << "for mimetype" << mimeType;
151 
152         // no interest in kparts which also just display the text (like katepart itself)
153         // TODO: what about parts which also support importing plain text and turning into richer format
154         // and thus have it in their mimetypes list?
155         // could that perhaps be solved by introducing the concept of "native" and "imported" mimetypes?
156         // or making a distinction between source editors/viewers and final editors/viewers?
157         // latter would also help other source editors/viewers like a hexeditor, which "supports" any mimetype
158         if (service.mimeTypes().contains(QLatin1String("text/plain"))) {
159             qCDebug(KTEPREVIEW) << "Blindly discarding preferred kpart as it also supports text/plain, to avoid useless plain/text preview.";
160             continue;
161         }
162 
163         return service;
164     }
165     return {};
166 }
167 
resetTextEditorView(KTextEditor::Document * document)168 void PreviewWidget::resetTextEditorView(KTextEditor::Document *document)
169 {
170     if (!isVisible() || m_previewedTextEditorDocument != document) {
171         return;
172     }
173 
174     std::optional<KPluginMetaData> service;
175 
176     if (m_previewedTextEditorDocument) {
177         // TODO: mimetype is not set for new documents which have not been saved yet.
178         // Maybe retry to guess as soon as content is inserted.
179         m_currentMode = m_previewedTextEditorDocument->mode();
180 
181         // Get mimetypes assigned to the currently set mode.
182         auto mimeTypes = KConfigGroup(KSharedConfig::openConfig(QStringLiteral("katemoderc")), m_currentMode).readXdgListEntry("Mimetypes");
183         // Also try to guess from the content, if the above fails.
184         mimeTypes << m_previewedTextEditorDocument->mimeType();
185 
186         service = findPreviewPart(mimeTypes);
187 
188         if (!service) {
189             qCDebug(KTEPREVIEW) << "Found no preferred kpart service for mimetypes" << mimeTypes;
190         }
191 
192         // Update if the mode is changed. The signal may also be emitted, when a new
193         // url is loaded, therefore wait (QueuedConnection) for the document to load.
194         connect(m_previewedTextEditorDocument,
195                 &KTextEditor::Document::modeChanged,
196                 this,
197                 &PreviewWidget::resetTextEditorView,
198                 static_cast<Qt::ConnectionType>(Qt::QueuedConnection | Qt::UniqueConnection));
199         // Explicitly clear the old document, which otherwise might be accessed in
200         // m_partView->setDocument.
201         connect(m_previewedTextEditorDocument, &KTextEditor::Document::aboutToClose, this, &PreviewWidget::unsetDocument, Qt::UniqueConnection);
202     } else {
203         m_currentMode.clear();
204     }
205 
206     // change of preview type?
207     // TODO: find a better id than library?
208     const QString serviceId = service ? service->pluginId() : QString();
209 
210     if (serviceId != m_currentServiceId) {
211         if (m_partView) {
212             clearMenu();
213         }
214 
215         m_currentServiceId = serviceId;
216 
217         if (service) {
218             qCDebug(KTEPREVIEW) << "Creating new kpart service instance.";
219             m_partView = new KPartView(*service, this);
220             const bool autoupdate = m_autoUpdateAction->isChecked();
221             m_partView->setAutoUpdating(autoupdate);
222             int index = addWidget(m_partView->widget());
223             setCurrentIndex(index);
224 
225             // update kpart menu
226             const auto kPart = m_partView->kPart();
227             if (kPart) {
228                 m_xmlGuiFactory->addClient(kPart);
229 
230                 m_aboutKPartAction->setText(i18n("About %1", kPart->metaData().name()));
231                 m_aboutKPartAction->setEnabled(true);
232                 m_kPartMenu->addSeparator();
233                 m_kPartMenu->addAction(m_aboutKPartAction);
234                 m_kPartMenuAction->setEnabled(true);
235             }
236 
237             m_updateAction->setEnabled(!autoupdate);
238         } else {
239             m_partView = nullptr;
240         }
241     } else if (m_partView) {
242         qCDebug(KTEPREVIEW) << "Reusing active kpart service instance.";
243     }
244 
245     if (m_partView) {
246         m_partView->setDocument(m_previewedTextEditorDocument);
247     }
248 }
249 
unsetDocument(KTextEditor::Document * document)250 void PreviewWidget::unsetDocument(KTextEditor::Document *document)
251 {
252     if (!m_partView || m_previewedTextEditorDocument != document) {
253         return;
254     }
255 
256     m_partView->setDocument(nullptr);
257     m_previewedTextEditorDocument = nullptr;
258 
259     // remove any current partview
260     clearMenu();
261     m_partView = nullptr;
262 
263     m_currentServiceId.clear();
264 }
265 
showEvent(QShowEvent * event)266 void PreviewWidget::showEvent(QShowEvent *event)
267 {
268     Q_UNUSED(event);
269 
270     m_updateAction->setEnabled(m_partView && !m_autoUpdateAction->isChecked());
271 
272     if (m_lockAction->isChecked()) {
273         resetTextEditorView(m_previewedTextEditorDocument);
274     } else {
275         setTextEditorView(m_mainWindow->activeView());
276     }
277 }
278 
hideEvent(QHideEvent * event)279 void PreviewWidget::hideEvent(QHideEvent *event)
280 {
281     Q_UNUSED(event);
282 
283     // keep active part for reuse, but close preview document
284     // TODO: we also get hide event in kdevelop when the view is changed,
285     // need to find out how to filter this out or how to fix kdevelop
286     // so currently keep the preview document
287     //     unsetDocument(m_previewedTextEditorDocument);
288 
289     m_updateAction->setEnabled(false);
290 }
291 
toggleDocumentLocking(bool locked)292 void PreviewWidget::toggleDocumentLocking(bool locked)
293 {
294     if (!locked) {
295         setTextEditorView(m_mainWindow->activeView());
296     }
297 }
298 
toggleAutoUpdating(bool autoRefreshing)299 void PreviewWidget::toggleAutoUpdating(bool autoRefreshing)
300 {
301     if (!m_partView) {
302         // nothing to do
303         return;
304     }
305 
306     m_updateAction->setEnabled(!autoRefreshing && isVisible());
307     m_partView->setAutoUpdating(autoRefreshing);
308 }
309 
updatePreview()310 void PreviewWidget::updatePreview()
311 {
312     if (m_partView && m_partView->document()) {
313         m_partView->updatePreview();
314     }
315 }
316 
createContainer(QWidget * parent,int index,const QDomElement & element,QAction * & containerAction)317 QWidget *PreviewWidget::createContainer(QWidget *parent, int index, const QDomElement &element, QAction *&containerAction)
318 {
319     containerAction = nullptr;
320 
321     if (element.attribute(QStringLiteral("deleted")).toLower() == QLatin1String("true")) {
322         return nullptr;
323     }
324 
325     const QString tagName = element.tagName().toLower();
326     // filter out things we do not support
327     // TODO: consider integrating the toolbars
328     if (tagName == QLatin1String("mainwindow") || tagName == QLatin1String("toolbar") || tagName == QLatin1String("statusbar")) {
329         return nullptr;
330     }
331 
332     if (tagName == QLatin1String("menubar")) {
333         return m_kPartMenu;
334     }
335 
336     return KXMLGUIBuilder::createContainer(parent, index, element, containerAction);
337 }
338 
removeContainer(QWidget * container,QWidget * parent,QDomElement & element,QAction * containerAction)339 void PreviewWidget::removeContainer(QWidget *container, QWidget *parent, QDomElement &element, QAction *containerAction)
340 {
341     if (container == m_kPartMenu) {
342         return;
343     }
344 
345     KXMLGUIBuilder::removeContainer(container, parent, element, containerAction);
346 }
347 
showAboutKPartPlugin()348 void PreviewWidget::showAboutKPartPlugin()
349 {
350     if (m_partView && m_partView->kPart()) {
351         QPointer<KAboutPluginDialog> aboutDialog = new KAboutPluginDialog(m_partView->kPart()->metaData(), this);
352         aboutDialog->exec();
353         delete aboutDialog;
354     }
355 }
356 
clearMenu()357 void PreviewWidget::clearMenu()
358 {
359     // clear kpart menu
360     m_xmlGuiFactory->removeClient(m_partView->kPart());
361     m_kPartMenu->clear();
362 
363     removeWidget(m_partView->widget());
364     delete m_partView;
365 
366     m_updateAction->setEnabled(false);
367     m_kPartMenuAction->setEnabled(false);
368     m_aboutKPartAction->setEnabled(false);
369 }
370