KGuiAddons

waylandclipboard.cpp
1/*
2 SPDX-FileCopyrightText: 2020 David Edmundson <davidedmundson@kde.org>
3 SPDX-FileCopyrightText: 2021 Méven Car <meven.car@enioka.com>
4
5 SPDX-License-Identifier: LGPL-2.0-or-later
6*/
7
8#include "waylandclipboard_p.h"
9
10#include <QBuffer>
11#include <QFile>
12#include <QGuiApplication>
13#include <QImageReader>
14#include <QImageWriter>
15#include <QMimeData>
16#include <QPointer>
17#include <QWaylandClientExtension>
18#include <QWindow>
19#include <QtWaylandClientVersion>
20#include <qpa/qplatformnativeinterface.h>
21
22#include <errno.h>
23#include <poll.h>
24#include <signal.h>
25#include <string.h>
26#include <unistd.h>
27
28#include "qwayland-wayland.h"
29#include "qwayland-wlr-data-control-unstable-v1.h"
30
31static inline QString applicationQtXImageLiteral()
32{
33 return QStringLiteral("application/x-qt-image");
34}
35
36// copied from https://code.woboq.org/qt5/qtbase/src/gui/kernel/qinternalmimedata.cpp.html
37static QString utf8Text()
38{
39 return QStringLiteral("text/plain;charset=utf-8");
40}
41
42static QStringList imageMimeFormats(const QList<QByteArray> &imageFormats)
43{
44 QStringList formats;
45 formats.reserve(imageFormats.size());
46 for (const auto &format : imageFormats)
47 formats.append(QLatin1String("image/") + QLatin1String(format.toLower()));
48 // put png at the front because it is best
49 int pngIndex = formats.indexOf(QLatin1String("image/png"));
50 if (pngIndex != -1 && pngIndex != 0)
51 formats.move(pngIndex, 0);
52 return formats;
53}
54
55static inline QStringList imageReadMimeFormats()
56{
57 return imageMimeFormats(QImageReader::supportedImageFormats());
58}
59
60static inline QStringList imageWriteMimeFormats()
61{
62 return imageMimeFormats(QImageWriter::supportedImageFormats());
63}
64// end copied
65
66class DataControlDeviceManager : public QWaylandClientExtensionTemplate<DataControlDeviceManager>, public QtWayland::zwlr_data_control_manager_v1
67{
68 Q_OBJECT
69public:
70 DataControlDeviceManager()
71 : QWaylandClientExtensionTemplate<DataControlDeviceManager>(2)
72 {
73 }
74
75 void instantiate()
76 {
77 initialize();
78 }
79
80 ~DataControlDeviceManager()
81 {
82 if (isInitialized()) {
83 destroy();
84 }
85 }
86};
87
88class DataControlOffer : public QMimeData, public QtWayland::zwlr_data_control_offer_v1
89{
91public:
92 DataControlOffer(struct ::zwlr_data_control_offer_v1 *id)
93 : QtWayland::zwlr_data_control_offer_v1(id)
94 {
95 }
96
97 ~DataControlOffer()
98 {
99 destroy();
100 }
101
102 QStringList formats() const override
103 {
104 return m_receivedFormats;
105 }
106
107 bool containsImageData() const
108 {
109 if (m_receivedFormats.contains(applicationQtXImageLiteral())) {
110 return true;
111 }
112 const auto formats = imageReadMimeFormats();
113 for (const auto &receivedFormat : m_receivedFormats) {
114 if (formats.contains(receivedFormat)) {
115 return true;
116 }
117 }
118 return false;
119 }
120
121 bool hasFormat(const QString &mimeType) const override
122 {
123 if (mimeType == QStringLiteral("text/plain") && m_receivedFormats.contains(utf8Text())) {
124 return true;
125 }
126 if (m_receivedFormats.contains(mimeType)) {
127 return true;
128 }
129
130 // If we have image data
131 if (containsImageData()) {
132 // is the requested output mimeType supported ?
133 const QStringList imageFormats = imageWriteMimeFormats();
134 for (const QString &imageFormat : imageFormats) {
135 if (imageFormat == mimeType) {
136 return true;
137 }
138 }
139 if (mimeType == applicationQtXImageLiteral()) {
140 return true;
141 }
142 }
143
144 return false;
145 }
146
147protected:
148 void zwlr_data_control_offer_v1_offer(const QString &mime_type) override
149 {
150 m_receivedFormats << mime_type;
151 }
152
153 QVariant retrieveData(const QString &mimeType, QMetaType type) const override;
154
155private:
156 /** reads data from a file descriptor with a timeout of 1 second
157 * true if data is read successfully
158 */
159 static bool readData(int fd, QByteArray &data);
160 QStringList m_receivedFormats;
161};
162
163QVariant DataControlOffer::retrieveData(const QString &mimeType, QMetaType type) const
164{
165 Q_UNUSED(type);
166
167 QString mime;
168 if (!m_receivedFormats.contains(mimeType)) {
169 if (mimeType == QStringLiteral("text/plain") && m_receivedFormats.contains(utf8Text())) {
170 mime = utf8Text();
171 } else if (mimeType == applicationQtXImageLiteral()) {
172 const auto writeFormats = imageWriteMimeFormats();
173 for (const auto &receivedFormat : m_receivedFormats) {
174 if (writeFormats.contains(receivedFormat)) {
175 mime = receivedFormat;
176 break;
177 }
178 }
179 if (mime.isEmpty()) {
180 // default exchange format
181 mime = QStringLiteral("image/png");
182 }
183 }
184
185 if (mime.isEmpty()) {
186 return QVariant();
187 }
188 } else {
189 mime = mimeType;
190 }
191
192 int pipeFds[2];
193 if (pipe(pipeFds) != 0) {
194 return QVariant();
195 }
196
197 auto t = const_cast<DataControlOffer *>(this);
198 t->receive(mime, pipeFds[1]);
199
200 close(pipeFds[1]);
201
202 /*
203 * Ideally we need to introduce a non-blocking QMimeData object
204 * Or a non-blocking constructor to QMimeData with the mimetypes that are relevant
205 *
206 * However this isn't actually any worse than X.
207 */
208
209 QPlatformNativeInterface *native = qGuiApp->platformNativeInterface();
210 auto display = static_cast<struct ::wl_display *>(native->nativeResourceForIntegration("wl_display"));
211 wl_display_flush(display);
212
213 QFile readPipe;
214 if (readPipe.open(pipeFds[0], QIODevice::ReadOnly)) {
216 if (readData(pipeFds[0], data)) {
217 close(pipeFds[0]);
218
219 if (mimeType == applicationQtXImageLiteral()) {
220 QImage img = QImage::fromData(data, mime.mid(mime.indexOf(QLatin1Char('/')) + 1).toLatin1().toUpper().data());
221 if (!img.isNull()) {
222 return img;
223 }
224 }
225 return data;
226 }
227 close(pipeFds[0]);
228 }
229 return QVariant();
230}
231
232bool DataControlOffer::readData(int fd, QByteArray &data)
233{
234 pollfd pfds[1];
235 pfds[0].fd = fd;
236 pfds[0].events = POLLIN;
237
238 while (true) {
239 const int ready = poll(pfds, 1, 1000);
240 if (ready < 0) {
241 if (errno != EINTR) {
242 qWarning("DataControlOffer: poll() failed: %s", strerror(errno));
243 return false;
244 }
245 } else if (ready == 0) {
246 qWarning("DataControlOffer: timeout reading from pipe");
247 return false;
248 } else {
249 char buf[4096];
250 int n = read(fd, buf, sizeof buf);
251
252 if (n < 0) {
253 qWarning("DataControlOffer: read() failed: %s", strerror(errno));
254 return false;
255 } else if (n == 0) {
256 return true;
257 } else if (n > 0) {
258 data.append(buf, n);
259 }
260 }
261 }
262}
263
264class DataControlSource : public QObject, public QtWayland::zwlr_data_control_source_v1
265{
267public:
268 DataControlSource(struct ::zwlr_data_control_source_v1 *id, QMimeData *mimeData);
269 DataControlSource() = default;
270 ~DataControlSource()
271 {
272 destroy();
273 }
274
275 QMimeData *mimeData()
276 {
277 return m_mimeData.get();
278 }
279 std::unique_ptr<QMimeData> releaseMimeData()
280 {
281 return std::move(m_mimeData);
282 }
283
285 void cancelled();
286
287protected:
288 void zwlr_data_control_source_v1_send(const QString &mime_type, int32_t fd) override;
289 void zwlr_data_control_source_v1_cancelled() override;
290
291private:
292 std::unique_ptr<QMimeData> m_mimeData;
293};
294
295DataControlSource::DataControlSource(struct ::zwlr_data_control_source_v1 *id, QMimeData *mimeData)
296 : QtWayland::zwlr_data_control_source_v1(id)
297 , m_mimeData(mimeData)
298{
299 const auto formats = mimeData->formats();
300 for (const QString &format : formats) {
301 offer(format);
302 }
303 if (mimeData->hasText()) {
304 // ensure GTK applications get this mimetype to avoid them discarding the offer
305 offer(QStringLiteral("text/plain;charset=utf-8"));
306 }
307
308 if (mimeData->hasImage()) {
309 const QStringList imageFormats = imageWriteMimeFormats();
310 for (const QString &imageFormat : imageFormats) {
311 if (!formats.contains(imageFormat)) {
312 offer(imageFormat);
313 }
314 }
315 }
316}
317
318void DataControlSource::zwlr_data_control_source_v1_send(const QString &mime_type, int32_t fd)
319{
320 QString send_mime_type = mime_type;
321 if (send_mime_type == QStringLiteral("text/plain;charset=utf-8")) {
322 // if we get a request on the fallback mime, send the data from the original mime type
323 send_mime_type = QStringLiteral("text/plain");
324 }
325
326 QByteArray ba;
327 if (m_mimeData->hasImage()) {
328 // adapted from QInternalMimeData::renderDataHelper
329 if (mime_type == applicationQtXImageLiteral()) {
330 QImage image = qvariant_cast<QImage>(m_mimeData->imageData());
331 QBuffer buf(&ba);
332 buf.open(QBuffer::WriteOnly);
333 // would there not be PNG ??
334 image.save(&buf, "PNG");
335
336 } else if (mime_type.startsWith(QLatin1String("image/"))) {
337 QImage image = qvariant_cast<QImage>(m_mimeData->imageData());
338 QBuffer buf(&ba);
339 buf.open(QBuffer::WriteOnly);
340 image.save(&buf, mime_type.mid(mime_type.indexOf(QLatin1Char('/')) + 1).toLatin1().toUpper().data());
341 }
342 // end adapted
343 } else {
344 ba = m_mimeData->data(send_mime_type);
345 }
346
347 // Create a sigpipe handler that does nothing, or clients may be forced to terminate
348 // if the pipe is closed in the other end.
349 struct sigaction action, oldAction;
350 action.sa_handler = SIG_IGN;
351 sigemptyset(&action.sa_mask);
352 action.sa_flags = 0;
353 sigaction(SIGPIPE, &action, &oldAction);
354 write(fd, ba.constData(), ba.size());
355 sigaction(SIGPIPE, &oldAction, nullptr);
356 close(fd);
357}
358
359void DataControlSource::zwlr_data_control_source_v1_cancelled()
360{
361 Q_EMIT cancelled();
362}
363
364class DataControlDevice : public QObject, public QtWayland::zwlr_data_control_device_v1
365{
367public:
368 DataControlDevice(struct ::zwlr_data_control_device_v1 *id)
369 : QtWayland::zwlr_data_control_device_v1(id)
370 {
371 }
372
373 ~DataControlDevice()
374 {
375 destroy();
376 }
377
378 void setSelection(std::unique_ptr<DataControlSource> selection);
379 QMimeData *receivedSelection()
380 {
381 return m_receivedSelection.get();
382 }
383 QMimeData *selection()
384 {
385 return m_selection ? m_selection->mimeData() : nullptr;
386 }
387
388 void setPrimarySelection(std::unique_ptr<DataControlSource> selection);
389 QMimeData *receivedPrimarySelection()
390 {
391 return m_receivedPrimarySelection.get();
392 }
393 QMimeData *primarySelection()
394 {
395 return m_primarySelection ? m_primarySelection->mimeData() : nullptr;
396 }
397
399 void receivedSelectionChanged();
400 void selectionChanged();
401
402 void receivedPrimarySelectionChanged();
403 void primarySelectionChanged();
404
405protected:
406 void zwlr_data_control_device_v1_data_offer(struct ::zwlr_data_control_offer_v1 *id) override
407 {
408 // this will become memory managed when we retrieve the selection event
409 // a compositor calling data_offer without doing that would be a bug
410 new DataControlOffer(id);
411 }
412
413 void zwlr_data_control_device_v1_selection(struct ::zwlr_data_control_offer_v1 *id) override
414 {
415 if (!id) {
416 m_receivedSelection.reset();
417 } else {
418 auto derivated = QtWayland::zwlr_data_control_offer_v1::fromObject(id);
419 auto offer = dynamic_cast<DataControlOffer *>(derivated); // dynamic because of the dual inheritance
420 m_receivedSelection.reset(offer);
421 }
422 Q_EMIT receivedSelectionChanged();
423 }
424
425 void zwlr_data_control_device_v1_primary_selection(struct ::zwlr_data_control_offer_v1 *id) override
426 {
427 if (!id) {
428 m_receivedPrimarySelection.reset();
429 } else {
430 auto derivated = QtWayland::zwlr_data_control_offer_v1::fromObject(id);
431 auto offer = dynamic_cast<DataControlOffer *>(derivated); // dynamic because of the dual inheritance
432 m_receivedPrimarySelection.reset(offer);
433 }
434 Q_EMIT receivedPrimarySelectionChanged();
435 }
436
437private:
438 std::unique_ptr<DataControlSource> m_selection; // selection set locally
439 std::unique_ptr<DataControlOffer> m_receivedSelection; // latest selection set from externally to here
440
441 std::unique_ptr<DataControlSource> m_primarySelection; // selection set locally
442 std::unique_ptr<DataControlOffer> m_receivedPrimarySelection; // latest selection set from externally to here
443 friend WaylandClipboard;
444};
445
446void DataControlDevice::setSelection(std::unique_ptr<DataControlSource> selection)
447{
448 m_selection = std::move(selection);
449 connect(m_selection.get(), &DataControlSource::cancelled, this, [this]() {
450 m_selection.reset();
451 });
452 set_selection(m_selection->object());
453 Q_EMIT selectionChanged();
454}
455
456void DataControlDevice::setPrimarySelection(std::unique_ptr<DataControlSource> selection)
457{
458 m_primarySelection = std::move(selection);
459 connect(m_primarySelection.get(), &DataControlSource::cancelled, this, [this]() {
460 m_primarySelection.reset();
461 });
462
463 if (zwlr_data_control_device_v1_get_version(object()) >= ZWLR_DATA_CONTROL_DEVICE_V1_SET_PRIMARY_SELECTION_SINCE_VERSION) {
464 set_primary_selection(m_primarySelection->object());
465 Q_EMIT primarySelectionChanged();
466 }
467}
468class Keyboard;
469// We are binding to Seat/Keyboard manually because we want to react to gaining focus but inside Qt the events are Qt and arrive to late
470class KeyboardFocusWatcher : public QWaylandClientExtensionTemplate<KeyboardFocusWatcher>, public QtWayland::wl_seat
471{
472 Q_OBJECT
473public:
474 KeyboardFocusWatcher()
475 : QWaylandClientExtensionTemplate(5)
476 {
477 initialize();
478 auto native = qGuiApp->platformNativeInterface();
479 auto display = static_cast<struct ::wl_display *>(native->nativeResourceForIntegration("wl_display"));
480 // so we get capabilities
481 wl_display_roundtrip(display);
482 }
483 ~KeyboardFocusWatcher() override
484 {
485 if (isActive()) {
486 release();
487 }
488 }
489 void seat_capabilities(uint32_t capabilities) override
490 {
491 const bool hasKeyboard = capabilities & capability_keyboard;
492 if (hasKeyboard && !m_keyboard) {
493 m_keyboard = std::make_unique<Keyboard>(get_keyboard(), *this);
494 } else if (!hasKeyboard && m_keyboard) {
495 m_keyboard.reset();
496 }
497 }
498 bool hasFocus() const
499 {
500 return m_focus;
501 }
502Q_SIGNALS:
503 void keyboardEntered();
504
505private:
506 friend Keyboard;
507 bool m_focus = false;
508 std::unique_ptr<Keyboard> m_keyboard;
509};
510
511class Keyboard : public QtWayland::wl_keyboard
512{
513public:
514 Keyboard(::wl_keyboard *keyboard, KeyboardFocusWatcher &seat)
515 : wl_keyboard(keyboard)
516 , m_seat(seat)
517 {
518 }
519 ~Keyboard()
520 {
521 release();
522 }
523
524private:
525 void keyboard_enter([[maybe_unused]] uint32_t serial, [[maybe_unused]] wl_surface *surface, [[maybe_unused]] wl_array *keys) override
526 {
527 m_seat.m_focus = true;
528 Q_EMIT m_seat.keyboardEntered();
529 }
530 void keyboard_leave([[maybe_unused]] uint32_t serial, [[maybe_unused]] wl_surface *surface) override
531 {
532 m_seat.m_focus = false;
533 }
534 KeyboardFocusWatcher &m_seat;
535};
536
537WaylandClipboard::WaylandClipboard(QObject *parent)
538 : KSystemClipboard(parent)
539 , m_keyboardFocusWatcher(new KeyboardFocusWatcher)
540 , m_manager(new DataControlDeviceManager)
541{
542 connect(m_manager.get(), &DataControlDeviceManager::activeChanged, this, [this]() {
543 if (m_manager->isActive()) {
544 QPlatformNativeInterface *native = qApp->platformNativeInterface();
545 if (!native) {
546 return;
547 }
548 auto seat = static_cast<struct ::wl_seat *>(native->nativeResourceForIntegration("wl_seat"));
549 if (!seat) {
550 return;
551 }
552 m_device.reset(new DataControlDevice(m_manager->get_data_device(seat)));
553
554 connect(m_device.get(), &DataControlDevice::receivedSelectionChanged, this, [this]() {
555 // When our source is still valid, so the offer is for setting it or we emit changed when it is cancelled
556 if (!m_device->selection()) {
557 Q_EMIT changed(QClipboard::Clipboard);
558 }
559 });
560 connect(m_device.get(), &DataControlDevice::selectionChanged, this, [this]() {
561 Q_EMIT changed(QClipboard::Clipboard);
562 });
563
564 connect(m_device.get(), &DataControlDevice::receivedPrimarySelectionChanged, this, [this]() {
565 // When our source is still valid, so the offer is for setting it or we emit changed when it is cancelled
566 if (!m_device->primarySelection()) {
567 Q_EMIT changed(QClipboard::Selection);
568 }
569 });
570 connect(m_device.get(), &DataControlDevice::primarySelectionChanged, this, [this]() {
571 Q_EMIT changed(QClipboard::Selection);
572 });
573
574 } else {
575 m_device.reset();
576 }
577 });
578
579 m_manager->instantiate();
580}
581
582WaylandClipboard::~WaylandClipboard() = default;
583
584bool WaylandClipboard::isValid()
585{
586 return m_manager && m_manager->isInitialized();
587}
588
589void WaylandClipboard::setMimeData(QMimeData *mime, QClipboard::Mode mode)
590{
591 if (!m_device) {
592 return;
593 }
594
595 // roundtrip to have accurate focus state when losing focus but setting mime data before processing wayland events.
596 auto native = qGuiApp->platformNativeInterface();
597 auto display = static_cast<struct ::wl_display *>(native->nativeResourceForIntegration("wl_display"));
598 wl_display_roundtrip(display);
599
600 // If the application is focused, use the normal mechanism so a future paste will not deadlock itselfs
601 if (m_keyboardFocusWatcher->hasFocus()) {
603 // if we short-circuit the wlr_data_device, when we receive the data
604 // we cannot identify ourselves as the owner
605 // because of that we act like it's a synchronous action to not confuse klipper.
606 wl_display_roundtrip(display);
607 return;
608 }
609 // If not, set the clipboard once the app receives focus to avoid the deadlock
610 connect(m_keyboardFocusWatcher.get(), &KeyboardFocusWatcher::keyboardEntered, this, &WaylandClipboard::gainedFocus, Qt::UniqueConnection);
611 auto source = std::make_unique<DataControlSource>(m_manager->create_data_source(), mime);
612 if (mode == QClipboard::Clipboard) {
613 m_device->setSelection(std::move(source));
614 } else if (mode == QClipboard::Selection) {
615 m_device->setPrimarySelection(std::move(source));
616 }
617}
618
619void WaylandClipboard::gainedFocus()
620{
621 disconnect(m_keyboardFocusWatcher.get(), &KeyboardFocusWatcher::keyboardEntered, this, nullptr);
622 // QClipboard takes ownership of the QMimeData so we need to transfer and unset our selections
623 if (auto &selection = m_device->m_selection) {
624 std::unique_ptr<QMimeData> data = selection->releaseMimeData();
625 WaylandClipboard::clear(QClipboard::Clipboard);
627 }
628 if (auto &primarySelection = m_device->m_primarySelection) {
629 std::unique_ptr<QMimeData> data = primarySelection->releaseMimeData();
630 WaylandClipboard::clear(QClipboard::Selection);
632 }
633}
634
635void WaylandClipboard::clear(QClipboard::Mode mode)
636{
637 if (!m_device) {
638 return;
639 }
640 if (mode == QClipboard::Clipboard) {
641 m_device->set_selection(nullptr);
642 m_device->m_selection.reset();
643 } else if (mode == QClipboard::Selection) {
644 if (zwlr_data_control_device_v1_get_version(m_device->object()) >= ZWLR_DATA_CONTROL_DEVICE_V1_SET_PRIMARY_SELECTION_SINCE_VERSION) {
645 m_device->set_primary_selection(nullptr);
646 m_device->m_primarySelection.reset();
647 }
648 }
649}
650
651const QMimeData *WaylandClipboard::mimeData(QClipboard::Mode mode) const
652{
653 if (!m_device) {
654 return nullptr;
655 }
656
657 // return our locally set selection if it's not cancelled to avoid copying data to ourselves
658 if (mode == QClipboard::Clipboard) {
659 if (m_device->selection()) {
660 return m_device->selection();
661 }
662 // This application owns the clipboard via the regular data_device, use it so we don't block ourselves
663 if (QGuiApplication::clipboard()->ownsClipboard()) {
664 return QGuiApplication::clipboard()->mimeData(mode);
665 }
666 return m_device->receivedSelection();
667 } else if (mode == QClipboard::Selection) {
668 if (m_device->primarySelection()) {
669 return m_device->primarySelection();
670 }
671 // This application owns the primary selection via the regular primary_selection_device, use it so we don't block ourselves
672 if (QGuiApplication::clipboard()->ownsSelection()) {
673 return QGuiApplication::clipboard()->mimeData(mode);
674 }
675 return m_device->receivedPrimarySelection();
676 }
677 return nullptr;
678}
679
680#include "waylandclipboard.moc"
This class mimics QClipboard but unlike QClipboard it will continue to get updates even when our wind...
KCALUTILS_EXPORT QString mimeType()
Capabilities capabilities()
QVariant read(const QByteArray &data, int versionOverride=0)
QAction * close(const QObject *recvr, const char *slot, QObject *parent)
void initialize(StandardShortcut id)
QByteArray & append(QByteArrayView data)
const char * constData() const const
qsizetype size() const const
const QMimeData * mimeData(Mode mode) const const
void setMimeData(QMimeData *src, Mode mode)
bool open(FILE *fh, OpenMode mode, FileHandleFlags handleFlags)
QClipboard * clipboard()
QImage fromData(QByteArrayView data, const char *format)
bool isNull() const const
bool save(QIODevice *device, const char *format, int quality) const const
QList< QByteArray > supportedImageFormats()
QList< QByteArray > supportedImageFormats()
void move(qsizetype from, qsizetype to)
void reserve(qsizetype size)
qsizetype size() const const
QByteArray data(const QString &mimeType) const const
virtual QStringList formats() const const
bool hasImage() const const
bool hasText() const const
Q_EMITQ_EMIT
Q_OBJECTQ_OBJECT
Q_SIGNALSQ_SIGNALS
QMetaObject::Connection connect(const QObject *sender, PointerToMemberFunction signal, Functor functor)
qsizetype indexOf(QChar ch, qsizetype from, Qt::CaseSensitivity cs) const const
bool isEmpty() const const
QString mid(qsizetype position, qsizetype n) const const
bool startsWith(QChar c, Qt::CaseSensitivity cs) const const
bool contains(QLatin1StringView str, Qt::CaseSensitivity cs) const const
qsizetype indexOf(const QRegularExpression &re, qsizetype from) const const
UniqueConnection
QFuture< ArgsType< Signal > > connect(Sender *sender, Signal signal)
This file is part of the KDE documentation.
Documentation copyright © 1996-2024 The KDE developers.
Generated on Fri Jul 19 2024 11:59:55 by doxygen 1.11.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.