diff --git a/build.sh b/build.sh index 78deeb2..fd25547 100755 --- a/build.sh +++ b/build.sh @@ -72,6 +72,14 @@ install -m 644 wdx/mediainfo/luajit/*.lua release/wdx/mediainfo/ install -m 644 wdx/translitwdx/translitwdx.lua release/wdx/translitwdx/ install -m 644 wdx/translitwdx/readme.txt release/wdx/translitwdx/ +# kpart +mkdir -p release/wlx/kpart +mkdir -p wlx/kpart/build +(cd wlx/kpart/build && cmake .. && make) +install -m 644 wlx/kpart/build/kpart_host_qt6.wlx release/wlx/kpart/ +install -m 644 wlx/kpart/*.md release/wlx/kpart/ +install -m 644 wlx/kpart/*.png release/wlx/kpart/ + # logview mkdir -p release/wlx/logview mkdir -p wlx/logview/build diff --git a/wlx/kpart/CMakeLists.txt b/wlx/kpart/CMakeLists.txt new file mode 100644 index 0000000..08632b2 --- /dev/null +++ b/wlx/kpart/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.16) +project(kpart_host_qt6 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +# Double Commander plugin settings +set(CMAKE_SHARED_LIBRARY_PREFIX "") +if(UNIX) + set(CMAKE_SHARED_LIBRARY_SUFFIX ".wlx") +endif() + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets) +find_package(KF6Parts REQUIRED) +find_package(KF6KIO REQUIRED) +find_package(KF6CoreAddons REQUIRED) + +include_directories( + ${CMAKE_CURRENT_SOURCE_DIR}/../../sdk +) + +add_library(kpart_host_qt6 SHARED + src/main.cpp + src/kpartwidget.cpp +) + +target_link_libraries(kpart_host_qt6 + PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Widgets + KF6::Parts + KF6::KIOFileWidgets + KF6::CoreAddons +) + +# Use ECM for standard installation paths and KDE integration +find_package(ECM 5.80.0 REQUIRED NO_MODULE) +set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) +include(KDEInstallDirs) +include(KDECMakeSettings) +include(KDECompilerSettings NO_POLICY_SCOPE) diff --git a/wlx/kpart/README.md b/wlx/kpart/README.md new file mode 100644 index 0000000..d87dc37 --- /dev/null +++ b/wlx/kpart/README.md @@ -0,0 +1,26 @@ +# KPart Host Double Commander Plugin + +A "Universal KDE Wrapper" WLX (Lister) plugin for Double Commander. This plugin acts as a host for KDE KParts, allowing Double Commander to view any file type supported by a KDE application (like Okular for PDFs or LibreOffice for documents) directly in the Quick View panel. + +![Markdown Screenshot](kpart_md.png) +![SVG Screenshot](kpart_svg.png) + +## Features +- Dynamic MIME-type detection using `QMimeDatabase`. +- Automatic loading of the best available KDE KPart for the file type. +- Native Wayland/Qt6 embedding via in-process KPart hosting. + +## Dependencies +- **Qt6**: Core, Gui, Widgets +- **KDE Frameworks 6 (KF6)**: Parts, KIO, CoreAddons +- **CMake**: `cmake` and `extra-cmake-modules` + +## Compilation +```bash +mkdir build +cd build +cmake .. +make +``` + +The build will produce `kpart_host.wlx`. diff --git a/wlx/kpart/build/kpart_host_qt6.wlx b/wlx/kpart/build/kpart_host_qt6.wlx new file mode 100755 index 0000000..1c16458 Binary files /dev/null and b/wlx/kpart/build/kpart_host_qt6.wlx differ diff --git a/wlx/kpart/kpart_md.png b/wlx/kpart/kpart_md.png new file mode 100644 index 0000000..14a14af Binary files /dev/null and b/wlx/kpart/kpart_md.png differ diff --git a/wlx/kpart/kpart_svg.png b/wlx/kpart/kpart_svg.png new file mode 100644 index 0000000..4fc81bc Binary files /dev/null and b/wlx/kpart/kpart_svg.png differ diff --git a/wlx/kpart/src/kpartwidget.cpp b/wlx/kpart/src/kpartwidget.cpp new file mode 100644 index 0000000..d2baccd --- /dev/null +++ b/wlx/kpart/src/kpartwidget.cpp @@ -0,0 +1,770 @@ +#include "kpartwidget.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +KPartWidget::KPartWidget(QWidget *parent) + : QWidget(parent) + , m_part(nullptr) + , m_loadGeneration(0) +{ + // NoFocus by default — activation is managed exclusively via + // lc_focus from DC and geometry-based click detection. + setFocusPolicy(Qt::NoFocus); + + m_layout = new QVBoxLayout(this); + m_layout->setContentsMargins(0, 0, 0, 0); + m_layout->setSpacing(0); + + // Install global event filter to intercept focus-stealing by KParts + // at the application level, regardless of which widget they target. + if (QCoreApplication::instance()) { + QCoreApplication::instance()->installEventFilter(this); + } + + // Connect to focusChanged to bounce focus back when inactive. + // This is the primary defence against KParts stealing focus + // programmatically (e.g. Okular after rendering a page). + connect(qApp, &QApplication::focusChanged, this, [this](QWidget *old, QWidget *now) { + if (!m_part || !m_part->widget()) return; + + bool nowInside = now && (now == this || this->isAncestorOf(now)); + + if (m_isActive) { + // If focus left the plugin while active, deactivate. + bool oldInside = old && (old == this || this->isAncestorOf(old)); + if (oldInside && !nowInside) { + setActive(false); + } + } else { + // Inactive: if focus entered the plugin, bounce it back. + if (nowInside) { + // Learn which widget the KPart naturally wants to focus. + if (now != this) { + m_partFocusWidget = now; + } + QPointer pOld(old); + QTimer::singleShot(0, this, [this, pOld]() { + QWidget *currentFocus = QApplication::focusWidget(); + bool stillInside = currentFocus && + (currentFocus == this || this->isAncestorOf(currentFocus)); + if (stillInside) { + if (pOld) { + pOld->setFocus(Qt::OtherFocusReason); + } else { + restoreFocusToDC(); + } + } + }); + } + } + }); +} + +KPartWidget::~KPartWidget() +{ + if (QCoreApplication::instance()) { + QCoreApplication::instance()->removeEventFilter(this); + } + if (m_part) { + m_part->closeUrl(); + delete m_part; + } +} + +void KPartWidget::returnFocusToDC() +{ + setActive(false); +} + +void KPartWidget::restoreFocusToDC() +{ + if (m_savedFocusWidget) { + m_savedFocusWidget->setFocus(Qt::OtherFocusReason); + } else if (this->parentWidget()) { + this->parentWidget()->setFocus(Qt::OtherFocusReason); + } +} + +void KPartWidget::setActive(bool active) +{ + if (m_isActive == active) + return; + + m_isActive = active; + + if (!active) { + this->clearFocus(); + if (m_part && m_part->widget()) { + m_part->widget()->clearFocus(); + } + if (this->parentWidget()) { + this->parentWidget()->setFocus(Qt::OtherFocusReason); + } + } else { + // Find the best widget to give focus to: + // 1. m_partFocusWidget (learned from the KPart's own focus steal) + // 2. Walk the focus proxy chain from m_part->widget() + // 3. Search for a child with StrongFocus policy + // 4. Fall back to m_part->widget() itself + QWidget *target = nullptr; + if (m_part && m_part->widget()) { + if (m_partFocusWidget) { + target = m_partFocusWidget.data(); + } else { + // Walk focus proxy chain + target = m_part->widget(); + while (target->focusProxy()) { + target = target->focusProxy(); + } + // If proxy chain led nowhere useful, search children + if (target == m_part->widget() && + !(target->focusPolicy() & Qt::StrongFocus)) { + for (QWidget *child : m_part->widget()->findChildren()) { + if (child->focusPolicy() & Qt::StrongFocus) { + target = child; + break; + } + } + } + } + target->setFocus(Qt::OtherFocusReason); + } + } +} + +void KPartWidget::installFocusGuard() +{ + if (!m_part || !m_part->widget()) return; + + // Install event filters to intercept mouse/key/focus events, but do NOT + // override focus policies. The focusChanged bounce-back (see constructor) + // is the sole defence against programmatic focus steals. Keeping the + // native focus policies lets KPart widgets actually receive keyboard + // input (arrows, pgup/down, home/end) when the plugin is active. + m_part->widget()->installEventFilter(this); + m_part->widget()->setAttribute(Qt::WA_NativeWindow, false); + + for (QWidget *child : m_part->widget()->findChildren()) { + child->setAttribute(Qt::WA_NativeWindow, false); + child->installEventFilter(this); + } +} + +bool KPartWidget::eventFilter(QObject *watched, QEvent *event) +{ + switch (event->type()) { + case QEvent::MouseButtonPress: { + // Geometry-based click detection, mirroring FocusManager pattern. + if (!m_part || !m_part->widget()) break; + + QMouseEvent *me = static_cast(event); + const QPoint gp = me->globalPosition().toPoint(); + const QRect gr(mapToGlobal(QPoint(0, 0)), size()); + + if (m_isActive && !gr.contains(gp)) { + setActive(false); + } else if (!m_isActive && gr.contains(gp)) { + // Following FocusManager pattern: just set the flag. + // Let the click event propagate naturally so Qt gives + // click-focus to the widget the user actually clicked. + m_isActive = true; + } + break; + } + case QEvent::KeyPress: { + if (m_isActive) { + QKeyEvent *ke = static_cast(event); + // Ctrl+Q: deactivate and forward to DC. + if (ke->key() == Qt::Key_Q && (ke->modifiers() & Qt::ControlModifier)) { + setActive(false); + QTimer::singleShot(0, this, [this]() { + QWidget *target = QApplication::activeWindow(); + if (!target) target = this->window(); + if (target) { + QCoreApplication::postEvent(target, + new QKeyEvent(QEvent::KeyPress, Qt::Key_Q, Qt::ControlModifier)); + QCoreApplication::postEvent(target, + new QKeyEvent(QEvent::KeyRelease, Qt::Key_Q, Qt::ControlModifier)); + } + }); + return true; + } + + // Manual Shortcut Routing for Zoom Actions + if (m_part) { + QString actionToTrigger; + if (ke->key() == Qt::Key_F && ke->modifiers() == Qt::NoModifier) { + actionToTrigger = QStringLiteral("view_zoom_to_fit"); + } else if (ke->key() == Qt::Key_0 && (ke->modifiers() & Qt::ControlModifier)) { + actionToTrigger = QStringLiteral("view_actual_size"); + } else if (ke->key() == Qt::Key_Plus || ke->key() == Qt::Key_Equal) { + actionToTrigger = QStringLiteral("view_zoom_in"); + } else if (ke->key() == Qt::Key_Minus) { + actionToTrigger = QStringLiteral("view_zoom_out"); + } else if (ke->key() == Qt::Key_S && (ke->modifiers() & Qt::ControlModifier) && (ke->modifiers() & Qt::ShiftModifier)) { + actionToTrigger = QStringLiteral("file_save_as"); + } + + if (!actionToTrigger.isEmpty()) { + // Try actionCollection first + QAction *act = m_part->actionCollection() + ? m_part->actionCollection()->action(actionToTrigger) + : nullptr; + // Fallback: findChild by objectName + if (!act) { + act = m_part->findChild(actionToTrigger, Qt::FindChildrenRecursively); + } + qDebug() << "[KPartWidget] KeyPress: key=" << ke->key() + << "looking for action:" << actionToTrigger + << "found:" << (act != nullptr) + << "enabled:" << (act ? act->isEnabled() : false) + << "m_isActive:" << m_isActive; + if (act && act->isEnabled()) { + act->trigger(); + return true; + } else if (!act) { + // Dump all available actions for diagnosis + qDebug() << "[KPartWidget] Action not found. Available actions from actionCollection:"; + if (m_part->actionCollection()) { + const auto allActions = m_part->actionCollection()->actions(); + for (const QAction *a : allActions) { + qDebug() << " -" << a->objectName() << "text:" << a->text() + << "checkable:" << a->isCheckable() + << "checked:" << a->isChecked() + << "enabled:" << a->isEnabled(); + } + } + qDebug() << "[KPartWidget] Available actions from findChildren:"; + const auto childActions = m_part->findChildren(Qt::FindChildrenRecursively); + for (const QAction *a : childActions) { + qDebug() << " -" << a->objectName() << "text:" << a->text() + << "checkable:" << a->isCheckable() + << "checked:" << a->isChecked() + << "enabled:" << a->isEnabled(); + } + } + } + } + + // Navigation keys: if the event has propagated up to KPartWidget, + // it means the KPart didn't handle it (e.g. KTextEditor in + // read-only embedded mode). Scroll the view ourselves. + if (watched == this) { + switch (ke->key()) { + case Qt::Key_Up: + case Qt::Key_Down: + case Qt::Key_PageUp: + case Qt::Key_PageDown: + case Qt::Key_Home: + case Qt::Key_End: + if (scrollView(ke->key())) { + return true; + } + break; + } + } + } + break; + } + case QEvent::ChildAdded: { + // Okular/Calligra spawn widgets asynchronously (e.g. PageView). + // Apply focus guards to each new child in our KPart's subtree. + QChildEvent *ce = static_cast(event); + if (ce->child() && ce->child()->isWidgetType()) { + QWidget *childWidget = static_cast(ce->child()); + if (m_part && m_part->widget() && + (watched == m_part->widget() || m_part->widget()->isAncestorOf(static_cast(watched)))) { + childWidget->setAttribute(Qt::WA_NativeWindow, false); + childWidget->installEventFilter(this); + } + } + break; + } + + case QEvent::FocusIn: { + // When inactive, block any programmatic focus entry into the + // plugin subtree. The focusChanged connection above handles the + // bounce, but this provides belt-and-suspenders protection. + if (!m_isActive && watched->isWidgetType()) { + QWidget *w = static_cast(watched); + bool isOurs = (w == this); + if (!isOurs && m_part && m_part->widget()) { + isOurs = (w == m_part->widget() || m_part->widget()->isAncestorOf(w)); + } + if (isOurs) { + QFocusEvent *fe = static_cast(event); + if (fe->reason() == Qt::OtherFocusReason || + fe->reason() == Qt::ActiveWindowFocusReason) { + // Programmatic focus steal while inactive — bounce it + QTimer::singleShot(0, this, [this]() { + restoreFocusToDC(); + }); + } + } + } + break; + } + + case QEvent::Show: { + if (watched->isWidgetType() && watched->inherits("QMessageBox")) { + QMessageBox *mb = qobject_cast(watched); + if (mb) { + // If a KPart pops up a warning about a file being deleted or modified, + // reject it automatically to prevent freezing Double Commander. + QString text = mb->text(); + if (text.contains(QLatin1String("deleted"), Qt::CaseInsensitive) || + text.contains(QLatin1String("modified"), Qt::CaseInsensitive)) { + QTimer::singleShot(0, mb, &QDialog::reject); + } + } + } + break; + } + + default: + break; + } + + return QWidget::eventFilter(watched, event); +} + +bool KPartWidget::loadFile(const QString &fileName) +{ + QUrl url = QUrl::fromLocalFile(fileName); + + if (m_part && m_pendingUrl == url) { + // Double Commander is asking us to reload the same file (e.g., it was modified). + // Many KParts (like Gwenview) auto-reload the file internally. + // Destroying the KPart here while it's processing its own file watcher events + // causes severe GLib/GTK crashes. We just safely re-open the URL. + KParts::OpenUrlArguments args; + args.setReload(true); + m_part->setArguments(args); + m_part->openUrl(url); + return true; + } + + // Save which widget currently has focus (DC's file list) so we can + // restore it after the KPart inevitably steals focus. + m_savedFocusWidget = QApplication::focusWidget(); + + // Increment generation to invalidate any queued callbacks from the + // previous part before we tear it down. + m_loadGeneration++; + + if (m_part) { + // Deactivate but don't try to restore focus yet — we're about to + // tear down and rebuild the part. + m_isActive = false; + m_partFocusWidget = nullptr; + + m_part->closeUrl(); + QWidget *oldWidget = m_part->widget(); + if (oldWidget) { + m_layout->removeWidget(oldWidget); + oldWidget->hide(); + oldWidget->setParent(nullptr); + } + m_part->deleteLater(); + m_part = nullptr; + } + + QMimeDatabase db; + QMimeType mime = db.mimeTypeForFile(fileName); + + // If the file is detected as a generic ZIP but has a more specific extension + // (like .docx, .odt, etc.), prioritize the extension-based MIME type. + if (mime.name() == QLatin1String("application/zip") || mime.isDefault()) { + QMimeType extMime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchExtension); + if (!extMime.isDefault() && extMime.name() != mime.name()) { + mime = extMime; + } + } + + // Find all parts available for this MIME type + QVector parts = KParts::PartLoader::partsForMimeType(mime.name()); + + KPluginMetaData selectedPart; + + // First pass: look for specialized renderers (not archives, not terminal) + for (const auto &metaData : parts) { + QString pluginId = metaData.pluginId(); + if (pluginId.contains(QLatin1String("konsole"), Qt::CaseInsensitive) || + pluginId.contains(QLatin1String("arkpart"), Qt::CaseInsensitive) || + pluginId.contains(QLatin1String("kioarchive"), Qt::CaseInsensitive)) { + continue; + } + selectedPart = metaData; + break; + } + + // Second pass: if no specialized renderer found, allow archive explorers as fallback + if (!selectedPart.isValid()) { + for (const auto &metaData : parts) { + QString pluginId = metaData.pluginId(); + if (pluginId.contains(QLatin1String("konsole"), Qt::CaseInsensitive)) { + continue; + } + if (pluginId.contains(QLatin1String("arkpart"), Qt::CaseInsensitive) || + pluginId.contains(QLatin1String("kioarchive"), Qt::CaseInsensitive)) { + selectedPart = metaData; + break; + } + } + } + + if (selectedPart.isValid()) { + m_pendingUrl = url; + m_selectedPart = selectedPart; + + // Defer instantiation by 50ms so Double Commander can finish handling + // the user's MouseRelease event on the file list. Without this delay, + // complex KParts spin up Wayland grabs so fast that DC misses the + // release and gets stuck in a phantom-drag mode. + QTimer::singleShot(50, this, [this, gen = m_loadGeneration]() { + if (gen == m_loadGeneration) { + instantiatePart(); + } + }); + + return true; + } + + return false; +} + +void KPartWidget::instantiatePart() +{ + auto result = KParts::PartLoader::instantiatePart(m_selectedPart, this, this); + if (result) { + m_part = result.plugin; + + m_layout->addWidget(m_part->widget()); + + installFocusGuard(); + + // Lambda to configure actions: connect toggled signals, set shortcut contexts, etc. + // Must be called AFTER the part is fully loaded (from completed signal). + auto configureAndRestore = [this]() { + if (!m_part || !m_part->widget()) return; + + // Collect actions from both sources, deduplicating + QSet seen; + QList allActions; + + if (m_part->actionCollection()) { + const auto acActions = m_part->actionCollection()->actions(); + qDebug() << "[KPartWidget] configureAndRestore: actionCollection has" << acActions.size() << "actions"; + for (QAction *a : acActions) { + if (!seen.contains(a)) { + seen.insert(a); + allActions.append(a); + } + } + } + + const auto childActions = m_part->findChildren(Qt::FindChildrenRecursively); + qDebug() << "[KPartWidget] configureAndRestore: findChildren found" << childActions.size() << "actions"; + for (QAction *a : childActions) { + if (!seen.contains(a)) { + seen.insert(a); + allActions.append(a); + } + } + + qDebug() << "[KPartWidget] configureAndRestore: total unique actions:" << allActions.size(); + + for (QAction *act : allActions) { + QString actionName = act->objectName(); + + qDebug() << "[KPartWidget] Action:" << actionName + << "text:" << act->text() + << "checkable:" << act->isCheckable() + << "checked:" << act->isChecked() + << "enabled:" << act->isEnabled() + << "shortcutContext:" << act->shortcutContext() + << "shortcuts:" << act->shortcuts(); + + // Add action to widget so shortcuts with WidgetWithChildrenShortcut can work + if (!m_part->widget()->actions().contains(act)) { + m_part->widget()->addAction(act); + } + + if (act->shortcutContext() == Qt::WindowShortcut || act->shortcutContext() == Qt::ApplicationShortcut) { + act->setShortcutContext(Qt::WidgetWithChildrenShortcut); + } + + // Guard against duplicate connections using a dynamic property. + // Qt::UniqueConnection does NOT work with lambdas. + if (act->isCheckable() && !actionName.isEmpty() + && !act->property("_kpw_connected").toBool()) { + act->setProperty("_kpw_connected", true); + + connect(act, &QAction::toggled, this, [this, actionName](bool checked) { + qDebug() << "[KPartWidget] Action toggled:" << actionName << "checked:" << checked; + QSettings s(QSettings::IniFormat, QSettings::UserScope, QStringLiteral("doublecmd"), QStringLiteral("wlx_kpart")); + s.setValue(actionName, checked); + s.sync(); + qDebug() << "[KPartWidget] Saved" << actionName << "=" << checked << "to" << s.fileName(); + + if (m_part) { + // Radio-button behavior for zoom modes: + // checking one unchecks the other; unchecking one checks the other. + QString otherName; + if (actionName == QLatin1String("view_zoom_to_fit")) { + otherName = QStringLiteral("view_actual_size"); + } else if (actionName == QLatin1String("view_actual_size")) { + otherName = QStringLiteral("view_zoom_to_fit"); + } + if (!otherName.isEmpty()) { + QAction *other = m_part->actionCollection() + ? m_part->actionCollection()->action(otherName) + : nullptr; + if (!other) { + other = m_part->findChild(otherName, Qt::FindChildrenRecursively); + } + if (checked && other && other->isChecked()) { + qDebug() << "[KPartWidget] Radio: toggling off" << otherName; + other->trigger(); // let Gwenview handle its internal state + } + } + } + + if (m_part && m_part->widget()) { + QResizeEvent re(m_part->widget()->size(), m_part->widget()->size()); + QCoreApplication::sendEvent(m_part->widget(), &re); + m_part->widget()->update(); + } + }); + } + } + + // Now restore saved settings (connections are already established above). + // We must use trigger() instead of setChecked() so the KPart's internal + // handler fires (e.g. Gwenview actually changes zoom mode, not just checkbox). + // + // For the zoom radio pair: Gwenview fights back when we setChecked(false) + // on zoom_to_fit — it re-checks it internally, cascading through our radio + // handler and undoing the restore. Fix: block signals on BOTH zoom actions, + // uncheck both, unblock, then trigger() only the desired one. + QSettings settings(QSettings::IniFormat, QSettings::UserScope, QStringLiteral("doublecmd"), QStringLiteral("wlx_kpart")); + qDebug() << "[KPartWidget] Restoring settings from" << settings.fileName() << "keys:" << settings.allKeys(); + + // Identify zoom actions and which should be active + static const QString zoomFit = QStringLiteral("view_zoom_to_fit"); + static const QString zoomActual = QStringLiteral("view_actual_size"); + QAction *zoomFitAct = nullptr, *zoomActualAct = nullptr; + for (QAction *act : allActions) { + if (act->objectName() == zoomFit) zoomFitAct = act; + else if (act->objectName() == zoomActual) zoomActualAct = act; + } + + QString activeZoom; + if (settings.contains(zoomActual) && settings.value(zoomActual).toBool()) { + activeZoom = zoomActual; + } else if (settings.contains(zoomFit) && settings.value(zoomFit).toBool()) { + activeZoom = zoomFit; + } + + // Restore zoom radio pair: just trigger() the desired action. + // trigger() properly changes Gwenview's internal state (same as a menu click). + // If the action is already checked (Gwenview's default), skip — it's already active. + if (!activeZoom.isEmpty() && (zoomFitAct || zoomActualAct)) { + QAction *activeAct = (activeZoom == zoomFit) ? zoomFitAct : zoomActualAct; + qDebug() << "[KPartWidget] Restoring zoom:" << activeZoom + << "currently checked:" << (activeAct ? activeAct->isChecked() : false); + if (activeAct && !activeAct->isChecked()) { + activeAct->trigger(); + } + } + + // Restore non-zoom checkable actions normally + for (QAction *act : allActions) { + if (!act->isCheckable()) continue; + QString actionName = act->objectName(); + // Skip zoom pair (already handled above) + if (actionName == zoomFit || actionName == zoomActual) continue; + if (!actionName.isEmpty() && settings.contains(actionName)) { + bool desired = settings.value(actionName).toBool(); + qDebug() << "[KPartWidget] Restoring" << actionName + << "current:" << act->isChecked() << "desired:" << desired; + if (desired && !act->isChecked()) { + act->trigger(); + } else if (!desired && act->isChecked()) { + act->setChecked(false); + } + } + } + }; + + connect(m_part, &KParts::ReadOnlyPart::completed, this, [this, configureAndRestore]() { + installFocusGuard(); + restoreFocusToDC(); + + // Configure action connections now that the part is fully loaded. + // The restore portion inside configureAndRestore uses trigger() which + // must run AFTER all of Gwenview's own completed handlers have finished + // (otherwise Gwenview resets zoom to its defaults after our restore). + // QTimer::singleShot(0) defers to the next event loop iteration. + QTimer::singleShot(0, this, [this, configureAndRestore]() { + configureAndRestore(); + }); + + if (m_part && m_part->widget()) { + QTimer::singleShot(300, m_part->widget(), [this, w = m_part->widget()]() { + QCoreApplication::postEvent(w, new QEvent(QEvent::WindowActivate)); + QCoreApplication::postEvent(w, new QResizeEvent(w->size(), w->size())); + QCoreApplication::postEvent(w, new QEnterEvent(QPointF(0,0), QPointF(0,0), QPointF(0,0))); + QCoreApplication::postEvent(w, new QEvent(QEvent::Leave)); + w->update(); + + for (QWidget *child : w->findChildren()) { + QCoreApplication::postEvent(child, new QEvent(QEvent::WindowActivate)); + QCoreApplication::postEvent(child, new QResizeEvent(child->size(), child->size())); + child->update(); + } + + if (m_selectedPart.pluginId() == QLatin1String("markdownpart")) { + QScrollBar *vbar = nullptr; + QAbstractScrollArea *scrollArea = w->findChild(); + if (scrollArea) { + vbar = scrollArea->verticalScrollBar(); + } + if (!vbar) { + for (QScrollBar *bar : w->findChildren()) { + if (bar->orientation() == Qt::Vertical && bar->maximum() > 0) { + vbar = bar; + break; + } + } + } + if (vbar) { + vbar->setValue(0); + } + } + }); + } + }); + connect(m_part, &KParts::ReadOnlyPart::completedWithPendingAction, this, [this, configureAndRestore]() { + installFocusGuard(); + restoreFocusToDC(); + QTimer::singleShot(0, this, [this, configureAndRestore]() { + configureAndRestore(); + }); + if (m_part && m_part->widget()) { + QTimer::singleShot(300, m_part->widget(), [this, w = m_part->widget()]() { + QCoreApplication::postEvent(w, new QEvent(QEvent::WindowActivate)); + QCoreApplication::postEvent(w, new QResizeEvent(w->size(), w->size())); + QCoreApplication::postEvent(w, new QEnterEvent(QPointF(0,0), QPointF(0,0), QPointF(0,0))); + QCoreApplication::postEvent(w, new QEvent(QEvent::Leave)); + w->update(); + + for (QWidget *child : w->findChildren()) { + QCoreApplication::postEvent(child, new QEvent(QEvent::WindowActivate)); + QCoreApplication::postEvent(child, new QResizeEvent(child->size(), child->size())); + child->update(); + } + + if (m_selectedPart.pluginId() == QLatin1String("markdownpart")) { + QScrollBar *vbar = nullptr; + QAbstractScrollArea *scrollArea = w->findChild(); + if (scrollArea) { + vbar = scrollArea->verticalScrollBar(); + } + if (!vbar) { + for (QScrollBar *bar : w->findChildren()) { + if (bar->orientation() == Qt::Vertical && bar->maximum() > 0) { + vbar = bar; + break; + } + } + } + if (vbar) { + vbar->setValue(0); + } + } + }); + } + }); + KParts::OpenUrlArguments args; + args.setReload(true); + m_part->setArguments(args); + m_part->openUrl(m_pendingUrl); + + // Immediately restore focus after opening (catches synchronous focus steals) + restoreFocusToDC(); + } +} + + + +bool KPartWidget::scrollView(int key) +{ + if (!m_part || !m_part->widget()) return false; + + // Find the vertical scrollbar inside the KPart's widget tree. + // Try QAbstractScrollArea first (Okular, Gwenview, etc.), then + // fall back to any vertical QScrollBar (KTextEditor uses KateScrollBar). + QScrollBar *vbar = nullptr; + + QAbstractScrollArea *scrollArea = m_part->widget()->findChild(); + if (scrollArea) { + vbar = scrollArea->verticalScrollBar(); + } + + if (!vbar || !vbar->maximum()) { + for (QScrollBar *bar : m_part->widget()->findChildren()) { + if (bar->orientation() == Qt::Vertical && bar->maximum() > 0) { + vbar = bar; + break; + } + } + } + + if (!vbar || vbar->maximum() <= 0) return false; + + switch (key) { + case Qt::Key_Up: + vbar->triggerAction(QAbstractSlider::SliderSingleStepSub); + return true; + case Qt::Key_Down: + vbar->triggerAction(QAbstractSlider::SliderSingleStepAdd); + return true; + case Qt::Key_PageUp: + vbar->triggerAction(QAbstractSlider::SliderPageStepSub); + return true; + case Qt::Key_PageDown: + vbar->triggerAction(QAbstractSlider::SliderPageStepAdd); + return true; + case Qt::Key_Home: + vbar->triggerAction(QAbstractSlider::SliderToMinimum); + return true; + case Qt::Key_End: + vbar->triggerAction(QAbstractSlider::SliderToMaximum); + return true; + } + return false; +} diff --git a/wlx/kpart/src/kpartwidget.h b/wlx/kpart/src/kpartwidget.h new file mode 100644 index 0000000..b4882cd --- /dev/null +++ b/wlx/kpart/src/kpartwidget.h @@ -0,0 +1,42 @@ +#ifndef KPARTWIDGET_H +#define KPARTWIDGET_H + +#include +#include +#include +#include +#include +#include + +class KPartWidget : public QWidget +{ + Q_OBJECT + +public: + explicit KPartWidget(QWidget *parent = nullptr); + ~KPartWidget(); + + bool loadFile(const QString &fileName); + void setActive(bool active); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void installFocusGuard(); + void returnFocusToDC(); + void restoreFocusToDC(); + void instantiatePart(); + bool scrollView(int key); + + KParts::ReadOnlyPart *m_part; + QVBoxLayout *m_layout; + int m_loadGeneration; + QUrl m_pendingUrl; + KPluginMetaData m_selectedPart; + QPointer m_savedFocusWidget; + QPointer m_partFocusWidget; + bool m_isActive = false; +}; + +#endif // KPARTWIDGET_H diff --git a/wlx/kpart/src/main.cpp b/wlx/kpart/src/main.cpp new file mode 100644 index 0000000..c4526b2 --- /dev/null +++ b/wlx/kpart/src/main.cpp @@ -0,0 +1,230 @@ +#include "wlxplugin.h" +#include "kpartwidget.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { + +HWND DCPCALL ListLoad(HWND ParentWin, char* FileToLoad, int ShowFlags) +{ + (void)ShowFlags; + + if (!QCoreApplication::instance()) { + return nullptr; + } + + QWidget *parent = static_cast(ParentWin); + KPartWidget *view = new KPartWidget(parent); + + if (view->loadFile(QString::fromUtf8(FileToLoad))) { + view->show(); + return static_cast(view); + } else { + delete view; + return nullptr; + } +} + +HWND DCPCALL ListLoadW(HWND ParentWin, WCHAR* FileToLoad, int ShowFlags) +{ + (void)ShowFlags; + + if (!QCoreApplication::instance()) { + return nullptr; + } + + QString fileName = QString::fromUtf16(reinterpret_cast(FileToLoad)); + + QWidget *parent = static_cast(ParentWin); + KPartWidget *view = new KPartWidget(parent); + + if (view->loadFile(fileName)) { + view->show(); + return static_cast(view); + } else { + delete view; + return nullptr; + } +} + +int DCPCALL ListLoadNext(HWND ParentWin, HWND PluginWin, char* FileToLoad, int ShowFlags) +{ + (void)ParentWin; + (void)ShowFlags; + + KPartWidget *view = static_cast(PluginWin); + if (!view) return LISTPLUGIN_ERROR; + + if (view->loadFile(QString::fromUtf8(FileToLoad))) { + return LISTPLUGIN_OK; + } + return LISTPLUGIN_ERROR; +} + +int DCPCALL ListLoadNextW(HWND ParentWin, HWND PluginWin, WCHAR* FileToLoad, int ShowFlags) +{ + (void)ParentWin; + (void)ShowFlags; + + KPartWidget *view = static_cast(PluginWin); + if (!view) return LISTPLUGIN_ERROR; + + QString fileName = QString::fromUtf16(reinterpret_cast(FileToLoad)); + if (view->loadFile(fileName)) { + return LISTPLUGIN_OK; + } + return LISTPLUGIN_ERROR; +} + +void DCPCALL ListCloseWindow(HWND ListWin) +{ + KPartWidget *view = static_cast(ListWin); + if (view) { + delete view; + } +} + +void DCPCALL ListGetDetectString(char* DetectString, int maxlen) +{ + // Need a QCoreApplication to use QMimeDatabase and KPluginMetaData + int argc = 1; + char* argv[] = { (char*)"doublecmd", nullptr }; + QCoreApplication *app = QCoreApplication::instance(); + bool appCreated = false; + if (!app) { + app = new QCoreApplication(argc, argv); + appCreated = true; + } + + QSet preferred; + QSet allExts; + QMimeDatabase mimeDb; + + // Find all KParts installed on the system, plus Okular generators + QVector parts = KPluginMetaData::findPlugins(QStringLiteral("kf6/parts")); + parts.append(KPluginMetaData::findPlugins(QStringLiteral("okular_generators"))); + for (const KPluginMetaData &part : parts) { + QStringList mimeTypes = part.mimeTypes(); + for (const QString &mimeName : mimeTypes) { + QMimeType mimeType = mimeDb.mimeTypeForName(mimeName); + if (mimeType.isValid()) { + QString fullExt = mimeType.preferredSuffix(); + int lastDot = fullExt.lastIndexOf(QLatin1Char('.')); + QString ext = (lastDot == -1) ? fullExt : fullExt.mid(lastDot + 1); + ext = ext.toUpper(); + if (!ext.isEmpty()) { + preferred.insert(ext); + } + + QStringList globs = mimeType.globPatterns(); + for (const QString &glob : globs) { + if (glob.startsWith(QLatin1String("*."))) { + QString fExt = glob.mid(2); + int lDot = fExt.lastIndexOf(QLatin1Char('.')); + QString aExt = (lDot == -1) ? fExt : fExt.mid(lDot + 1); + aExt = aExt.toUpper(); + if (!aExt.isEmpty()) { + allExts.insert(aExt); + } + } + } + } + } + } + + // Priority user-defined extensions that might otherwise be cut off or missed + QStringList priorityList = { + "ASC", "CPP", "H++", "C++", "HTM", "INS", "LATEX", "LTX", "PAS", "PATCH", + "PERL", "PHP3", "PHP4", "PHP5", "PHPS", "STY", "VRML", "XSD", "TGZ", "LZH", + "GEM", "001", "PKG", "TB2", "TZO", "JPEG", "JPE", "TIFF", + "F90", "F95", "FOR", "PM", "POD", "T", "MAK", "CLS", "DTX" + }; + for (const QString &p : priorityList) { + preferred.insert(p.toUpper()); + } + + if (preferred.isEmpty()) { + // Fallback in case finding plugins failed or zero parts are installed + snprintf(DetectString, maxlen, "EXT=\"TXT\""); + } else { + QStringList extList = preferred.values(); + extList.sort(); + + QString result; + for (const QString &ext : extList) { + if (!result.isEmpty()) { + result += QLatin1String(" | "); + } + result += QStringLiteral("EXT=\"%1\"").arg(ext); + } + + QSet remaining = allExts; + remaining.subtract(preferred); + + QStringList remList = remaining.values(); + std::sort(remList.begin(), remList.end(), [](const QString &a, const QString &b) { + if (a.length() != b.length()) return a.length() < b.length(); + return a < b; + }); + + for (const QString &ext : remList) { + QString addition = QStringLiteral(" | EXT=\"%1\"").arg(ext); + if (result.length() + addition.length() < maxlen - 1) { + result += addition; + } else { + break; + } + } + + QByteArray utf8 = result.toUtf8(); + qstrncpy(DetectString, utf8.constData(), maxlen); + } + + if (appCreated) { + delete app; + } +} + +int DCPCALL ListSearchDialog(HWND ListWin, int FindNext) +{ + (void)ListWin; + (void)FindNext; + return LISTPLUGIN_OK; +} + +int DCPCALL ListSendCommand(HWND ListWin, int Command, int Parameter) +{ + if (Command == 5 /* lc_focus */) { + KPartWidget *view = static_cast(ListWin); + if (view) { + view->setActive(Parameter != 0); + return LISTPLUGIN_OK; + } + } + return LISTPLUGIN_ERROR; +} + +void DCPCALL ListSetDefaultParams(ListDefaultParamStruct* dps) +{ + (void)dps; + + // Set application metadata once during plugin global initialization + // This helps KDE Frameworks associate jobs with the application. + if (QCoreApplication::instance()) { + if (QCoreApplication::applicationName().isEmpty()) { + QCoreApplication::setApplicationName(QStringLiteral("doublecmd")); + } + if (QGuiApplication::desktopFileName().isEmpty()) { + QGuiApplication::setDesktopFileName(QStringLiteral("doublecmd")); + } + } +} + +} // extern "C"