diff --git a/src/ExportManager.cpp b/src/ExportManager.cpp index 3d1007f..10df576 100644 --- a/src/ExportManager.cpp +++ b/src/ExportManager.cpp @@ -1,514 +1,514 @@ /* * Copyright (C) 2015 Boudhayan Gupta * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, * Boston, MA 02110-1301, USA. */ #include "ExportManager.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "SpectacleConfig.h" ExportManager::ExportManager(QObject *parent) : QObject(parent), mSavePixmap(QPixmap()), mTempFile(QUrl()), mTempDir(nullptr) { connect(this, &ExportManager::imageSaved, [this](const QUrl savedAt) { SpectacleConfig::instance()->setLastSaveFile(savedAt); }); } ExportManager::~ExportManager() { delete mTempDir; } ExportManager* ExportManager::instance() { static ExportManager instance; return &instance; } // screenshot pixmap setter and getter QPixmap ExportManager::pixmap() const { return mSavePixmap; } void ExportManager::setWindowTitle(const QString &windowTitle) { mWindowTitle = windowTitle; } QString ExportManager::windowTitle() const { return mWindowTitle; } ImageGrabber::GrabMode ExportManager::grabMode() const { return mGrabMode; } void ExportManager::setGrabMode(const ImageGrabber::GrabMode &grabMode) { mGrabMode = grabMode; } void ExportManager::setPixmap(const QPixmap &pixmap) { mSavePixmap = pixmap; // reset our saved tempfile if (mTempFile.isValid()) { mUsedTempFileNames.append(mTempFile); QFile file(mTempFile.toLocalFile()); file.remove(); mTempFile = QUrl(); } } void ExportManager::updatePixmapTimestamp() { mPixmapTimestamp = QDateTime::currentDateTime(); } // native file save helpers QString ExportManager::defaultSaveLocation() const { QString savePath = SpectacleConfig::instance()->defaultSaveLocation(); if (savePath.isEmpty() || savePath.isNull()) { savePath = QDir::homePath(); } savePath = QDir::cleanPath(savePath); QDir savePathDir(savePath); if (!(savePathDir.exists())) { savePathDir.mkpath(QStringLiteral(".")); SpectacleConfig::instance()->setDefaultSaveLocation(savePath); } return savePath; } QUrl ExportManager::getAutosaveFilename() { const QString baseDir = defaultSaveLocation(); const QDir baseDirPath(baseDir); const QString filename = makeAutosaveFilename(); const QString fullpath = autoIncrementFilename(baseDirPath.filePath(filename), SpectacleConfig::instance()->saveImageFormat(), &ExportManager::isFileExists); const QUrl fileNameUrl = QUrl::fromUserInput(fullpath); if (fileNameUrl.isValid()) { return fileNameUrl; } else { return QUrl(); } } QString ExportManager::truncatedFilename(QString const &filename) { QString result = filename; constexpr auto maxFilenameLength = 255; constexpr auto maxExtensionLength = 5; // For example, ".jpeg" constexpr auto maxCounterLength = 20; // std::numeric_limits::max() == 18446744073709551615 constexpr auto maxLength = maxFilenameLength - maxCounterLength - maxExtensionLength; result.truncate(maxLength); return result; } QString ExportManager::makeAutosaveFilename() { const QDateTime timestamp = mPixmapTimestamp; QString baseName = SpectacleConfig::instance()->autoSaveFilenameFormat(); const QString baseDir = defaultSaveLocation(); QString title; if (mGrabMode == ImageGrabber::GrabMode::ActiveWindow || mGrabMode == ImageGrabber::GrabMode::TransientWithParent || mGrabMode == ImageGrabber::GrabMode::WindowUnderCursor) { title = mWindowTitle.replace(QLatin1String("/"), QLatin1String("_")); // POSIX doesn't allow "/" in filenames } else { // Remove '%T' with separators around it const auto wordSymbol = QStringLiteral(R"(\p{L}\p{M}\p{N})"); const auto separator = QStringLiteral("([^%1]+)").arg(wordSymbol); const auto re = QRegularExpression(QStringLiteral("(.*?)(%1%T|%T%1)(.*?)").arg(separator)); baseName.replace(re, QStringLiteral(R"(\1\5)")); } QString result = baseName.replace(QLatin1String("%Y"), timestamp.toString(QStringLiteral("yyyy"))) .replace(QLatin1String("%y"), timestamp.toString(QStringLiteral("yy"))) .replace(QLatin1String("%M"), timestamp.toString(QStringLiteral("MM"))) .replace(QLatin1String("%D"), timestamp.toString(QStringLiteral("dd"))) .replace(QLatin1String("%H"), timestamp.toString(QStringLiteral("hh"))) .replace(QLatin1String("%m"), timestamp.toString(QStringLiteral("mm"))) .replace(QLatin1String("%S"), timestamp.toString(QStringLiteral("ss"))) .replace(QLatin1String("%T"), title); // check if basename includes %[N]d token for sequential file numbering QRegularExpression paddingRE; paddingRE.setPattern(QLatin1String("%(\\d*)d")); QRegularExpressionMatch paddingMatch; if (result.indexOf(paddingRE, 0, &paddingMatch) > -1) { int highestFileNumber = 0; // determine padding value int paddedLength = 1; if (!paddingMatch.captured(1).isEmpty()) { paddedLength = paddingMatch.captured(1).toInt(); } - // seach save directory for files + // search save directory for files QDir dir(baseDir); const QStringList fileNames = dir.entryList(QDir::Files, QDir::Name); // if there are files in the directory... if (fileNames.length() > 0) { QString resultCopy = result; QRegularExpression fileNumberRE; const QString replacement = QString::fromLatin1("(\\d{").append(QString::number(paddedLength)).append(QString::fromLatin1(",})")); const QString fullNameMatch = QStringLiteral("^").append(resultCopy.replace(paddingMatch.captured(),replacement)).append(QStringLiteral("\\..*$")); fileNumberRE.setPattern(fullNameMatch); // ... check the file names for string matching token with padding specified in result const QStringList filteredFiles = fileNames.filter(fileNumberRE); // if there are files in the direcory that look like the file name with sequential numbering if (filteredFiles.length() > 0) { // loop through filtered file names looking for highest number for (const QString &filteredFile: filteredFiles) { int currentFileNumber = fileNumberRE.match(filteredFile).captured(1).toInt(); if (currentFileNumber > highestFileNumber) { highestFileNumber = currentFileNumber; } } } } // replace placeholder with next number padded const QString nextFileNumberPadded = QString::number(highestFileNumber + 1).rightJustified(paddedLength, QLatin1Char('0')); result = result.replace(paddingMatch.captured(), nextFileNumberPadded); } // Remove leading and trailing '/' while (result.startsWith(QLatin1Char('/'))) { result.remove(0, 1); } while (result.endsWith(QLatin1Char('/'))) { result.chop(1); } if (result.isEmpty()) { result = SpectacleConfig::instance()->defaultFilename(); } return truncatedFilename(result); } QString ExportManager::autoIncrementFilename(const QString &baseName, const QString &extension, FileNameAlreadyUsedCheck isFileNameUsed) { QString result = truncatedFilename(baseName) + QLatin1Literal(".") + extension; if (!((this->*isFileNameUsed)(QUrl::fromUserInput(result)))) { return result; } QString fileNameFmt = truncatedFilename(baseName) + QStringLiteral("-%1."); for (quint64 i = 1; i < std::numeric_limits::max(); i++) { result = fileNameFmt.arg(i) + extension; if (!((this->*isFileNameUsed)(QUrl::fromUserInput(result)))) { return result; } } // unlikely this will ever happen, but just in case we've run // out of numbers result = fileNameFmt.arg(QStringLiteral("OVERFLOW-") + QString::number(qrand() % 10000)); return truncatedFilename(result) + extension; } QString ExportManager::makeSaveMimetype(const QUrl &url) { QMimeDatabase mimedb; QString type = mimedb.mimeTypeForUrl(url).preferredSuffix(); if (type.isEmpty()) { return SpectacleConfig::instance()->saveImageFormat(); } return type; } bool ExportManager::writeImage(QIODevice *device, const QByteArray &format) { QImageWriter imageWriter(device, format); if (!(imageWriter.canWrite())) { emit errorMessage(i18n("QImageWriter cannot write image: %1", imageWriter.errorString())); return false; } return imageWriter.write(mSavePixmap.toImage()); } bool ExportManager::localSave(const QUrl &url, const QString &mimetype) { // Create save directory if it doesn't exist const QUrl dirPath(url.adjusted(QUrl::RemoveFilename)); const QDir dir(dirPath.path()); if (!dir.mkpath(QLatin1String("."))) { emit errorMessage(xi18nc("@info", "Cannot save screenshot because creating " "the directory failed:%1", dirPath.path())); return false; } QFile outputFile(url.toLocalFile()); outputFile.open(QFile::WriteOnly); if(!writeImage(&outputFile, mimetype.toLatin1())) { emit errorMessage(i18n("Cannot save screenshot. Error while writing file.")); return false; } return true; } bool ExportManager::remoteSave(const QUrl &url, const QString &mimetype) { // Check if remote save directory exists const QUrl dirPath(url.adjusted(QUrl::RemoveFilename)); KIO::ListJob *listJob = KIO::listDir(dirPath); listJob->exec(); if (listJob->error() != KJob::NoError) { // Create remote save directory KIO::MkpathJob *mkpathJob = KIO::mkpath(dirPath, QUrl(defaultSaveLocation())); mkpathJob->exec(); if (mkpathJob->error() != KJob::NoError) { emit errorMessage(xi18nc("@info", "Cannot save screenshot because creating the " "remote directory failed:%1", dirPath.path())); return false; } } QTemporaryFile tmpFile; if (tmpFile.open()) { if(!writeImage(&tmpFile, mimetype.toLatin1())) { emit errorMessage(i18n("Cannot save screenshot. Error while writing temporary local file.")); return false; } KIO::FileCopyJob *uploadJob = KIO::file_copy(QUrl::fromLocalFile(tmpFile.fileName()), url); uploadJob->exec(); if (uploadJob->error() != KJob::NoError) { emit errorMessage(i18n("Unable to save image. Could not upload file to remote location.")); return false; } return true; } return false; } QUrl ExportManager::tempSave(const QString &mimetype) { // if we already have a temp file saved, use that if (mTempFile.isValid()) { if (QFile(mTempFile.toLocalFile()).exists()) { return mTempFile; } } if (!mTempDir) { mTempDir = new QTemporaryDir(QDir::tempPath() + QDir::separator() + QStringLiteral("Spectacle.XXXXXX")); } if (mTempDir && mTempDir->isValid()) { // create the temporary file itself with normal file name and also unique one for this session // supports the use-case of creating multiple screenshots in a row // and exporting them to the same destination e.g. via clipboard, // where the temp file name is used as filename suggestion const QString baseFileName = mTempDir->path() + QDir::separator() + makeAutosaveFilename(); const QString fileName = autoIncrementFilename(baseFileName, mimetype, &ExportManager::isTempFileAlreadyUsed); QFile tmpFile(fileName); if (tmpFile.open(QFile::WriteOnly)) { if(writeImage(&tmpFile, mimetype.toLatin1())) { mTempFile = QUrl::fromLocalFile(tmpFile.fileName()); // try to make sure 3rd-party which gets the url of the temporary file e.g. on export // properly treats this as readonly, also hide from other users tmpFile.setPermissions(QFile::ReadUser); return mTempFile; } } } emit errorMessage(i18n("Cannot save screenshot. Error while writing temporary local file.")); return QUrl(); } bool ExportManager::save(const QUrl &url) { if (!(url.isValid())) { emit errorMessage(i18n("Cannot save screenshot. The save filename is invalid.")); return false; } QString mimetype = makeSaveMimetype(url); if (url.isLocalFile()) { return localSave(url, mimetype); } return remoteSave(url, mimetype); } bool ExportManager::isFileExists(const QUrl &url) const { if (!(url.isValid())) { return false; } KIO::StatJob * existsJob = KIO::stat(url, KIO::StatJob::DestinationSide, 0); existsJob->exec(); return (existsJob->error() == KJob::NoError); } bool ExportManager::isTempFileAlreadyUsed(const QUrl &url) const { return mUsedTempFileNames.contains(url); } // save slots void ExportManager::doSave(const QUrl &url, bool notify) { if (mSavePixmap.isNull()) { emit errorMessage(i18n("Cannot save an empty screenshot image.")); return; } QUrl savePath = url.isValid() ? url : getAutosaveFilename(); if (save(savePath)) { QDir dir(savePath.path()); dir.cdUp(); SpectacleConfig::instance()->setLastSaveFile(savePath); emit imageSaved(savePath); if (notify) { emit forceNotify(savePath); } } } bool ExportManager::doSaveAs(QWidget *parentWindow, bool notify) { QStringList supportedFilters; SpectacleConfig *config = SpectacleConfig::instance(); // construct the supported mimetype list Q_FOREACH (auto mimeType, QImageWriter::supportedMimeTypes()) { supportedFilters.append(QString::fromUtf8(mimeType).trimmed()); } // construct the file name const QString filenameExtension = SpectacleConfig::instance()->saveImageFormat(); const QString mimetype = QMimeDatabase().mimeTypeForFile(QStringLiteral("~/fakefile.") + filenameExtension, QMimeDatabase::MatchExtension).name(); QFileDialog dialog(parentWindow); dialog.setAcceptMode(QFileDialog::AcceptSave); dialog.setFileMode(QFileDialog::AnyFile); dialog.setDirectoryUrl(config->lastSaveAsLocation()); dialog.selectFile(makeAutosaveFilename() + QStringLiteral(".") + filenameExtension); dialog.setDefaultSuffix(QStringLiteral(".") + filenameExtension); dialog.setMimeTypeFilters(supportedFilters); dialog.selectMimeTypeFilter(mimetype); // launch the dialog if (dialog.exec() == QFileDialog::Accepted) { const QUrl saveUrl = dialog.selectedUrls().first(); if (saveUrl.isValid()) { if (save(saveUrl)) { emit imageSaved(saveUrl); config->setLastSaveAsFile(saveUrl); if (notify) { emit forceNotify(saveUrl); } return true; } } } return false; } // misc helpers void ExportManager::doCopyToClipboard() { QApplication::clipboard()->setPixmap(mSavePixmap, QClipboard::Clipboard); } void ExportManager::doPrint(QPrinter *printer) { QPainter painter; if (!(painter.begin(printer))) { emit errorMessage(i18n("Printing failed. The printer failed to initialize.")); delete printer; return; } QRect devRect(0, 0, printer->width(), printer->height()); QPixmap pixmap = mSavePixmap.scaled(devRect.size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); QRect srcRect = pixmap.rect(); srcRect.moveCenter(devRect.center()); painter.drawPixmap(srcRect.topLeft(), pixmap); painter.end(); delete printer; return; } diff --git a/src/PlatformBackends/X11ImageGrabber.cpp b/src/PlatformBackends/X11ImageGrabber.cpp index 2e2ee60..f2651f3 100644 --- a/src/PlatformBackends/X11ImageGrabber.cpp +++ b/src/PlatformBackends/X11ImageGrabber.cpp @@ -1,752 +1,752 @@ /* * Copyright (C) 2015 Boudhayan Gupta * * Contains code from kxutils.cpp, part of KWindowSystem. Copyright * notices reproduced below: * * Copyright (C) 2008 Lubos Lunak (l.lunak@kde.org) * Copyright (C) 2013 Martin Gräßlin * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, * Boston, MA 02110-1301, USA. */ #include "X11ImageGrabber.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include X11ImageGrabber::X11ImageGrabber(QObject *parent) : ImageGrabber(parent) { mNativeEventFilter = new OnClickEventFilter(this); } X11ImageGrabber::~X11ImageGrabber() { delete mNativeEventFilter; } // for onClick grab OnClickEventFilter::OnClickEventFilter(X11ImageGrabber *grabber) : QAbstractNativeEventFilter(), mImageGrabber(grabber) {} bool OnClickEventFilter::nativeEventFilter(const QByteArray &eventType, void *message, long *result) { Q_UNUSED(result); if (eventType == "xcb_generic_event_t") { xcb_generic_event_t *ev = static_cast(message); switch (ev->response_type & ~0x80) { case XCB_BUTTON_RELEASE: // uninstall the eventfilter and release the mouse qApp->removeNativeEventFilter(this); xcb_ungrab_pointer(QX11Info::connection(), XCB_TIME_CURRENT_TIME); // decide whether to grab or abort. regrab the mouse // on mouse-wheel events { xcb_button_release_event_t *ev2 = static_cast(message); if (ev2->detail == 1) { QMetaObject::invokeMethod(mImageGrabber, "doImageGrab", Qt::QueuedConnection); } else if (ev2->detail < 4) { emit mImageGrabber->imageGrabFailed(); } else { QMetaObject::invokeMethod(mImageGrabber, "doOnClickGrab", Qt::QueuedConnection); } } return true; default: return false; } } return false; } bool X11ImageGrabber::onClickGrabSupported() const { return true; } void X11ImageGrabber::doOnClickGrab() { // get the cursor image xcb_cursor_t xcbCursor = XCB_CURSOR_NONE; xcb_cursor_context_t *xcbCursorCtx; xcb_screen_t *xcbAppScreen = xcb_aux_get_screen(QX11Info::connection(), QX11Info::appScreen()); if (xcb_cursor_context_new(QX11Info::connection(), xcbAppScreen, &xcbCursorCtx) >= 0) { QVector cursorNames = { QByteArrayLiteral("cross"), QByteArrayLiteral("crosshair"), QByteArrayLiteral("diamond-cross"), QByteArrayLiteral("cross-reverse") }; Q_FOREACH (const QByteArray &cursorName, cursorNames) { xcb_cursor_t cursor = xcb_cursor_load_cursor(xcbCursorCtx, cursorName.constData()); if (cursor != XCB_CURSOR_NONE) { xcbCursor = cursor; break; } } } // grab the cursor xcb_grab_pointer_cookie_t grabPointerCookie = xcb_grab_pointer_unchecked( QX11Info::connection(), // xcb connection 0, // deliver events to owner? nope QX11Info::appRootWindow(), // window to grab pointer for (root) XCB_EVENT_MASK_BUTTON_RELEASE, // which events do I want XCB_GRAB_MODE_SYNC, // pointer grab mode XCB_GRAB_MODE_ASYNC, // keyboard grab mode (why is this even here) XCB_NONE, // confine pointer to which window (none) xcbCursor, // cursor to change to for the duration of grab XCB_TIME_CURRENT_TIME // do this right now ); CScopedPointer grabPointerReply(xcb_grab_pointer_reply(QX11Info::connection(), grabPointerCookie, NULL)); // if the grab failed, take the screenshot right away if (grabPointerReply->status != XCB_GRAB_STATUS_SUCCESS) { return doImageGrab(); } // fix things if our pointer grab causes a lockup xcb_allow_events(QX11Info::connection(), XCB_ALLOW_SYNC_POINTER, XCB_TIME_CURRENT_TIME); // and install our event filter qApp->installNativeEventFilter(mNativeEventFilter); // done. clean stuff up xcb_cursor_context_free(xcbCursorCtx); xcb_free_cursor(QX11Info::connection(), xcbCursor); } // image conversion routine QPixmap X11ImageGrabber::convertFromNative(xcb_image_t *xcbImage) { QImage::Format format = QImage::Format_Invalid; switch (xcbImage->depth) { case 1: format = QImage::Format_MonoLSB; break; case 16: format = QImage::Format_RGB16; break; case 24: format = QImage::Format_RGB32; break; case 30: format = QImage::Format_BGR30; break; case 32: format = QImage::Format_ARGB32_Premultiplied; break; default: return QPixmap(); // we don't know } // The RGB32 format requires data format 0xffRRGGBB, ensure that this fourth byte really is 0xff if (format == QImage::Format_RGB32) { quint32 *data = reinterpret_cast(xcbImage->data); for (int i = 0; i < xcbImage->width * xcbImage->height; i++) { data[i] |= 0xff000000; } } QImage image(xcbImage->data, xcbImage->width, xcbImage->height, format); if (image.isNull()) { return QPixmap(); } // work around an abort in QImage::color if (image.format() == QImage::Format_MonoLSB) { image.setColorCount(2); image.setColor(0, QColor(Qt::white).rgb()); image.setColor(1, QColor(Qt::black).rgb()); } // Image is ready. Since the backing data from xcbImage could be freed // before the QPixmap goes away, a deep copy is necessary. return QPixmap::fromImage(image).copy(); } // utility functions // Note: x, y, width and height are measured in device pixels QPixmap X11ImageGrabber::blendCursorImage(const QPixmap &pixmap, int x, int y, int width, int height) { // If the cursor position lies outside the area, do not bother drawing a cursor. QPoint cursorPos = getNativeCursorPosition(); QRect screenRect(x, y, width, height); if (!screenRect.contains(cursorPos)) { return pixmap; } // now we can get the image and start processing xcb_connection_t *xcbConn = QX11Info::connection(); xcb_xfixes_get_cursor_image_cookie_t cursorCookie = xcb_xfixes_get_cursor_image_unchecked(xcbConn); CScopedPointer cursorReply(xcb_xfixes_get_cursor_image_reply(xcbConn, cursorCookie, NULL)); if (cursorReply.isNull()) { return pixmap; } quint32 *pixelData = xcb_xfixes_get_cursor_image_cursor_image(cursorReply.data()); if (!pixelData) { return pixmap; } // process the image into a QImage QImage cursorImage = QImage((quint8 *)pixelData, cursorReply->width, cursorReply->height, QImage::Format_ARGB32_Premultiplied); // a small fix for the cursor position for fancier cursors cursorPos -= QPoint(cursorReply->xhot, cursorReply->yhot); // now we translate the cursor point to our screen rectangle cursorPos -= QPoint(x, y); // and do the painting QPixmap blendedPixmap = pixmap; QPainter painter(&blendedPixmap); painter.drawImage(cursorPos, cursorImage); // and done return blendedPixmap; } QPixmap X11ImageGrabber::getPixmapFromDrawable(xcb_drawable_t drawableId, const QRect &rect) { xcb_connection_t *xcbConn = QX11Info::connection(); // proceed to get an image based on the geometry (in device pixels) QScopedPointer xcbImage( xcb_image_get( xcbConn, drawableId, rect.x(), rect.y(), rect.width(), rect.height(), ~0, XCB_IMAGE_FORMAT_Z_PIXMAP ) ); // too bad, the capture failed. if (xcbImage.isNull()) { return QPixmap(); } // now process the image QPixmap nativePixmap = convertFromNative(xcbImage.data()); return nativePixmap; } // finalize the grabbed pixmap where we know the absolute position QPixmap X11ImageGrabber::postProcessPixmap(QPixmap pixmap, QRect rect, bool blendPointer) { if (!(blendPointer)) { // note: this may be the null pixmap if an error occurred. return pixmap; } return blendCursorImage(pixmap, rect.x(), rect.y(), rect.width(), rect.height()); } QPixmap X11ImageGrabber::getToplevelPixmap(QRect rect, bool blendPointer) { xcb_window_t rootWindow = QX11Info::appRootWindow(); // Treat a null rect as an alias for capturing fullscreen if (!rect.isValid()) { rect = getDrawableGeometry(rootWindow); } else { QRegion screenRegion; for (auto screen : QGuiApplication::screens()) { QRect screenRect = screen->geometry(); // Do not use setSize() here, because QSize::operator*=() // performs qRound() which can result in xcb_image_get() failing const qreal dpr = screen->devicePixelRatio(); screenRect.setHeight(qFloor(screenRect.height() * dpr)); screenRect.setWidth(qFloor(screenRect.width() * dpr)); screenRegion += screenRect; } rect = (screenRegion & rect).boundingRect(); } QPixmap nativePixmap = getPixmapFromDrawable(rootWindow, rect); return postProcessPixmap(nativePixmap, rect, blendPointer); } QPixmap X11ImageGrabber::getWindowPixmap(xcb_window_t window, bool blendPointer) { xcb_connection_t *xcbConn = QX11Info::connection(); // first get geometry information for our window xcb_get_geometry_cookie_t geomCookie = xcb_get_geometry_unchecked(xcbConn, window); CScopedPointer geomReply(xcb_get_geometry_reply(xcbConn, geomCookie, NULL)); QRect rect(geomReply->x, geomReply->y, geomReply->width, geomReply->height); // then proceed to get an image QPixmap nativePixmap = getPixmapFromDrawable(window, rect); // Translate window coordinates to global ones. xcb_get_geometry_cookie_t geomRootCookie = xcb_get_geometry_unchecked(xcbConn, geomReply->root); CScopedPointer geomRootReply(xcb_get_geometry_reply(xcbConn, geomRootCookie, NULL)); xcb_translate_coordinates_cookie_t translateCookie = xcb_translate_coordinates_unchecked( xcbConn, window, geomReply->root, geomRootReply->x, geomRootReply->y); CScopedPointer translateReply( xcb_translate_coordinates_reply(xcbConn, translateCookie, NULL)); // Adjust local to global coordinates. rect.moveRight(rect.x() + translateReply->dst_x); rect.moveTop(rect.y() + translateReply->dst_y); // If the window capture failed, try to obtain one from the full screen. if (nativePixmap.isNull()) { return getToplevelPixmap(rect, blendPointer); } return postProcessPixmap(nativePixmap, rect, blendPointer); } bool X11ImageGrabber::isKWinAvailable() { if (QDBusConnection::sessionBus().interface()->isServiceRegistered(QStringLiteral("org.kde.KWin"))) { QDBusInterface interface(QStringLiteral("org.kde.KWin"), QStringLiteral("/Effects"), QStringLiteral("org.kde.kwin.Effects")); QDBusReply reply = interface.call(QStringLiteral("isEffectLoaded"), QStringLiteral("screenshot")); return reply.value(); } return false; } void X11ImageGrabber::KWinDBusScreenshotHelper(quint64 pixmapId) { // obtain width and height and grab an image (x and y are always zero for pixmaps) QRect rect = getDrawableGeometry((xcb_drawable_t)pixmapId); mPixmap = getPixmapFromDrawable((xcb_drawable_t)pixmapId, rect); if (!mPixmap.isNull()) { emit pixmapChanged(mPixmap); return; } // Cannot retrieve pixmap from KWin, just fallback to fullscreen capture. We // could try to detect the original action (window under cursor or active // window), but that is too complex for this edge case. grabFullScreen(); } void X11ImageGrabber::rectangleSelectionCancelled() { QObject *sender = QObject::sender(); sender->disconnect(); sender->deleteLater(); emit imageGrabFailed(); } void X11ImageGrabber::rectangleSelectionConfirmed(const QPixmap &pixmap) { QObject *sender = QObject::sender(); sender->disconnect(); sender->deleteLater(); mPixmap = pixmap; emit pixmapChanged(mPixmap); } // grabber methods void X11ImageGrabber::updateWindowTitle(xcb_window_t window) { QString windowTitle = KWindowSystem::readNameProperty(window, XA_WM_NAME); emit windowTitleChanged(windowTitle); } void X11ImageGrabber::grabFullScreen() { mPixmap = getToplevelPixmap(QRect(), mCapturePointer); emit pixmapChanged(mPixmap); } void X11ImageGrabber::grabTransientWithParent() { xcb_window_t curWin = getRealWindowUnderCursor(); updateWindowTitle(curWin); // grab the image early mPixmap = getToplevelPixmap(QRect(), false); // now that we know we have a transient window, let's // find other possible transient windows and the app window itself. QRegion clipRegion; QSet transients; xcb_window_t parentWinId = curWin; const QRect desktopRect(0, 0, 1, 1); do { // find parent window and add the window to the visible region xcb_window_t winId = parentWinId; QRect winRect; parentWinId = getTransientWindowParent(winId, winRect); transients << winId; // Don't include the 1x1 pixel sized desktop window in the top left corner that is present // if the window is a QDialog without a parent. // BUG: 376350 if (winRect != desktopRect) { clipRegion += winRect; } // Continue walking only if this is a transient window (having a parent) } while (parentWinId != XCB_WINDOW_NONE && !transients.contains(parentWinId)); // All parents are known now, find other transient children. // Assume that the lowest window is behind everything else, then if a new // transient window is discovered, its children can then also be found. QList winList = KWindowSystem::stackingOrder(); for (auto winId : winList) { QRect winRect; xcb_window_t parentWinId = getTransientWindowParent(winId, winRect); // if the parent should be displayed, then show the child too if (transients.contains(parentWinId)) { if (!transients.contains(winId)) { transients << winId; clipRegion += winRect; } } } // we can probably go ahead and generate the image now QImage tempImage(mPixmap.size(), QImage::Format_ARGB32); tempImage.fill(Qt::transparent); QPainter tempPainter(&tempImage); tempPainter.setClipRegion(clipRegion); tempPainter.drawPixmap(0, 0, mPixmap); tempPainter.end(); mPixmap = QPixmap::fromImage(tempImage).copy(clipRegion.boundingRect()); // why stop here, when we can render a 20px drop shadow all around it QGraphicsDropShadowEffect *effect = new QGraphicsDropShadowEffect; effect->setOffset(0); effect->setBlurRadius(20); QGraphicsPixmapItem *item = new QGraphicsPixmapItem; item->setPixmap(mPixmap); item->setGraphicsEffect(effect); QImage shadowImage(mPixmap.size() + QSize(40, 40), QImage::Format_ARGB32); shadowImage.fill(Qt::transparent); QPainter shadowPainter(&shadowImage); QGraphicsScene scene; scene.addItem(item); scene.render(&shadowPainter, QRectF(), QRectF(-20, -20, mPixmap.width() + 40, mPixmap.height() + 40)); shadowPainter.end(); // we can finish up now mPixmap = QPixmap::fromImage(shadowImage); if (mCapturePointer) { QPoint topLeft = clipRegion.boundingRect().topLeft() - QPoint(20, 20); mPixmap = blendCursorImage(mPixmap, topLeft.x(), topLeft.y(), mPixmap.width(), mPixmap.height()); } emit pixmapChanged(mPixmap); } void X11ImageGrabber::grabActiveWindow() { xcb_window_t activeWindow = KWindowSystem::activeWindow(); updateWindowTitle(activeWindow); // if KWin is available, use the KWin DBus interfaces if (mCaptureDecorations && isKWinAvailable()) { QDBusConnection bus = QDBusConnection::sessionBus(); bus.connect(QStringLiteral("org.kde.KWin"), QStringLiteral("/Screenshot"), QStringLiteral("org.kde.kwin.Screenshot"), QStringLiteral("screenshotCreated"), this, SLOT(KWinDBusScreenshotHelper(quint64))); QDBusInterface interface(QStringLiteral("org.kde.KWin"), QStringLiteral("/Screenshot"), QStringLiteral("org.kde.kwin.Screenshot")); int mask = 1; if (mCapturePointer) { mask |= 1 << 1; } interface.call(QStringLiteral("screenshotForWindow"), (quint64)activeWindow, mask); return; } // otherwise, use the native functionality return grabApplicationWindowHelper(activeWindow); } void X11ImageGrabber::grabWindowUnderCursor() { const xcb_window_t windowUnderCursor = getRealWindowUnderCursor(); updateWindowTitle(windowUnderCursor); // if KWin is available, use the KWin DBus interfaces if (mCaptureDecorations && isKWinAvailable()) { QDBusConnection bus = QDBusConnection::sessionBus(); bus.connect(QStringLiteral("org.kde.KWin"), QStringLiteral("/Screenshot"), QStringLiteral("org.kde.kwin.Screenshot"), QStringLiteral("screenshotCreated"), this, SLOT(KWinDBusScreenshotHelper(quint64))); QDBusInterface interface(QStringLiteral("org.kde.KWin"), QStringLiteral("/Screenshot"), QStringLiteral("org.kde.kwin.Screenshot")); int mask = 1; if (mCapturePointer) { mask |= 1 << 1; } interface.call(QStringLiteral("screenshotWindowUnderCursor"), mask); return; } // else, go native return grabApplicationWindowHelper(windowUnderCursor); } void X11ImageGrabber::grabApplicationWindowHelper(xcb_window_t window) { // if the user doesn't want decorations captured, we're in luck. This is // the easiest bit mPixmap = getWindowPixmap(window, mCapturePointer); if (!mCaptureDecorations || window == QX11Info::appRootWindow()) { emit pixmapChanged(mPixmap); return; } // if the user wants the window decorations, things get a little tricky. // we can't simply get a handle to the window manager frame window and // just grab it, because some compositing window managers (yes, kwin // included) do not render the window onto the frame but keep it in a // separate opengl buffer, so grabbing this window is going to simply // give us a transparent image with the frame and titlebar. // all is not lost. what we need to do is grab the image of the entire // desktop, find the geometry of the window including its frame, and // crop the root image accordingly. KWindowInfo info(window, NET::WMFrameExtents); if (info.valid()) { QRect frameGeom = info.frameGeometry(); mPixmap = getToplevelPixmap(frameGeom, mCapturePointer); } // fallback is window without the frame emit pixmapChanged(mPixmap); } QRect X11ImageGrabber::getDrawableGeometry(xcb_drawable_t drawable) { xcb_connection_t *xcbConn = QX11Info::connection(); xcb_get_geometry_cookie_t geomCookie = xcb_get_geometry_unchecked(xcbConn, drawable); CScopedPointer geomReply(xcb_get_geometry_reply(xcbConn, geomCookie, NULL)); if (geomReply.isNull()) { return QRect(); } return QRect(geomReply->x, geomReply->y, geomReply->width, geomReply->height); } void X11ImageGrabber::grabCurrentScreen() { QPoint cursorPosition = QCursor::pos(); for (auto screen : QGuiApplication::screens()) { QRect screenRect = screen->geometry(); if (!screenRect.contains(cursorPosition)) { continue; } // The screen origin is in native pixels, but the size is device-dependent. Convert these also to native pixels. QRect nativeScreenRect(screenRect.topLeft(), screenRect.size() * screen->devicePixelRatio()); mPixmap = getToplevelPixmap(nativeScreenRect, mCapturePointer); emit pixmapChanged(mPixmap); return; } // No screen found with our cursor, fallback to capturing full screen grabFullScreen(); } void X11ImageGrabber::grabRectangularRegion() { const auto pixmap = getToplevelPixmap(QRect(), mCapturePointer); if (!pixmap.isNull()) { QuickEditor *editor = new QuickEditor(pixmap); connect(editor, &QuickEditor::grabDone, this, &X11ImageGrabber::rectangleSelectionConfirmed); connect(editor, &QuickEditor::grabCancelled, this, &X11ImageGrabber::rectangleSelectionCancelled); } else { emit pixmapChanged(pixmap); } } xcb_window_t X11ImageGrabber::getRealWindowUnderCursor() { xcb_connection_t *xcbConn = QX11Info::connection(); xcb_window_t curWin = QX11Info::appRootWindow(); const QByteArray atomName("WM_STATE"); xcb_intern_atom_cookie_t atomCookie = xcb_intern_atom_unchecked(xcbConn, 0, atomName.length(), atomName.constData()); xcb_query_pointer_cookie_t pointerCookie = xcb_query_pointer_unchecked(xcbConn, curWin); CScopedPointer atomReply(xcb_intern_atom_reply(xcbConn, atomCookie, NULL)); CScopedPointer pointerReply(xcb_query_pointer_reply(xcbConn, pointerCookie, NULL)); if (atomReply->atom == XCB_ATOM_NONE) { return QX11Info::appRootWindow(); } // now start testing QStack windowStack; windowStack.push(pointerReply->child); while (!windowStack.isEmpty()) { curWin = windowStack.pop(); - // next, check if our window has the WM_STATE peoperty set on + // next, check if our window has the WM_STATE property set on // the window. if yes, return the window - we have found it xcb_get_property_cookie_t propertyCookie = xcb_get_property_unchecked(xcbConn, 0, curWin, atomReply->atom, XCB_ATOM_ANY, 0, 0); CScopedPointer propertyReply(xcb_get_property_reply(xcbConn, propertyCookie, NULL)); if (propertyReply->type != XCB_ATOM_NONE) { return curWin; } // if we're here, this means the window is not the real window // we should start looking at its children xcb_query_tree_cookie_t treeCookie = xcb_query_tree_unchecked(xcbConn, curWin); CScopedPointer treeReply(xcb_query_tree_reply(xcbConn, treeCookie, NULL)); xcb_window_t *winChildren = xcb_query_tree_children(treeReply.data()); int winChildrenLength = xcb_query_tree_children_length(treeReply.data()); for (int i = winChildrenLength - 1; i >= 0; i--) { windowStack.push(winChildren[i]); } } // return the window. it has geometry information for a crop return pointerReply->child; } // obtain the size of the given window, returning the window ID of the parent xcb_window_t X11ImageGrabber::getTransientWindowParent(xcb_window_t winId, QRect &outRect) { NET::Properties properties = mCaptureDecorations ? NET::WMFrameExtents : NET::WMGeometry; KWindowInfo winInfo(winId, properties, NET::WM2TransientFor); // add the current window to the image if (mCaptureDecorations) { outRect = winInfo.frameGeometry(); } else { outRect = winInfo.geometry(); } return winInfo.transientFor(); } QPoint X11ImageGrabber::getNativeCursorPosition() { // QCursor::pos() is not used because it requires additional calculations. // Its value is the offset to the origin of the current screen is in // device-independent pixels while the origin itself uses native pixels. xcb_connection_t *xcbConn = QX11Info::connection(); xcb_query_pointer_cookie_t pointerCookie = xcb_query_pointer_unchecked(xcbConn, QX11Info::appRootWindow()); CScopedPointer pointerReply(xcb_query_pointer_reply(xcbConn, pointerCookie, NULL)); return QPoint(pointerReply->root_x, pointerReply->root_y); } diff --git a/src/QuickEditor/QuickEditor.cpp b/src/QuickEditor/QuickEditor.cpp index 22657ac..5bf8435 100644 --- a/src/QuickEditor/QuickEditor.cpp +++ b/src/QuickEditor/QuickEditor.cpp @@ -1,773 +1,773 @@ /* * Copyright (C) 2016 Boudhayan Gupta * Copyright (C) 2018 Ambareesh "Amby" Balaji * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, * Boston, MA 02110-1301, USA. */ #include #include "QuickEditor.h" #include "SpectacleConfig.h" const qreal QuickEditor::mouseAreaSize = 20.0; const qreal QuickEditor::cornerHandleRadius = 8.0; const qreal QuickEditor::midHandleRadius = 5.0; const int QuickEditor::selectionSizeThreshold = 100; const int QuickEditor::selectionBoxPaddingX = 5; const int QuickEditor::selectionBoxPaddingY = 4; const int QuickEditor::selectionBoxMarginY = 2; std::pair> QuickEditor::bottomHelpText[]{ {QStaticText(i18n("Enter, double-click:")), {QStaticText(i18n("Take screenshot"))}}, {QStaticText(i18n("Shift:")), { QStaticText(i18n("Hold to toggle magnifier")), QStaticText(i18n("while dragging selection handles")) }}, {QStaticText(i18n("Arrow keys:")), { - QStaticText(i18n("Move seletion rectangle")), + QStaticText(i18n("Move selection rectangle")), QStaticText(i18n("Hold Alt to resize, Shift to fine‑tune")) }}, {QStaticText(i18n("Right-click:")), {QStaticText(i18n("Reset selection"))}}, {QStaticText(i18n("Esc:")), {QStaticText(i18n("Cancel"))}}, }; bool QuickEditor::bottomHelpTextPrepared = false; const int QuickEditor::bottomHelpBoxPaddingX = 12; const int QuickEditor::bottomHelpBoxPaddingY = 8; const int QuickEditor::bottomHelpBoxPairSpacing = 6; const int QuickEditor::bottomHelpBoxMarginBottom = 5; const int QuickEditor::midHelpTextFontSize = 12; const int QuickEditor::magnifierLargeStep = 15; const int QuickEditor::magZoom = 5; const int QuickEditor::magPixels = 16; const int QuickEditor::magOffset = 32; QuickEditor::QuickEditor(const QPixmap& pixmap) : mMaskColor(QColor::fromRgbF(0, 0, 0, 0.15)), mStrokeColor(palette().highlight().color()), mCrossColor(QColor::fromRgbF(mStrokeColor.redF(), mStrokeColor.greenF(), mStrokeColor.blueF(), 0.7)), mLabelBackgroundColor(QColor::fromRgbF( palette().light().color().redF(), palette().light().color().greenF(), palette().light().color().blueF(), 0.85 )), mLabelForegroundColor(palette().windowText().color()), mMidHelpText(i18n("Click and drag to draw a selection rectangle,\nor press Esc to quit")), mMidHelpTextFont(font()), mBottomHelpTextFont(font()), mBottomHelpGridLeftWidth(0), mMouseDragState(MouseState::None), mPixmap(pixmap), mMagnifierAllowed(false), mShowMagnifier(SpectacleConfig::instance()->showMagnifierChecked()), mToggleMagnifier(false) { SpectacleConfig *config = SpectacleConfig::instance(); if (config->useLightRegionMaskColour()) { mMaskColor = QColor(255, 255, 255, 100); } setMouseTracking(true); setAttribute(Qt::WA_StaticContents); setWindowFlags(Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint | Qt::Popup | Qt::WindowStaysOnTopHint); show(); dprI = 1.0 / devicePixelRatioF(); setGeometry(0, 0, static_cast(mPixmap.width() * dprI), static_cast(mPixmap.height() * dprI)); if (config->rememberLastRectangularRegion()) { QRect cropRegion = config->cropRegion(); if (!cropRegion.isEmpty()) { mSelection = QRectF( cropRegion.x() * dprI, cropRegion.y() * dprI, cropRegion.width() * dprI, cropRegion.height() * dprI ).intersected(geometry()); } setMouseCursor(QCursor::pos()); } else { setCursor(Qt::CrossCursor); } mMidHelpTextFont.setPointSize(midHelpTextFontSize); if (!bottomHelpTextPrepared) { bottomHelpTextPrepared = true; const auto prepare = [this](QStaticText& item) { item.prepare(QTransform(), mBottomHelpTextFont); item.setPerformanceHint(QStaticText::AggressiveCaching); }; for (auto& pair : bottomHelpText) { prepare(pair.first); for (auto item : pair.second) { prepare(item); } } } layoutBottomHelpText(); update(); } void QuickEditor::acceptSelection() { if (!mSelection.isEmpty()) { const qreal dpr = devicePixelRatioF(); QRect scaledCropRegion = QRect( qRound(mSelection.x() * dpr), qRound(mSelection.y() * dpr), qRound(mSelection.width() * dpr), qRound(mSelection.height() * dpr) ); SpectacleConfig::instance()->setCropRegion(scaledCropRegion); emit grabDone(mPixmap.copy(scaledCropRegion)); } } void QuickEditor::keyPressEvent(QKeyEvent* event) { const auto modifiers = event->modifiers(); const bool shiftPressed = modifiers & Qt::ShiftModifier; if (shiftPressed) { mToggleMagnifier = true; } switch(event->key()) { case Qt::Key_Escape: emit grabCancelled(); break; case Qt::Key_Return: case Qt::Key_Enter: acceptSelection(); break; case Qt::Key_Up: { const qreal step = (shiftPressed ? 1 : magnifierLargeStep); const int newPos = boundsUp(qRound(mSelection.top() * devicePixelRatioF() - step), false); if (modifiers & Qt::AltModifier) { mSelection.setBottom(dprI * newPos + mSelection.height()); mSelection = mSelection.normalized(); } else { mSelection.moveTop(dprI * newPos); } update(); break; } case Qt::Key_Right: { const qreal step = (shiftPressed ? 1 : magnifierLargeStep); const int newPos = boundsRight(qRound(mSelection.left() * devicePixelRatioF() + step), false); if (modifiers & Qt::AltModifier) { mSelection.setRight(dprI * newPos + mSelection.width()); } else { mSelection.moveLeft(dprI * newPos); } update(); break; } case Qt::Key_Down: { const qreal step = (shiftPressed ? 1 : magnifierLargeStep); const int newPos = boundsDown(qRound(mSelection.top() * devicePixelRatioF() + step), false); if (modifiers & Qt::AltModifier) { mSelection.setBottom(dprI * newPos + mSelection.height()); } else { mSelection.moveTop(dprI * newPos); } update(); break; } case Qt::Key_Left: { const qreal step = (shiftPressed ? 1 : magnifierLargeStep); const int newPos = boundsLeft(qRound(mSelection.left() * devicePixelRatioF() - step), false); if (modifiers & Qt::AltModifier) { mSelection.setRight(dprI * newPos + mSelection.width()); mSelection = mSelection.normalized(); } else { mSelection.moveLeft(dprI * newPos); } update(); break; } default: break; } event->accept(); } void QuickEditor::keyReleaseEvent(QKeyEvent* event) { if (mToggleMagnifier && !(event->modifiers() & Qt::ShiftModifier)) { mToggleMagnifier = false; update(); } event->accept(); } int QuickEditor::boundsLeft(int newTopLeftX, const bool mouse) { if (newTopLeftX < 0) { if (mouse) { // tweak startPos to prevent rectangle from getting stuck mStartPos.setX(mStartPos.x() + newTopLeftX * dprI); } newTopLeftX = 0; } return newTopLeftX; } int QuickEditor::boundsRight(int newTopLeftX, const bool mouse) { // the max X coordinate of the top left point const int realMaxX = qRound((width() - mSelection.width()) * devicePixelRatioF()); const int xOffset = newTopLeftX - realMaxX; if (xOffset > 0) { if (mouse) { mStartPos.setX(mStartPos.x() + xOffset * dprI); } newTopLeftX = realMaxX; } return newTopLeftX; } int QuickEditor::boundsUp(int newTopLeftY, const bool mouse) { if (newTopLeftY < 0) { if (mouse) { mStartPos.setY(mStartPos.y() + newTopLeftY * dprI); } newTopLeftY = 0; } return newTopLeftY; } int QuickEditor::boundsDown(int newTopLeftY, const bool mouse) { // the max Y coordinate of the top left point const int realMaxY = qRound((height() - mSelection.height()) * devicePixelRatioF()); const int yOffset = newTopLeftY - realMaxY; if (yOffset > 0) { if (mouse) { mStartPos.setY(mStartPos.y() + yOffset * dprI); } newTopLeftY = realMaxY; } return newTopLeftY; } void QuickEditor::mousePressEvent(QMouseEvent* event) { if (event->button() & Qt::LeftButton) { const QPointF& pos = event->pos(); mMousePos = pos; mMagnifierAllowed = true; mMouseDragState = mouseLocation(pos); switch(mMouseDragState) { case MouseState::Outside: mStartPos = pos; break; case MouseState::Inside: mStartPos = pos; mMagnifierAllowed = false; mInitialTopLeft = mSelection.topLeft(); setCursor(Qt::ClosedHandCursor); break; case MouseState::Top: case MouseState::Left: case MouseState::TopLeft: mStartPos = mSelection.bottomRight(); break; case MouseState::Bottom: case MouseState::Right: case MouseState::BottomRight: mStartPos = mSelection.topLeft(); break; case MouseState::TopRight: mStartPos = mSelection.bottomLeft(); break; case MouseState::BottomLeft: mStartPos = mSelection.topRight(); break; default: break; } } if (mMagnifierAllowed) { update(); } event->accept(); } void QuickEditor::mouseMoveEvent(QMouseEvent* event) { const QPointF& pos = event->pos(); mMousePos = pos; mMagnifierAllowed = true; switch (mMouseDragState) { case MouseState::None: { setMouseCursor(pos); mMagnifierAllowed = false; break; } case MouseState::TopLeft: case MouseState::TopRight: case MouseState::BottomRight: case MouseState::BottomLeft: { const bool afterX = pos.x() >= mStartPos.x(); const bool afterY = pos.y() >= mStartPos.y(); mSelection.setRect( afterX ? mStartPos.x() : pos.x(), afterY ? mStartPos.y() : pos.y(), qAbs(pos.x() - mStartPos.x()) + (afterX ? dprI : 0), qAbs(pos.y() - mStartPos.y()) + (afterY ? dprI : 0) ); update(); break; } case MouseState::Outside: { mSelection.setRect( qMin(pos.x(), mStartPos.x()), qMin(pos.y(), mStartPos.y()), qAbs(pos.x() - mStartPos.x()) + dprI, qAbs(pos.y() - mStartPos.y()) + dprI ); update(); break; } case MouseState::Top: case MouseState::Bottom: { const bool afterY = pos.y() >= mStartPos.y(); mSelection.setRect( mSelection.x(), afterY ? mStartPos.y() : pos.y(), mSelection.width(), qAbs(pos.y() - mStartPos.y()) + (afterY ? dprI : 0) ); update(); break; } case MouseState::Right: case MouseState::Left: { const bool afterX = pos.x() >= mStartPos.x(); mSelection.setRect( afterX ? mStartPos.x() : pos.x(), mSelection.y(), qAbs(pos.x() - mStartPos.x()) + (afterX ? dprI : 0), mSelection.height() ); update(); break; } case MouseState::Inside: { mMagnifierAllowed = false; // We use some math here to figure out if the diff with which we // move the rectangle with moves it out of bounds, // in which case we adjust the diff to not let that happen const qreal dpr = devicePixelRatioF(); // new top left point of the rectangle QPoint newTopLeft = ((pos - mStartPos + mInitialTopLeft) * dpr).toPoint(); int newTopLeftX = boundsLeft(newTopLeft.x()); if (newTopLeftX != 0) { newTopLeftX = boundsRight(newTopLeftX); } int newTopLeftY = boundsUp(newTopLeft.y()); if (newTopLeftY != 0) { newTopLeftY = boundsDown(newTopLeftY); } const auto newTopLeftF = QPointF(newTopLeftX * dprI, newTopLeftY * dprI); mSelection.moveTo(newTopLeftF); update(); break; } default: break; } event->accept(); } void QuickEditor::mouseReleaseEvent(QMouseEvent* event) { const auto button = event->button(); if (button == Qt::LeftButton && mMouseDragState == MouseState::Inside) { setCursor(Qt::OpenHandCursor); } else if (button == Qt::RightButton) { mSelection.setWidth(0); mSelection.setHeight(0); } event->accept(); mMouseDragState = MouseState::None; update(); } void QuickEditor::mouseDoubleClickEvent(QMouseEvent* event) { event->accept(); if (event->button() == Qt::LeftButton && mSelection.contains(event->pos())) { acceptSelection(); } } void QuickEditor::paintEvent(QPaintEvent*) { QPainter painter(this); painter.setRenderHints(QPainter::Antialiasing); QBrush brush(mPixmap); brush.setTransform(QTransform().scale(dprI, dprI)); painter.setBackground(brush); painter.eraseRect(geometry()); if (!mSelection.size().isEmpty() || mMouseDragState != MouseState::None) { painter.fillRect(mSelection, mStrokeColor); const QRectF innerRect = mSelection.adjusted(1, 1, -1, -1); if (innerRect.width() > 0 && innerRect.height() > 0) { painter.eraseRect(mSelection.adjusted(1, 1, -1, -1)); } QRectF top(0, 0, width(), mSelection.top()); QRectF right(mSelection.right(), mSelection.top(), width() - mSelection.right(), mSelection.height()); QRectF bottom(0, mSelection.bottom(), width(), height() - mSelection.bottom()); QRectF left(0, mSelection.top(), mSelection.left(), mSelection.height()); for (const auto& rect : { top, right, bottom, left }) { painter.fillRect(rect, mMaskColor); } drawSelectionSizeTooltip(painter); if (mMouseDragState == MouseState::None) { // mouse is up if ((mSelection.width() > 20) && (mSelection.height() > 20)) { drawDragHandles(painter); } } else if (mMagnifierAllowed && (mShowMagnifier ^ mToggleMagnifier)) { drawMagnifier(painter); } drawBottomHelpText(painter); } else { drawMidHelpText(painter); } } void QuickEditor::layoutBottomHelpText() { int maxRightWidth = 0; int contentWidth = 0; int contentHeight = 0; mBottomHelpGridLeftWidth = 0; int i = 0; for (const auto& item : bottomHelpText) { const auto& left = item.first; const auto& right = item.second; const auto leftSize = left.size().toSize(); mBottomHelpGridLeftWidth = qMax(mBottomHelpGridLeftWidth, leftSize.width()); for (const auto& item : right) { const auto rightItemSize = item.size().toSize(); maxRightWidth = qMax(maxRightWidth, rightItemSize.width()); contentHeight += rightItemSize.height(); } contentWidth = qMax(contentWidth, mBottomHelpGridLeftWidth + maxRightWidth + bottomHelpBoxPairSpacing); contentHeight += (++i != bottomHelpLength ? bottomHelpBoxMarginBottom : 0); } mBottomHelpContentPos.setX((width() - contentWidth) / 2); mBottomHelpContentPos.setY(height() - contentHeight - 8); mBottomHelpGridLeftWidth += mBottomHelpContentPos.x(); mBottomHelpBorderBox.setRect( mBottomHelpContentPos.x() - bottomHelpBoxPaddingX, mBottomHelpContentPos.y() - bottomHelpBoxPaddingY, contentWidth + bottomHelpBoxPaddingX * 2, contentHeight + bottomHelpBoxPaddingY * 2 - 1 ); } void QuickEditor::drawBottomHelpText(QPainter &painter) { if (mSelection.intersects(mBottomHelpBorderBox)) { return; } painter.setBrush(mLabelBackgroundColor); painter.setPen(mLabelForegroundColor); painter.setFont(mBottomHelpTextFont); painter.setRenderHint(QPainter::Antialiasing, false); painter.drawRect(mBottomHelpBorderBox); painter.setRenderHint(QPainter::Antialiasing, true); int topOffset = mBottomHelpContentPos.y(); int i = 0; for (const auto& item : bottomHelpText) { const auto& left = item.first; const auto& right = item.second; const auto leftSize = left.size().toSize(); painter.drawStaticText(mBottomHelpGridLeftWidth - leftSize.width(), topOffset, left); for (const auto& item : right) { const auto rightItemSize = item.size().toSize(); painter.drawStaticText(mBottomHelpGridLeftWidth + bottomHelpBoxPairSpacing, topOffset, item); topOffset += rightItemSize.height(); } if (++i != bottomHelpLength) { topOffset += bottomHelpBoxMarginBottom; } } } void QuickEditor::drawDragHandles(QPainter& painter) { const qreal left = mSelection.x(); const qreal width = mSelection.width(); const qreal centerX = left + width / 2.0; const qreal right = left + width; const qreal top = mSelection.y(); const qreal height = mSelection.height(); const qreal centerY = top + height / 2.0; const qreal bottom = top + height; // start a path QPainterPath path; const qreal cornerHandleDiameter = 2 * cornerHandleRadius; // x and y coordinates of handle arcs const qreal leftHandle = left - cornerHandleRadius; const qreal topHandle = top - cornerHandleRadius; const qreal rightHandle = right - cornerHandleRadius; const qreal bottomHandle = bottom - cornerHandleRadius; const qreal centerHandleX = centerX - midHandleRadius; const qreal centerHandleY = centerY - midHandleRadius; // top-left handle path.moveTo(left, top); path.arcTo(leftHandle, topHandle, cornerHandleDiameter, cornerHandleDiameter, 0, -90); // top-right handle path.moveTo(right, top); path.arcTo(rightHandle, topHandle, cornerHandleDiameter, cornerHandleDiameter, 180, 90); // bottom-left handle path.moveTo(left, bottom); path.arcTo(leftHandle, bottomHandle, cornerHandleDiameter, cornerHandleDiameter, 0, 90); // bottom-right handle path.moveTo(right, bottom); path.arcTo(rightHandle, bottomHandle, cornerHandleDiameter, cornerHandleDiameter, 180, -90); const qreal midHandleDiameter = 2 * midHandleRadius; // top-center handle path.moveTo(centerX, top); path.arcTo(centerHandleX, top - midHandleRadius, midHandleDiameter, midHandleDiameter, 0, -180); // right-center handle path.moveTo(right, centerY); path.arcTo(right - midHandleRadius, centerHandleY, midHandleDiameter, midHandleDiameter, 90, 180); // bottom-center handle path.moveTo(centerX, bottom); path.arcTo(centerHandleX, bottom - midHandleRadius, midHandleDiameter, midHandleDiameter, 0, 180); // left-center handle path.moveTo(left, centerY); path.arcTo(left - midHandleRadius, centerHandleY, midHandleDiameter, midHandleDiameter, 90, -180); // draw the path painter.fillPath(path, mStrokeColor); } void QuickEditor::drawMagnifier(QPainter &painter) { const int pixels = 2 * magPixels + 1; int magX = static_cast(mMousePos.x() * devicePixelRatioF() - magPixels); int offsetX = 0; if (magX < 0) { offsetX = magX; magX = 0; } else { const int maxX = mPixmap.width() - pixels; if (magX > maxX) { offsetX = magX - maxX; magX = maxX; } } int magY = static_cast(mMousePos.y() * devicePixelRatioF() - magPixels); int offsetY = 0; if (magY < 0) { offsetY = magY; magY = 0; } else { const int maxY = mPixmap.height() - pixels; if (magY > maxY) { offsetY = magY - maxY; magY = maxY; } } QRectF magniRect(magX, magY, pixels, pixels); qreal drawPosX = mMousePos.x() + magOffset + pixels * magZoom / 2; if (drawPosX > width() - pixels * magZoom / 2) { drawPosX = mMousePos.x() - magOffset - pixels * magZoom / 2; } qreal drawPosY = mMousePos.y() + magOffset + pixels * magZoom / 2; if (drawPosY > height() - pixels * magZoom / 2) { drawPosY = mMousePos.y() - magOffset - pixels * magZoom / 2; } QPointF drawPos(drawPosX, drawPosY); QRectF crossHairTop(drawPos.x() + magZoom * (offsetX - 0.5), drawPos.y() - magZoom * (magPixels + 0.5), magZoom, magZoom * (magPixels + offsetY)); QRectF crossHairRight(drawPos.x() + magZoom * (0.5 + offsetX), drawPos.y() + magZoom * (offsetY - 0.5), magZoom * (magPixels - offsetX), magZoom); QRectF crossHairBottom(drawPos.x() + magZoom * (offsetX - 0.5), drawPos.y() + magZoom * (0.5 + offsetY), magZoom, magZoom * (magPixels - offsetY)); QRectF crossHairLeft(drawPos.x() - magZoom * (magPixels + 0.5), drawPos.y() + magZoom * (offsetY - 0.5), magZoom * (magPixels + offsetX), magZoom); QRectF crossHairBorder(drawPos.x() - magZoom * (magPixels + 0.5) - 1, drawPos.y() - magZoom * (magPixels + 0.5) - 1, pixels * magZoom + 2, pixels * magZoom + 2); const auto frag = QPainter::PixmapFragment::create(drawPos, magniRect, magZoom, magZoom); painter.fillRect(crossHairBorder, mLabelForegroundColor); painter.drawPixmapFragments(&frag, 1, mPixmap, QPainter::OpaqueHint); painter.setCompositionMode(QPainter::CompositionMode_SourceOver); for (auto& rect : { crossHairTop, crossHairRight, crossHairBottom, crossHairLeft }) { painter.fillRect(rect, mCrossColor); } } void QuickEditor::drawMidHelpText(QPainter &painter) { painter.fillRect(geometry(), mMaskColor); painter.setFont(mMidHelpTextFont); QRect textSize = painter.boundingRect(QRect(), Qt::AlignCenter, mMidHelpText); QPoint pos((width() - textSize.width()) / 2, (height() - textSize.height()) / 2); painter.setBrush(mLabelBackgroundColor); QPen pen(mLabelForegroundColor); pen.setWidth(2); painter.setPen(pen); painter.drawRoundedRect(QRect(pos.x() - 20, pos.y() - 20, textSize.width() + 40, textSize.height() + 40), 4, 4); painter.setCompositionMode(QPainter::CompositionMode_Source); painter.drawText(QRect(pos, textSize.size()), Qt::AlignCenter, mMidHelpText); } void QuickEditor::drawSelectionSizeTooltip(QPainter &painter) { // Set the selection size and finds the most appropriate position: // - vertically centered inside the selection if the box is not covering the a large part of selection // - on top of the selection if the selection x position fits the box height plus some margin // - at the bottom otherwise const qreal dpr = devicePixelRatioF(); QString selectionSizeText = ki18n("%1×%2").subs(qRound(mSelection.width() * dpr)).subs(qRound(mSelection.height() * dpr)).toString(); const QRect selectionSizeTextRect = painter.boundingRect(QRect(), 0, selectionSizeText); const int selectionBoxWidth = selectionSizeTextRect.width() + selectionBoxPaddingX * 2; const int selectionBoxHeight = selectionSizeTextRect.height() + selectionBoxPaddingY * 2; const int selectionBoxX = qBound( 0, static_cast(mSelection.x()) + (static_cast(mSelection.width()) - selectionSizeTextRect.width()) / 2 - selectionBoxPaddingX, width() - selectionBoxWidth ); int selectionBoxY; if ((mSelection.width() >= selectionSizeThreshold) && (mSelection.height() >= selectionSizeThreshold)) { // show inside the box selectionBoxY = static_cast(mSelection.y() + (mSelection.height() - selectionSizeTextRect.height()) / 2); } else { // show on top by default selectionBoxY = static_cast(mSelection.y() - selectionBoxHeight - selectionBoxMarginY); if (selectionBoxY < 0) { // show at the bottom selectionBoxY = static_cast(mSelection.y() + mSelection.height() + selectionBoxMarginY); } } // Now do the actual box, border, and text drawing painter.setBrush(mLabelBackgroundColor); painter.setPen(mLabelForegroundColor); const QRect selectionBoxRect( selectionBoxX, selectionBoxY, selectionBoxWidth, selectionBoxHeight ); painter.setRenderHint(QPainter::Antialiasing, false); painter.drawRect(selectionBoxRect); painter.setRenderHint(QPainter::Antialiasing, true); painter.drawText(selectionBoxRect, Qt::AlignCenter, selectionSizeText); } void QuickEditor::setMouseCursor(const QPointF& pos) { MouseState mouseState = mouseLocation(pos); if (mouseState == MouseState::Outside) { setCursor(Qt::CrossCursor); } else if (MouseState::TopLeftOrBottomRight & mouseState) { setCursor(Qt::SizeFDiagCursor); } else if (MouseState::TopRightOrBottomLeft & mouseState) { setCursor(Qt::SizeBDiagCursor); } else if (MouseState::TopOrBottom & mouseState) { setCursor(Qt::SizeVerCursor); } else if (MouseState::RightOrLeft & mouseState) { setCursor(Qt::SizeHorCursor); } else { setCursor(Qt::OpenHandCursor); } } QuickEditor::MouseState QuickEditor::mouseLocation(const QPointF& pos) { if (mSelection.contains(pos)) { const qreal verSize = qMin(mouseAreaSize, mSelection.height() / 2); const qreal horSize = qMin(mouseAreaSize, mSelection.width() / 2); auto withinThreshold = [](const qreal offset, const qreal size) { return offset <= size && offset >= 0; }; const bool withinTopEdge = withinThreshold(pos.y() - mSelection.top(), verSize); const bool withinRightEdge = withinThreshold(mSelection.right() - pos.x(), horSize); const bool withinBottomEdge = !withinTopEdge && withinThreshold(mSelection.bottom() - pos.y(), verSize); const bool withinLeftEdge = !withinRightEdge && withinThreshold(pos.x() - mSelection.left(), horSize); if (withinTopEdge) { if (withinRightEdge) { return MouseState::TopRight; } else if (withinLeftEdge) { return MouseState::TopLeft; } else { return MouseState::Top; } } else if (withinBottomEdge) { if (withinRightEdge) { return MouseState::BottomRight; } else if (withinLeftEdge) { return MouseState::BottomLeft; } else { return MouseState::Bottom; } } else if (withinRightEdge) { return MouseState::Right; } else if (withinLeftEdge) { return MouseState::Left; } else { return MouseState::Inside; } } else { return MouseState::Outside; } }