diff --git a/src/contents/ui/Tabs.qml b/src/contents/ui/Tabs.qml index a3402a5..e653ed0 100644 --- a/src/contents/ui/Tabs.qml +++ b/src/contents/ui/Tabs.qml @@ -1,231 +1,230 @@ /*************************************************************************** * * * Copyright 2014-2015 Sebastian Kügler * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU 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 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 . * * * ***************************************************************************/ import QtQuick 2.3 import QtQuick.Controls 2.0 as Controls import QtGraphicalEffects 1.0 //import QtWebEngine 1.0 import QtQuick.Layouts 1.0 import org.kde.kirigami 2.7 as Kirigami import org.kde.mobile.angelfish 1.0 Kirigami.ScrollablePage { id: tabsRoot title: rootPage.privateMode ? i18n("Private Tabs") : i18n("Tabs") leftPadding: 0 rightPadding: 0 topPadding: 0 bottomPadding: 0 Kirigami.ColumnView.fillWidth: false actions.main: Kirigami.Action { icon.name: "list-add" text: i18n("New") onTriggered: { // Somewhat weird behaviour, consider always just opening "about:blank" - tabs.tabsModel.newTab(rootPage.privateMode ? "about:blank" : BrowserManager.homepage) - tabs.currentIndex = tabs.count - 1; + tabs.tabsModel.newTab(rootPage.privateMode ? "about:blank" : BrowserManager.homepage, Kirigami.Settings.isMobile) pageStack.pop() } } property int itemHeight: Kirigami.Units.gridUnit * 6 property int itemWidth: { if (!landscapeMode) return width; // using grid width to take into account its scrollbar var n = Math.floor((grid.width - Kirigami.Units.largeSpacing) / (landscapeMinWidth + Kirigami.Units.largeSpacing)); return Math.floor(grid.width / n) - Kirigami.Units.largeSpacing; } property int landscapeMinWidth: Kirigami.Units.gridUnit * 12 property bool landscapeMode: grid.width > landscapeMinWidth * 2 + 3 * Kirigami.Units.largeSpacing //Rectangle { anchors.fill: parent; color: "brown"; opacity: 0.5; } GridView { id: grid anchors.fill: parent anchors.bottomMargin: Kirigami.Units.largeSpacing anchors.leftMargin: landscapeMode ? Kirigami.Units.largeSpacing / 2 : 0 // second half comes from item anchors.rightMargin: landscapeMode ? Kirigami.Units.largeSpacing / 2 : 0 // second half comes from item anchors.topMargin: Kirigami.Units.largeSpacing model: tabs.model cellWidth: itemWidth + (landscapeMode ? Kirigami.Units.largeSpacing : 0) cellHeight: itemHeight + Kirigami.Units.largeSpacing delegate: Item { // taking care of spacing width: grid.cellWidth height: grid.cellHeight Item { id: tabItem anchors.centerIn: parent width: itemWidth height: itemHeight Image { anchors.fill: parent clip: true fillMode: Image.PreserveAspectCrop source: tabs.itemAt(index) ? tabs.itemAt(index).thumb.source : "" verticalAlignment: Image.AlignTop LinearGradient { id: grad anchors.fill: parent cached: true start: Qt.point(0,0) end: Qt.point(0,height) gradient: Gradient { GradientStop { position: Math.max(0.25, 1 - 1.5 * (1 - label.y / itemHeight)); color: "transparent"; } GradientStop { position: Math.max(0.25, label.y / itemHeight); color: Kirigami.Theme.backgroundColor; } } } } // ShaderEffectSource requires that corresponding WebEngineView is // visible. Which is probably not the best practice as it seems to keep // all the views active. // ShaderEffectSource { // id: shaderItem // //live: true // anchors.fill: parent // sourceRect: Qt.rect(0, 0, width, height) // sourceItem: { // tabs.itemAt(index); // } // //opacity: tabs.currentIndex == index ? 1 : 0.0 // Behavior on height { // SequentialAnimation { // ScriptAction { // script: { // print("Animation start"); // // switch to tabs // } // } // NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } // NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad; target: contentView; property: opacity } // ScriptAction { // script: { // print("Animation done"); // contentView.state = "hidden" // } // } // } // } // Behavior on width { // NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad} // } // LinearGradient { // id: grad // anchors.fill: parent // cached: true // start: Qt.point(0,0) // end: Qt.point(0,height) // gradient: Gradient { // GradientStop { position: Math.max(0.25, 1 - 1.5*(1-label.y/itemHeight)); color: "transparent"; } // GradientStop { position: Math.max(0.25, label.y/itemHeight); color: Kirigami.Theme.backgroundColor; } // } // } // } Rectangle { // border around a tile anchors.fill: parent; border.color: Kirigami.Theme.textColor border.width: webBrowser.borderWidth color: "transparent" opacity: tabs.currentIndex === index ? 0.75 : 0.3 } Rectangle { // selection indicator anchors.fill: parent color: mouse.pressed ? Kirigami.Theme.highlightColor : "transparent" opacity: 0.2 } MouseArea { id: mouse anchors.fill: parent onClicked: { print("Switch from " + tabs.currentIndex + " to tab " + index); tabs.currentIndex = index; pageStack.pop() } } Controls.ToolButton { icon.name: "window-close" height: Kirigami.gridUnit width: height anchors.right: parent.right anchors.rightMargin: Kirigami.Units.smallSpacing + Kirigami.Units.largeSpacing + (tabsRoot.landscapeMode ? 0 : tabsRoot.width-grid.width) anchors.top: parent.top anchors.topMargin: Kirigami.Units.smallSpacing onClicked: tabs.tabsModel.closeTab(index) } Column { id: label anchors { left: tabItem.left right: tabItem.right bottom: tabItem.bottom bottomMargin: Kirigami.Units.smallSpacing leftMargin: Kirigami.Units.largeSpacing rightMargin: Kirigami.Units.largeSpacing } spacing: 0 Kirigami.Heading { elide: Text.ElideRight level: 4 text: tabs.itemAt(index) != null ? tabs.itemAt(index).title : "" width: label.width } Controls.Label { elide: Text.ElideRight font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.5 text: tabs.itemAt(index) != null ? tabs.itemAt(index).url : "" width: label.width } } } } } Component.onCompleted: grid.currentIndex = tabs.currentIndex } diff --git a/src/contents/ui/WebView.qml b/src/contents/ui/WebView.qml index 7327222..3c8411a 100644 --- a/src/contents/ui/WebView.qml +++ b/src/contents/ui/WebView.qml @@ -1,253 +1,253 @@ /*************************************************************************** * * * Copyright 2014-2015 Sebastian Kügler * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU 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 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 . * * * ***************************************************************************/ import QtQuick 2.3 import QtQuick.Controls 2.4 as Controls import QtQuick.Window 2.1 import QtQuick.Layouts 1.3 import QtWebEngine 1.7 import org.kde.kirigami 2.4 as Kirigami import org.kde.mobile.angelfish 1.0 WebEngineView { id: webEngineView property string errorCode: "" property string errorString: "" property bool privateMode: false property alias userAgent: userAgent property alias thumb: thumb property bool reloadOnVisible: false UserAgentGenerator { id: userAgent - isMobile: Kirigami.Settings.isMobile + isMobile: model.isMobile onUserAgentChanged: tabs.tabsModel.setTab(index, webEngineView.url, isMobile) } Image { id: thumb visible: false } Timer { id: snaphotTimer interval: 1000 repeat: false onTriggered: { if (webEngineView.visible) grabThumb(); } } profile { offTheRecord: privateMode httpUserAgent: userAgent.userAgent onDownloadRequested: { // if we don't accept the request right away, it will be deleted download.accept() // therefore just stop the download again as quickly as possible, // and ask the user for confirmation download.pause() questionLoader.setSource("DownloadQuestion.qml") questionLoader.item.download = download questionLoader.item.visible = true } onDownloadFinished: { if (download.state === WebEngineDownloadItem.DownloadCompleted) { showPassiveNotification(i18n("Download finished")) } else if (download.state === WebEngineDownloadItem.DownloadInterrupted) { showPassiveNotification(i18n("Download failed")) console.log("Download interrupt reason: " + download.interruptReason) } else if (download.state === WebEngineDownloadItem.DownloadCancelled) { console.log("Download cancelled by the user") } } } settings { // Disable builtin error pages in favor of our own errorPageEnabled: false // Disable scrollbars on mobile showScrollBars: !Kirigami.Settings.isMobile } // Custom context menu Controls.Menu { property var request id: contextMenu Controls.MenuItem { enabled: contextMenu.request != null && (contextMenu.request.editFlags & ContextMenuRequest.CanCopy) != 0 text: i18n("Copy") onTriggered: webEngineView.triggerWebAction(WebEngineView.Copy) } Controls.MenuItem { enabled: contextMenu.request != null && (contextMenu.request.editFlags & ContextMenuRequest.CanCut) != 0 text: i18n("Cut") onTriggered: webEngineView.triggerWebAction(WebEngineView.Cut) } Controls.MenuItem { enabled: contextMenu.request != null && (contextMenu.request.editFlags & ContextMenuRequest.CanPaste) != 0 text: i18n("Paste") onTriggered: webEngineView.triggerWebAction(WebEngineView.Paste) } Controls.MenuItem { enabled: contextMenu.request !== null && contextMenu.request.linkUrl !== "" text: i18n("Copy Url") onTriggered: webEngineView.triggerWebAction(WebEngineView.CopyLinkToClipboard) } Controls.MenuItem { text: i18n("View source") onTriggered: tabsModel.newTab("view-source:" + webEngineView.url) } Controls.MenuItem { text: i18n("Download") onTriggered: webEngineView.triggerWebAction(WebEngineView.DownloadLinkToDisk) } Controls.MenuItem { enabled: contextMenu.request !== null && contextMenu.request.linkUrl !== "" text: i18n("Open in new Tab") onTriggered: webEngineView.triggerWebAction(WebEngineView.OpenLinkInNewTab) } } focus: true onLoadingChanged: { //print("Loading: " + loading); print(" url: " + loadRequest.url) //print(" icon: " + webEngineView.icon) //print(" title: " + webEngineView.title) /* Handle * - WebEngineView::LoadStartedStatus, * - WebEngineView::LoadStoppedStatus, * - WebEngineView::LoadSucceededStatus and * - WebEngineView::LoadFailedStatus */ var ec = ""; var es = ""; if (loadRequest.status === WebEngineView.LoadStartedStatus) { thumb.source = ""; } if (loadRequest.status === WebEngineView.LoadSucceededStatus) { if (!privateMode) { addHistoryEntry(); } grabThumb(); } if (loadRequest.status === WebEngineView.LoadFailedStatus) { print("Load failed: " + loadRequest.errorCode + " " + loadRequest.errorString); ec = loadRequest.errorCode; es = loadRequest.errorString; thumb.source = ""; } errorCode = ec; errorString = es; } Component.onCompleted: { print("WebView completed."); var settings = webEngineView.settings; print("Settings: " + settings); } onIconChanged: { if (icon) BrowserManager.history.updateIcon(url, icon) } onNewViewRequested: { if (request.userInitiated) { tabsModel.newTab(request.requestedUrl.toString()) showPassiveNotification(i18n("Website was opened in a new tab")) } else { questionLoader.setSource("NewTabQuestion.qml") questionLoader.item.url = request.requestedUrl questionLoader.item.visible = true } } onUrlChanged: thumb.source = "" onFullScreenRequested: { request.accept() if (webBrowser.visibility !== Window.FullScreen) webBrowser.showFullScreen() else webBrowser.showNormal() } onContextMenuRequested: { request.accepted = true // Make sure QtWebEngine doesn't show its own context menu. contextMenu.request = request contextMenu.x = request.x contextMenu.y = request.y contextMenu.open() } onAuthenticationDialogRequested: { request.accepted = true sheetLoader.setSource("AuthSheet.qml") sheetLoader.item.request = request sheetLoader.item.open() } onFeaturePermissionRequested: { questionLoader.setSource("PermissionQuestion.qml") questionLoader.item.permission = feature questionLoader.item.origin = securityOrigin questionLoader.item.visible = true } onVisibleChanged: { // set user agent to the current displayed tab // this ensures that we follow mobile preference // of the current webview. also update the current // snapshot image with short delay to be sure that // all kirigami pages have moved into place if (visible) { profile.httpUserAgent = Qt.binding(function() { return userAgent.userAgent; }); snaphotTimer.start(); if (reloadOnVisible) { reloadOnVisible = false; reload(); } } } function grabThumb() { webEngineView.grabToImage(function(result) { webEngineView.thumb.source = result.url; }); } } diff --git a/src/tabsmodel.cpp b/src/tabsmodel.cpp index 4fc60b0..9de7723 100644 --- a/src/tabsmodel.cpp +++ b/src/tabsmodel.cpp @@ -1,383 +1,383 @@ /* * Copyright 2020 Jonah Brüchert * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Library General Public * License version 2 as published by the Free Software Foundation; * * This library 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 * Library General Public License for more details. * * You should have received a copy of the GNU Library General Public License * along with this library; see the file COPYING.LIB. If not, write to * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, * Boston, MA 02110-1301, USA. */ #include "tabsmodel.h" #include #include #include #include #include #include #include "browsermanager.h" TabsModel::TabsModel(QObject *parent) : QAbstractListModel(parent) { connect(this, &TabsModel::currentTabChanged, [this] { qDebug() << "Current tab changed to" << m_currentTab; }); // The fallback tab must not be saved, it would overwrite our actual data. m_tabsReadOnly = true; // Make sure model always contains at least one tab createEmptyTab(); } QHash TabsModel::roleNames() const { return { { RoleNames::UrlRole, QByteArrayLiteral("pageurl") }, { RoleNames::IsMobileRole, QByteArrayLiteral("isMobile") } }; } QVariant TabsModel::data(const QModelIndex &index, int role) const { if (!index.isValid() || index.row() < 0 || index.row() >= m_tabs.count()) { return {}; } switch(role) { case RoleNames::UrlRole: return m_tabs.at(index.row()).url(); case RoleNames::IsMobileRole: return m_tabs.at(index.row()).isMobile(); } return {}; } int TabsModel::rowCount(const QModelIndex &parent) const { return parent.isValid() ? 0 : m_tabs.count(); } /** * @brief TabsModel::setTabUrl sets the properties of a tab at a given index. * It should be used in cases were a reload of the web engine after the url change * is not wanted, e.g if this function is already triggered by a load of the web engine. * @param index * @param url * @param isMobile */ void TabsModel::setTab(int index, const QString &url, bool isMobile) { if (index < 0 && index >= m_tabs.count()) return; // index out of bounds m_tabs[index].setUrl(url); m_tabs[index].setIsMobile(isMobile); saveTabs(); tabsChanged(); } /** * @brief TabsModel::tab returns the tab at the given index * @param index * @return tab at the index */ TabState TabsModel::tab(int index) { if (index < 0 && index >= m_tabs.count()) return {}; // index out of bounds return m_tabs.at(index); } /** * @brief TabsModel::loadInitialTabs sets up the tabs that should already be open when starting the browser * This includes the configured homepage, an url passed on the command line (usually by another app) and tabs * which were still open when the browser was last closed. * * @warning It is impossible to save any new tabs until this function was called. */ void TabsModel::loadInitialTabs() { if (!m_privateMode) { loadTabs(); } m_tabsReadOnly = false; if (!m_privateMode) { if (AngelFish::BrowserManager::instance()->initialUrl().isEmpty()) { if (m_tabs.first().url() == QStringLiteral("about:blank")) load(AngelFish::BrowserManager::instance()->homepage()); } else { if (m_tabs.first().url() == QStringLiteral("about:blank")) load(AngelFish::BrowserManager::instance()->initialUrl()); else newTab(AngelFish::BrowserManager::instance()->initialUrl()); } } } /** * @brief TabsModel::currentTab returns the index of the tab that is currently visible to the user * @return index */ int TabsModel::currentTab() const { return m_currentTab; } /** * @brief TabsModel::setCurrentTab sets the tab that is currently visible to the user * @param index */ void TabsModel::setCurrentTab(int index) { if (index >= m_tabs.count()) return; m_currentTab = index; currentTabChanged(); saveTabs(); } QVector TabsModel::tabs() const { return m_tabs; } /** * @brief TabsModel::loadTabs restores tabs saved in tabs.json * @return whether any tabs were restored */ bool TabsModel::loadTabs() { if (!m_privateMode) { beginResetModel(); QString input = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + QStringLiteral("/angelfish/tabs.json"); QFile inputFile(input); if (!inputFile.exists()) { return false; } if (!inputFile.open(QIODevice::ReadOnly)) { qDebug() << "Failed to load tabs from disk"; } const auto tabsStorage = QJsonDocument::fromJson(inputFile.readAll()).object(); m_tabs.clear(); for (const auto tab : tabsStorage.value(QLatin1String("tabs")).toArray()) { m_tabs.append(TabState::fromJson(tab.toObject())); } qDebug() << "loaded from file:" << m_tabs.count() << input; m_currentTab = tabsStorage.value(QLatin1String("currentTab")).toInt(); // Make sure model always contains at least one tab if (m_tabs.count() == 0) { createEmptyTab(); } endResetModel(); tabsChanged(); currentTabChanged(); inputFile.close(); return true; } return false; } /** * @brief TabsModel::saveTabs saves the current state of the model to disk * @return whether the tabs could be saved */ bool TabsModel::saveTabs() const { // only save if not in private mode - if (!m_privateMode) { + if (!m_privateMode && !m_tabsReadOnly) { QString outputDir = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + QStringLiteral("/angelfish/"); QFile outputFile(outputDir + QStringLiteral("tabs.json")); if (!QDir(outputDir).mkpath(".")) { qDebug() << "Destdir doesn't exist and I can't create it: " << outputDir; return false; } if (!outputFile.open(QIODevice::WriteOnly)) { qDebug() << "Failed to write tabs to disk"; } auto document = QJsonDocument(); auto tabsStorage = QJsonObject(); QJsonArray tabsArray; for (const auto &tab : m_tabs) { tabsArray.append(tab.toJson()); } qDebug() << "Wrote to file" << outputFile.fileName() << "(" << tabsArray.count() << "urls" << ")"; tabsStorage.insert(QLatin1String("tabs"), tabsArray); tabsStorage.insert(QLatin1String("currentTab"), m_currentTab); document.setObject(tabsStorage); outputFile.write(document.toJson()); outputFile.close(); return true; } return false; } bool TabsModel::privateMode() const { return m_privateMode; } void TabsModel::setPrivateMode(bool privateMode) { m_privateMode = privateMode; privateModeChanged(); } /** * @brief TabsModel::createEmptyTab convinience function for opening a tab containing "about:blank" */ void TabsModel::createEmptyTab() { newTab(QStringLiteral("about:blank")); }; /** * @brief TabsModel::newTab * @param url * @param isMobile */ void TabsModel::newTab(const QString &url, bool isMobile) { beginInsertRows({}, m_tabs.count(), m_tabs.count()); m_tabs.append(TabState(url, isMobile)); endInsertRows(); // Switch to last tab m_currentTab = m_tabs.count() - 1; currentTabChanged(); } /** * @brief TabsModel::closeTab removes the tab at the index, handles moving the tabs after it and sets a new currentTab * @param index */ void TabsModel::closeTab(int index) { if (index < 0 && index >= m_tabs.count()) return; // index out of bounds if (m_tabs.count() <= 1) { // create new tab before removing the last one // to avoid linking all signals to null object createEmptyTab(); // now we have (tab_to_remove, "about:blank) // 0 will be the correct current tab index after tab_to_remove is gone m_currentTab = 0; // index to remove index = 0; } if (m_currentTab > index) { // decrease index if it's after the removed tab m_currentTab--; } if (m_currentTab == index) { // handle the removal of current tab // Just reset to first tab m_currentTab = 0; } beginRemoveRows({}, index, index); m_tabs.removeAt(index); endRemoveRows(); currentTabChanged(); saveTabs(); } /** * Load a url in the current tab */ void TabsModel::load(const QString &url) { qDebug() << "Loading url:" << url; qDebug() << "current tab" << m_currentTab << "tabs open" << m_tabs.count(); m_tabs[m_currentTab].setUrl(url); QModelIndex index = createIndex(m_currentTab, m_currentTab); dataChanged(index, index); tabsChanged(); } QString TabState::url() const { return m_url; } void TabState::setUrl(const QString &url) { m_url = url; } bool TabState::isMobile() const { return m_isMobile; } void TabState::setIsMobile(bool isMobile) { m_isMobile = isMobile; } TabState TabState::fromJson(const QJsonObject &obj) { TabState tab; tab.setUrl(obj.value(QStringLiteral("url")).toString()); tab.setIsMobile(obj.value(QStringLiteral("isMobile")).toBool()); return tab; } TabState::TabState(const QString &url, const bool isMobile) { setIsMobile(isMobile); setUrl(url); } bool TabState::operator==(const TabState &other) const { return (m_url == other.url() && m_isMobile == other.isMobile()); } QJsonObject TabState::toJson() const { QJsonObject obj; obj.insert(QStringLiteral("url"), m_url); obj.insert(QStringLiteral("isMobile"), m_isMobile); return obj; }