Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ 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.wlx release/wlx/kpart/
install -m 644 wlx/kpart/*.md release/wlx/kpart/
install -m 644 wlx/kpart/*.png release/wlx/kpart/

pushd release
tar -czpf ../plugins-$(date +%y.%m.%d)-$ARCH.tar.gz *
popd
43 changes: 43 additions & 0 deletions wlx/kpart/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
cmake_minimum_required(VERSION 3.16)
project(kpart_host 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 SHARED
src/main.cpp
src/kpartwidget.cpp
)

target_link_libraries(kpart_host
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)
26 changes: 26 additions & 0 deletions wlx/kpart/README.md
Original file line number Diff line number Diff line change
@@ -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`.
Binary file added wlx/kpart/kpart_md.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added wlx/kpart/kpart_svg.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
281 changes: 281 additions & 0 deletions wlx/kpart/src/kpartwidget.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
#include "kpartwidget.h"
#include <QMimeDatabase>
#include <KParts/ReadOnlyPart>
#include <KParts/PartLoader>
#include <KPluginMetaData>
#include <QUrl>
#include <QEvent>
#include <QCoreApplication>
#include <QApplication>
#include <QTimer>
#include <QChildEvent>
#include <QResizeEvent>
#include <QEnterEvent>

KPartWidget::KPartWidget(QWidget *parent)
: QWidget(parent)
, m_part(nullptr)
, m_loadGeneration(0)
{
// Prevent this widget from ever accepting keyboard focus.
// Without this, Okular/Calligra cause focus to land here,
// and arrow key events get consumed instead of reaching DC's file list.
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);
}
}

KPartWidget::~KPartWidget()
{
if (QCoreApplication::instance()) {
QCoreApplication::instance()->removeEventFilter(this);
}
if (m_part) {
m_part->closeUrl();
delete m_part;
}
}

void KPartWidget::returnFocusToDC()
{
QWidget *target = this->parentWidget();
if (target) {
target->setFocus(Qt::OtherFocusReason);
}
this->clearFocus();
if (m_part && m_part->widget()) {
m_part->widget()->clearFocus();
}
}

void KPartWidget::installFocusGuard()
{
if (!m_part || !m_part->widget()) return;

QWidget *focusTarget = this->parentWidget();

m_part->widget()->installEventFilter(this);

// Set NoFocus policy and focus proxy on the root part widget and all
// children. The focus proxy redirects any programmatic setFocus() calls
// to the parent widget, and NoFocus prevents Tab/click focus acquisition.
m_part->widget()->setAttribute(Qt::WA_NativeWindow, false);
m_part->widget()->setFocusPolicy(Qt::NoFocus);
if (focusTarget) {
m_part->widget()->setFocusProxy(focusTarget);
}

for (QWidget *child : m_part->widget()->findChildren<QWidget*>()) {
child->setAttribute(Qt::WA_NativeWindow, false);
child->setFocusPolicy(Qt::NoFocus);
child->installEventFilter(this);
if (focusTarget) {
child->setFocusProxy(focusTarget);
}
}
}

bool KPartWidget::eventFilter(QObject *watched, QEvent *event)
{
switch (event->type()) {
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<QChildEvent*>(event);
if (ce->child() && ce->child()->isWidgetType()) {
QWidget *childWidget = static_cast<QWidget*>(ce->child());
if (m_part && m_part->widget() &&
(watched == m_part->widget() || m_part->widget()->isAncestorOf(static_cast<QWidget*>(watched)))) {
childWidget->setAttribute(Qt::WA_NativeWindow, false);
childWidget->setFocusPolicy(Qt::NoFocus);
childWidget->installEventFilter(this);
QWidget *focusTarget = this->parentWidget();
if (focusTarget) {
childWidget->setFocusProxy(focusTarget);
}
}
}
break;
}

case QEvent::FocusIn: {
// Global safety net: if focus lands on KPartWidget itself or any
// widget inside the KPart's subtree, immediately restore focus to
// DC's file list (saved before loading). This catches late async
// focus steals by Okular during PDF/EPUB page rendering that occur
// after the completed signal has already fired.
if (watched->isWidgetType()) {
QWidget *w = static_cast<QWidget*>(watched);

bool isOurs = (w == this);
if (!isOurs && m_part && m_part->widget()) {
isOurs = (w == m_part->widget() || m_part->widget()->isAncestorOf(w));
}

if (isOurs && m_savedFocusWidget && m_savedFocusWidget != w) {
m_savedFocusWidget->setFocus(Qt::OtherFocusReason);
}
}
break;
}

default:
break;
}

return QWidget::eventFilter(watched, event);
}

bool KPartWidget::loadFile(const QString &fileName)
{
// 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) {
returnFocusToDC();

m_part->closeUrl();
m_layout->removeWidget(m_part->widget());
delete m_part;
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;
}
}

QUrl url = QUrl::fromLocalFile(fileName);

// Find all parts available for this MIME type
QVector<KPluginMetaData> 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<KParts::ReadOnlyPart>(m_selectedPart, this, this);
if (result) {
m_part = result.plugin;

m_layout->addWidget(m_part->widget());

installFocusGuard();
connect(m_part, &KParts::ReadOnlyPart::completed, this, [this]() {
installFocusGuard();
if (m_savedFocusWidget) {
m_savedFocusWidget->setFocus(Qt::OtherFocusReason);
}
if (m_part && m_part->widget()) {
QTimer::singleShot(300, m_part->widget(), [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<QWidget*>()) {
QCoreApplication::postEvent(child, new QEvent(QEvent::WindowActivate));
QCoreApplication::postEvent(child, new QResizeEvent(child->size(), child->size()));
child->update();
}
});
}
});
connect(m_part, &KParts::ReadOnlyPart::completedWithPendingAction, this, [this]() {
installFocusGuard();
if (m_savedFocusWidget) {
m_savedFocusWidget->setFocus(Qt::OtherFocusReason);
}
if (m_part && m_part->widget()) {
QTimer::singleShot(300, m_part->widget(), [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<QWidget*>()) {
QCoreApplication::postEvent(child, new QEvent(QEvent::WindowActivate));
QCoreApplication::postEvent(child, new QResizeEvent(child->size(), child->size()));
child->update();
}
});
}
});

m_part->openUrl(m_pendingUrl);

// Immediately restore focus after opening (catches synchronous focus steals)
if (m_savedFocusWidget) {
m_savedFocusWidget->setFocus(Qt::OtherFocusReason);
}
}
}
Loading