aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorFelix (xq) Queißner <git@mq32.de>2020-06-06 23:14:21 +0200
committerFelix (xq) Queißner <git@mq32.de>2020-06-06 23:14:21 +0200
commit3aed883402dc8da829fc304434c5efd0570cbb97 (patch)
tree48c46ab087a950d80f78819ceb609e93d246b040 /src
parent44e85dce678e7e36f436a6d0a25c212c9a2d3657 (diff)
downloadkristall-3aed883402dc8da829fc304434c5efd0570cbb97.tar.gz
Moves source code into subdirectory.
Diffstat (limited to 'src')
-rw-r--r--src/browsertab.cpp405
-rw-r--r--src/browsertab.hpp114
-rw-r--r--src/browsertab.ui187
-rw-r--r--src/documentoutlinemodel.cpp200
-rw-r--r--src/documentoutlinemodel.hpp57
-rw-r--r--src/favouritecollection.cpp140
-rw-r--r--src/favouritecollection.hpp44
-rw-r--r--src/geminiclient.cpp252
-rw-r--r--src/geminiclient.hpp89
-rw-r--r--src/geminirenderer.cpp404
-rw-r--r--src/geminirenderer.hpp76
-rw-r--r--src/icons.qrc15
-rw-r--r--src/icons/arrow-left.svg1
-rw-r--r--src/icons/arrow-right.svg1
-rw-r--r--src/icons/close.svg1
-rw-r--r--src/icons/format-font.svg1
-rw-r--r--src/icons/heart-outline.svg1
-rw-r--r--src/icons/heart.svg1
-rw-r--r--src/icons/kristall.svg13
-rw-r--r--src/icons/menu.svg1
-rw-r--r--src/icons/palette.svg1
-rw-r--r--src/icons/refresh.svg1
-rw-r--r--src/icons/settings.svg1
-rw-r--r--src/kristall.pro53
-rw-r--r--src/kristall_en_US.ts3
-rw-r--r--src/main.cpp18
-rw-r--r--src/mainwindow.cpp259
-rw-r--r--src/mainwindow.hpp78
-rw-r--r--src/mainwindow.ui309
-rw-r--r--src/settingsdialog.cpp255
-rw-r--r--src/settingsdialog.hpp79
-rw-r--r--src/settingsdialog.ui517
-rw-r--r--src/tabbrowsinghistory.cpp79
-rw-r--r--src/tabbrowsinghistory.hpp39
34 files changed, 3695 insertions, 0 deletions
diff --git a/src/browsertab.cpp b/src/browsertab.cpp
new file mode 100644
index 0000000..6a4bf69
--- /dev/null
+++ b/src/browsertab.cpp
@@ -0,0 +1,405 @@
+#include "browsertab.hpp"
+#include "ui_browsertab.h"
+#include "mainwindow.hpp"
+#include "geminirenderer.hpp"
+#include "settingsdialog.hpp"
+
+#include <QTabWidget>
+#include <QMenu>
+#include <QMessageBox>
+#include <QInputDialog>
+#include <QDockWidget>
+#include <QImage>
+#include <QPixmap>
+
+#include <QGraphicsPixmapItem>
+#include <QGraphicsTextItem>
+
+
+BrowserTab::BrowserTab(MainWindow * mainWindow) :
+ QWidget(nullptr),
+ ui(new Ui::BrowserTab),
+ mainWindow(mainWindow),
+ outline(),
+ graphics_scene()
+{
+ ui->setupUi(this);
+
+ connect(&gemini_client, &GeminiClient::requestComplete, this, &BrowserTab::on_gemini_complete);
+ connect(&gemini_client, &GeminiClient::protocolViolation, this, &BrowserTab::on_protocolViolation);
+ connect(&gemini_client, &GeminiClient::inputRequired, this, &BrowserTab::on_inputRequired);
+ connect(&gemini_client, &GeminiClient::redirected, this, &BrowserTab::on_redirected);
+ connect(&gemini_client, &GeminiClient::temporaryFailure, this, &BrowserTab::on_temporaryFailure);
+ connect(&gemini_client, &GeminiClient::permanentFailure, this, &BrowserTab::on_permanentFailure);
+ connect(&gemini_client, &GeminiClient::transientCertificateRequested, this, &BrowserTab::on_transientCertificateRequested);
+ connect(&gemini_client, &GeminiClient::authorisedCertificateRequested, this, &BrowserTab::on_authorisedCertificateRequested);
+ connect(&gemini_client, &GeminiClient::certificateRejected, this, &BrowserTab::on_certificateRejected);
+
+ this->updateUI();
+
+ this->ui->graphics_browser->setVisible(false);
+ this->ui->text_browser->setVisible(true);
+
+ this->ui->graphics_browser->setScene(&graphics_scene);
+}
+
+BrowserTab::~BrowserTab()
+{
+ delete ui;
+}
+
+void BrowserTab::navigateTo(const QUrl &url, PushToHistory mode)
+{
+ // TODO: Implement about:// scheme!
+ if(url.scheme() != "gemini") {
+ QMessageBox::warning(this, "Kristall", "Unsupported uri scheme: " + url.scheme());
+ return;
+ }
+ this->current_location = url;
+ this->ui->url_bar->setText(url.toString());
+
+ if(not gemini_client.cancelRequest()) {
+ QMessageBox::warning(this, "Kristall", "Unsupported uri scheme: " + url.scheme());
+ return;
+ }
+
+ this->redirection_count = 0;
+ this->successfully_loaded = false;
+ this->push_to_history_after_load = (mode == PushAfterSuccess);
+
+ gemini_client.startRequest(url);
+
+ switch(mode)
+ {
+ case DontPush:
+ case PushAfterSuccess:
+ break;
+
+ case PushImmediate:
+ pushToHistory(url);
+ break;
+ }
+
+ this->updateUI();
+}
+
+void BrowserTab::navigateBack(QModelIndex history_index)
+{
+ auto url = history.get(history_index);
+
+ if(url.isValid()) {
+ current_history_index = history_index;
+ navigateTo(url, DontPush);
+ }
+}
+
+void BrowserTab::navOneBackback()
+{
+ navigateBack(history.oneBackward(current_history_index));
+}
+
+void BrowserTab::navOneForward()
+{
+ navigateBack(history.oneForward(current_history_index));
+}
+
+void BrowserTab::scrollToAnchor(QString const & anchor)
+{
+ qDebug() << "scroll to anchor" << anchor;
+ this->ui->text_browser->scrollToAnchor(anchor);
+}
+
+void BrowserTab::reloadPage()
+{
+ if(current_location.isValid())
+ this->navigateTo(this->current_location, DontPush);
+}
+
+void BrowserTab::on_url_bar_returnPressed()
+{
+ QUrl url { this->ui->url_bar->text() };
+
+ if(url.scheme().isEmpty()) {
+ url = QUrl { "gemini://" + this->ui->url_bar->text() };
+ }
+
+ this->navigateTo(url, PushImmediate);
+}
+
+void BrowserTab::on_refresh_button_clicked()
+{
+ reloadPage();
+}
+
+void BrowserTab::on_gemini_complete(const QByteArray &data, const QString &mime)
+{
+ qDebug() << "Loaded" << data.length() << "bytes of type" << mime;
+
+
+ this->graphics_scene.clear();
+ this->ui->text_browser->setText("");
+
+ this->ui->text_browser->setVisible(mime.startsWith("text/"));
+ this->ui->graphics_browser->setVisible(mime.startsWith("image/"));
+
+ ui->text_browser->setStyleSheet("");
+
+ std::unique_ptr<QTextDocument> document;
+
+ this->outline.clear();
+
+ if(mime.startsWith("text/gemini")) {
+
+ auto doc= GeminiRenderer{ mainWindow->current_style }.render(data, this->current_location, this->outline);
+ this->ui->text_browser->setStyleSheet(QString("QTextBrowser { background-color: %1; }").arg(doc->background_color.name()));
+
+ document = std::move(doc);
+ }
+ else if(mime.startsWith("text/html")) {
+ document = std::make_unique<QTextDocument>();
+ document->setHtml(QString::fromUtf8(data));
+ }
+#if defined(QT_FEATURE_textmarkdownreader)
+ else if(mime.startsWith("text/markdown")) {
+ document = std::make_unique<QTextDocument>();
+ document->setMarkdown(QString::fromUtf8(data));
+ }
+#endif
+ else if(mime.startsWith("text/")) {
+ QFont monospace;
+ monospace.setFamily("monospace");
+
+ document = std::make_unique<QTextDocument>();
+ document->setDefaultFont(monospace);
+ document->setPlainText(QString::fromUtf8(data));
+ }
+ else if(mime.startsWith("image/")) {
+
+ QImage img;
+ if(img.loadFromData(data, nullptr))
+ {
+ this->graphics_scene.addPixmap(QPixmap::fromImage(img));
+ }
+ else
+ {
+ this->graphics_scene.addText("Failed to load picture!");
+ }
+
+ this->ui->graphics_browser->fitInView(graphics_scene.sceneRect(), Qt::KeepAspectRatio);
+
+ }
+ else {
+ this->ui->text_browser->setVisible(true);
+ this->ui->text_browser->setText(QString("Unsupported Mime: %1").arg(mime));
+ }
+
+ this->ui->text_browser->setDocument(document.get());
+ this->current_document = std::move(document);
+
+ emit this->locationChanged(this->current_location);
+
+ QString title = this->current_location.toString();
+ emit this->titleChanged(title);
+
+ this->successfully_loaded = true;
+
+ if(this->push_to_history_after_load) {
+ this->pushToHistory(this->current_location);
+ this->push_to_history_after_load = false;
+ }
+
+ this->updateUI();
+}
+
+void BrowserTab::on_protocolViolation(const QString &reason)
+{
+ this->setErrorMessage(QString("Protocol violation:\n%1").arg(reason));
+}
+
+void BrowserTab::on_inputRequired(const QString &query)
+{
+ QInputDialog dialog { this };
+
+ dialog.setInputMode(QInputDialog::TextInput);
+ dialog.setLabelText(query);
+
+ if(dialog.exec() != QDialog::Accepted) {
+ setErrorMessage(QString("Site requires input:\n%1").arg(query));
+ return;
+ }
+
+ QUrl new_location = current_location;
+ new_location.setQuery(dialog.textValue());
+ this->navigateTo(new_location, DontPush);
+}
+
+void BrowserTab::on_redirected(const QUrl &uri, bool is_permanent)
+{
+ if(redirection_count >= 5) {
+ setErrorMessage("Too many redirections!");
+ return;
+ }
+ else {
+ if(gemini_client.startRequest(uri)) {
+ redirection_count += 1;
+ this->current_location = uri;
+ this->ui->url_bar->setText(uri.toString());
+ }
+ }
+}
+
+void BrowserTab::on_temporaryFailure(TemporaryFailure reason, const QString &info)
+{
+ switch(reason)
+ {
+ case TemporaryFailure::cgi_error:
+ setErrorMessage(QString("CGI Error\n%1").arg(info));
+ break;
+ case TemporaryFailure::slow_down:
+ setErrorMessage(QString("Slow Down\n%1").arg(info));
+ break;
+ case TemporaryFailure::proxy_error:
+ setErrorMessage(QString("Proxy Error\n%1").arg(info));
+ break;
+ case TemporaryFailure::unspecified:
+ setErrorMessage(QString("Temporary Failure\n%1").arg(info));
+ break;
+ case TemporaryFailure::server_unavailable:
+ setErrorMessage(QString("Server Unavailable\n%1").arg(info));
+ break;
+ }
+}
+
+void BrowserTab::on_permanentFailure(PermanentFailure reason, const QString &info)
+{
+ switch(reason)
+ {
+ case PermanentFailure::gone:
+ setErrorMessage(QString("Gone\n%1").arg(info));
+ break;
+ case PermanentFailure::not_found:
+ setErrorMessage(QString("Not Found\n%1").arg(info));
+ break;
+ case PermanentFailure::bad_request:
+ setErrorMessage(QString("Bad Request\n%1").arg(info));
+ break;
+ case PermanentFailure::unspecified:
+ setErrorMessage(QString("Permanent Failure\n%1").arg(info));
+ break;
+ case PermanentFailure::proxy_request_required:
+ setErrorMessage(QString("Proxy Request Required\n%1").arg(info));
+ break;
+ }
+}
+
+void BrowserTab::on_transientCertificateRequested(const QString &reason)
+{
+ QMessageBox::warning(this, "Kristall", "Transient certificate requirested:\n" + reason);
+ this->updateUI();
+}
+
+void BrowserTab::on_authorisedCertificateRequested(const QString &reason)
+{
+ QMessageBox::warning(this, "Kristall", "Authorized certificate requirested:\n" + reason);
+ this->updateUI();
+}
+
+void BrowserTab::on_certificateRejected(CertificateRejection reason, const QString &info)
+{
+ switch(reason)
+ {
+ case CertificateRejection::unspecified:
+ setErrorMessage(QString("Certificate Rejected\n%1").arg(info));
+ break;
+ case CertificateRejection::not_accepted:
+ setErrorMessage(QString("Certificate not accepted\n%1").arg(info));
+ break;
+ case CertificateRejection::future_certificate_rejected:
+ setErrorMessage(QString("Certificate is not yet valid\n%1").arg(info));
+ break;
+ case CertificateRejection::expired_certificate_rejected:
+ setErrorMessage(QString("Certificate expired\n%1").arg(info));
+ break;
+ }
+}
+
+void BrowserTab::on_linkHovered(const QString &url)
+{
+ this->mainWindow->setUrlPreview(QUrl(url));
+}
+
+void BrowserTab::setErrorMessage(const QString &msg)
+{
+ // this->page.setContent(QString("An error happened:\n%0").arg(msg).toUtf8(), "text/plain charset=utf-8");
+ QMessageBox::warning(this, "Kristall", msg);
+ this->updateUI();
+}
+
+void BrowserTab::pushToHistory(const QUrl &url)
+{
+ this->current_history_index = this->history.pushUrl(this->current_history_index, url);
+ this->updateUI();
+}
+
+void BrowserTab::on_fav_button_clicked()
+{
+ if(this->ui->fav_button->isChecked()) {
+ this->mainWindow->favourites.add(this->current_location);
+ } else {
+ this->mainWindow->favourites.remove(this->current_location);
+ }
+
+ this->updateUI();
+}
+
+
+void BrowserTab::on_text_browser_anchorClicked(const QUrl &url)
+{
+ qDebug() << url;
+
+ QUrl real_url = url;
+ if(real_url.isRelative())
+ real_url = this->current_location.resolved(url);
+
+ if(real_url.scheme() != "gemini") {
+ QMessageBox::warning(this, "Kristall", QString("Unsupported url: %1").arg(real_url.toString()));
+ }
+ else {
+ this->navigateTo(real_url, PushAfterSuccess);
+ }
+}
+
+void BrowserTab::on_text_browser_highlighted(const QUrl &url)
+{
+ QUrl real_url = url;
+ if(real_url.isRelative())
+ real_url = this->current_location.resolved(url);
+ this->mainWindow->setUrlPreview(real_url);
+}
+
+void BrowserTab::on_stop_button_clicked()
+{
+ gemini_client.cancelRequest();
+}
+
+void BrowserTab::on_back_button_clicked()
+{
+ navOneBackback();
+}
+
+void BrowserTab::on_forward_button_clicked()
+{
+ navOneForward();
+}
+
+void BrowserTab::updateUI()
+{
+ this->ui->back_button->setEnabled(history.oneBackward(current_history_index).isValid());
+ this->ui->forward_button->setEnabled(history.oneForward(current_history_index).isValid());
+
+ this->ui->refresh_button->setVisible(this->successfully_loaded);
+ this->ui->stop_button->setVisible(not this->successfully_loaded);
+
+ this->ui->fav_button->setEnabled(this->successfully_loaded);
+ this->ui->fav_button->setChecked(this->mainWindow->favourites.contains(this->current_location));
+}
diff --git a/src/browsertab.hpp b/src/browsertab.hpp
new file mode 100644
index 0000000..7f0ac35
--- /dev/null
+++ b/src/browsertab.hpp
@@ -0,0 +1,114 @@
+#ifndef BROWSERTAB_HPP
+#define BROWSERTAB_HPP
+
+#include <QWidget>
+#include <QUrl>
+#include <QGraphicsScene>
+#include <QTextDocument>
+
+#include "geminiclient.hpp"
+#include "documentoutlinemodel.hpp"
+#include "tabbrowsinghistory.hpp"
+#include "geminirenderer.hpp"
+
+namespace Ui {
+class BrowserTab;
+}
+
+class MainWindow;
+
+class BrowserTab : public QWidget
+{
+ Q_OBJECT
+public:
+ enum PushToHistory {
+ DontPush,
+ PushImmediate,
+ PushAfterSuccess,
+ };
+
+public:
+ explicit BrowserTab(MainWindow * mainWindow);
+ ~BrowserTab();
+
+ void navigateTo(QUrl const & url, PushToHistory mode);
+
+ void navigateBack(QModelIndex history_index);
+
+ void navOneBackback();
+
+ void navOneForward();
+
+ void scrollToAnchor(QString const & anchor);
+
+ void reloadPage();
+
+signals:
+ void titleChanged(QString const & title);
+ void locationChanged(QUrl const & url);
+
+private slots:
+ void on_url_bar_returnPressed();
+
+ void on_refresh_button_clicked();
+
+ void on_gemini_complete(QByteArray const & data, QString const & mime);
+
+ void on_protocolViolation(QString const & reason);
+
+ void on_inputRequired(QString const & query);
+
+ void on_redirected(QUrl const & uri, bool is_permanent);
+
+ void on_temporaryFailure(TemporaryFailure reason, QString const & info);
+
+ void on_permanentFailure(PermanentFailure reason, QString const & info);
+
+ void on_transientCertificateRequested(QString const & reason);
+
+ void on_authorisedCertificateRequested(QString const & reason);
+
+ void on_certificateRejected(CertificateRejection reason, QString const & info);
+
+ void on_linkHovered(const QString &url);
+
+ void on_fav_button_clicked();
+
+ void on_text_browser_anchorClicked(const QUrl &arg1);
+
+ void on_text_browser_highlighted(const QUrl &arg1);
+
+ void on_back_button_clicked();
+
+ void on_forward_button_clicked();
+
+ void on_stop_button_clicked();
+
+private:
+ void setErrorMessage(QString const & msg);
+
+ void pushToHistory(QUrl const & url);
+
+ void updateUI();
+
+public:
+
+ Ui::BrowserTab *ui;
+ MainWindow * mainWindow;
+ QUrl current_location;
+
+ GeminiClient gemini_client;
+ int redirection_count = 0;
+
+ bool push_to_history_after_load = false;
+ bool successfully_loaded = false;
+
+ DocumentOutlineModel outline;
+ QGraphicsScene graphics_scene;
+ TabBrowsingHistory history;
+ QModelIndex current_history_index;
+
+ std::unique_ptr<QTextDocument> current_document;
+};
+
+#endif // BROWSERTAB_HPP
diff --git a/src/browsertab.ui b/src/browsertab.ui
new file mode 100644
index 0000000..265560e
--- /dev/null
+++ b/src/browsertab.ui
@@ -0,0 +1,187 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>BrowserTab</class>
+ <widget class="QWidget" name="BrowserTab">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>692</width>
+ <height>404</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Form</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="spacing">
+ <number>0</number>
+ </property>
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <property name="sizeConstraint">
+ <enum>QLayout::SetDefaultConstraint</enum>
+ </property>
+ <property name="leftMargin">
+ <number>9</number>
+ </property>
+ <property name="topMargin">
+ <number>9</number>
+ </property>
+ <property name="rightMargin">
+ <number>9</number>
+ </property>
+ <property name="bottomMargin">
+ <number>9</number>
+ </property>
+ <item>
+ <widget class="QToolButton" name="back_button">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/arrow-left.svg</normaloff>:/icons/arrow-left.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="forward_button">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string>...</string>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/arrow-right.svg</normaloff>:/icons/arrow-right.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="stop_button">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/close.svg</normaloff>:/icons/close.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="refresh_button">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/refresh.svg</normaloff>:/icons/refresh.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="url_bar">
+ <property name="placeholderText">
+ <string>gemini://</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="fav_button">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/heart-outline.svg</normaloff>
+ <normalon>:/icons/heart.svg</normalon>:/icons/heart-outline.svg</iconset>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_2">
+ <property name="sizeConstraint">
+ <enum>QLayout::SetDefaultConstraint</enum>
+ </property>
+ <item>
+ <widget class="QTextBrowser" name="text_browser">
+ <property name="styleSheet">
+ <string notr="true"/>
+ </property>
+ <property name="autoFormatting">
+ <set>QTextEdit::AutoNone</set>
+ </property>
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ <property name="html">
+ <string>&lt;!DOCTYPE HTML PUBLIC &quot;-//W3C//DTD HTML 4.0//EN&quot; &quot;http://www.w3.org/TR/REC-html40/strict.dtd&quot;&gt;
+&lt;html&gt;&lt;head&gt;&lt;meta name=&quot;qrichtext&quot; content=&quot;1&quot; /&gt;&lt;style type=&quot;text/css&quot;&gt;
+p, li { white-space: pre-wrap; }
+&lt;/style&gt;&lt;/head&gt;&lt;body style=&quot; font-family:'Sans Serif'; font-size:9pt; font-weight:400; font-style:normal;&quot;&gt;
+&lt;p style=&quot; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;Start surfin!&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ <property name="tabStopWidth">
+ <number>40</number>
+ </property>
+ <property name="openLinks">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGraphicsView" name="graphics_browser">
+ <property name="dragMode">
+ <enum>QGraphicsView::ScrollHandDrag</enum>
+ </property>
+ <property name="transformationAnchor">
+ <enum>QGraphicsView::AnchorUnderMouse</enum>
+ </property>
+ <property name="resizeAnchor">
+ <enum>QGraphicsView::AnchorUnderMouse</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <resources>
+ <include location="icons.qrc"/>
+ </resources>
+ <connections/>
+</ui>
diff --git a/src/documentoutlinemodel.cpp b/src/documentoutlinemodel.cpp
new file mode 100644
index 0000000..5978f33
--- /dev/null
+++ b/src/documentoutlinemodel.cpp
@@ -0,0 +1,200 @@
+#include "documentoutlinemodel.hpp"
+
+#include <QModelIndex>
+
+DocumentOutlineModel::DocumentOutlineModel() :
+ QAbstractItemModel(),
+ root()
+{
+
+}
+
+void DocumentOutlineModel::clear()
+{
+ beginBuild();
+ endBuild();
+
+}
+
+void DocumentOutlineModel::beginBuild()
+{
+ beginResetModel();
+ root = Node {
+ nullptr,
+ "<ROOT>", "",
+ 0, 0,
+ QList<Node> { },
+ };
+}
+
+void DocumentOutlineModel::appendH1(const QString &title, QString const & anchor)
+{
+ root.children.append(Node {
+ &root,
+ title, anchor,
+ 1, 0,
+ QList<Node> { },
+ });
+}
+
+void DocumentOutlineModel::appendH2(const QString &title, QString const & anchor)
+{
+ auto & parent = ensureLevel1();
+ parent.children.append(Node {
+ &parent,
+ title, anchor,
+ 2, parent.children.size() - 1,
+ QList<Node> { },
+ });
+}
+
+void DocumentOutlineModel::appendH3(const QString &title, QString const & anchor)
+{
+ auto & parent = ensureLevel2();
+ parent.children.append(Node {
+ &parent,
+ title, anchor,
+ 3, parent.children.size() - 1,
+ QList<Node> { },
+ });
+}
+
+void DocumentOutlineModel::endBuild()
+{
+ for(auto const & h1 : this->root.children)
+ {
+ assert(h1.depth == 1);
+ assert(h1.parent == &this->root);
+ for(auto const & h2 : h1.children)
+ {
+ assert(h2.depth == 2);
+ assert(h2.parent == &h1);
+ for(auto const & h3 : h2.children)
+ {
+ assert(h3.depth == 3);
+ assert(h3.parent == &h2);
+ }
+ }
+ }
+ endResetModel();
+}
+
+QString DocumentOutlineModel::getTitle(const QModelIndex &index) const
+{
+ if(not index.isValid())
+ return "";
+
+ Node const *childItem = static_cast<Node const *>(index.internalPointer());
+
+ return childItem->title;
+}
+
+QString DocumentOutlineModel::getAnchor(const QModelIndex &index) const
+{
+ if(not index.isValid())
+ return "";
+
+ Node const *childItem = static_cast<Node const *>(index.internalPointer());
+
+ return childItem->anchor;
+}
+
+QModelIndex DocumentOutlineModel::index(int row, int column, const QModelIndex &parent) const
+{
+ if (not hasIndex(row, column, parent))
+ return QModelIndex();
+
+ Node const * parentItem;
+
+ if (!parent.isValid())
+ parentItem = &this->root;
+ else
+ parentItem = static_cast<Node*>(parent.internalPointer());
+
+ Node const * childItem = &parentItem->children[row];
+ if (childItem)
+ return createIndex(row, column, reinterpret_cast<quintptr>(childItem));
+ return QModelIndex();
+
+}
+
+QModelIndex DocumentOutlineModel::parent(const QModelIndex &child) const
+{
+ if (!child.isValid())
+ return QModelIndex();
+
+ Node const *childItem = static_cast<Node const *>(child.internalPointer());
+ Node const * parent = childItem->parent;
+
+ if (parent == &root)
+ return QModelIndex();
+
+ return createIndex(
+ parent->index,
+ 0,
+ reinterpret_cast<quintptr>(parent));
+}
+
+int DocumentOutlineModel::rowCount(const QModelIndex &parent) const
+{
+ Node const *parentItem;
+ if (parent.column() > 0)
+ return 0;
+
+ if (!parent.isValid())
+ parentItem = &root;
+ else
+ parentItem = static_cast<Node const *>(parent.internalPointer());
+
+ return parentItem->children.size();
+}
+
+int DocumentOutlineModel::columnCount(const QModelIndex &parent) const
+{
+ return 1;
+}
+
+QVariant DocumentOutlineModel::data(const QModelIndex &index, int role) const
+{
+ if (!index.isValid())
+ return QVariant();
+
+ if (index.column() != 0)
+ return QVariant();
+
+ if (role != Qt::DisplayRole)
+ return QVariant();
+
+ Node const *item = static_cast<Node const*>(index.internalPointer());
+
+ return item->title;
+}
+
+DocumentOutlineModel::Node & DocumentOutlineModel::ensureLevel1()
+{
+ if(root.children.size() == 0) {
+ root.children.append(Node {
+ &root,
+ "<missing layer>", "",
+ 1, 0,
+ QList<Node> { },
+ });
+ }
+ return root.children.last();
+}
+
+DocumentOutlineModel::Node & DocumentOutlineModel::ensureLevel2()
+{
+ auto & parent = ensureLevel1();
+
+ if(parent.children.size() == 0) {
+ root.children.append(Node {
+ &parent,
+ "<missing layer>", "",
+ 2, 0,
+ QList<Node> { },
+ });
+ }
+
+ return parent.children.last();
+}
diff --git a/src/documentoutlinemodel.hpp b/src/documentoutlinemodel.hpp
new file mode 100644
index 0000000..0476892
--- /dev/null
+++ b/src/documentoutlinemodel.hpp
@@ -0,0 +1,57 @@
+#ifndef DOCUMENTOUTLINEMODEL_HPP
+#define DOCUMENTOUTLINEMODEL_HPP
+
+#include <QAbstractItemModel>
+#include <QList>
+
+class DocumentOutlineModel :
+ public QAbstractItemModel
+{
+ Q_OBJECT
+public:
+ DocumentOutlineModel();
+
+ void clear();
+
+ void beginBuild();
+
+ void appendH1(QString const & title, QString const & anchor);
+
+ void appendH2(QString const & title, QString const & anchor);
+
+ void appendH3(QString const & title, QString const & anchor);
+
+ void endBuild();
+
+ QString getTitle(QModelIndex const & index) const;
+ QString getAnchor(QModelIndex const & index) const;
+
+public:
+ QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override;
+
+ QModelIndex parent(const QModelIndex &child) const override;
+
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+ int columnCount(const QModelIndex &parent = QModelIndex()) const override;
+
+ QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+
+
+private:
+ struct Node
+ {
+ Node * parent;
+ QString title;
+ QString anchor;
+ int depth = 0;
+ int index = 0;
+ QList<Node> children;
+ };
+
+ Node root;
+
+ Node & ensureLevel1();
+ Node & ensureLevel2();
+};
+
+#endif // DOCUMENTOUTLINEMODEL_HPP
diff --git a/src/favouritecollection.cpp b/src/favouritecollection.cpp
new file mode 100644
index 0000000..161eacf
--- /dev/null
+++ b/src/favouritecollection.cpp
@@ -0,0 +1,140 @@
+#include "favouritecollection.hpp"
+
+#include <QFile>
+
+FavouriteCollection::FavouriteCollection(QObject *parent) :
+ QAbstractListModel(parent)
+{
+
+}
+
+void FavouriteCollection::add(QUrl const & url)
+{
+ if(contains(url))
+ return;
+
+ beginInsertRows(QModelIndex{}, items.size(), items.size() + 1);
+ items.push_back(url);
+ endInsertRows();
+}
+
+void FavouriteCollection::remove(QUrl const & url)
+{
+ for(int i = 0; i < items.size(); i++)
+ {
+ if(items.at(i) == url) {
+ beginRemoveRows(QModelIndex{}, i, i + 1);
+ items.removeAt(i);
+ endRemoveRows();
+ return;
+ }
+ }
+}
+
+bool FavouriteCollection::contains(const QUrl &url)
+{
+ for(auto const & item : items) {
+ if(item == url)
+ return true;
+ }
+ return false;
+}
+
+QUrl FavouriteCollection::get(const QModelIndex &index) const
+{
+ if(index.isValid()) {
+ return items.at(index.row());
+ } else {
+ return QUrl { };
+ }
+}
+
+bool FavouriteCollection::save(const QString &fileName) const
+{
+ QFile file(fileName);
+ if(not file.open(QFile::WriteOnly))
+ return false;
+
+ for(auto const & url: items)
+ {
+ QByteArray blob = (url.toString() + "\n").toUtf8();
+
+ qint64 offset = 0;
+ while(offset < blob.size())
+ {
+ auto len = file.write(blob.data() + offset, blob.size() - offset);
+ if(len <= 0) {
+ file.close();
+ return false;
+ }
+ offset += len;
+ }
+ }
+
+ file.close();
+ return true;
+}
+
+bool FavouriteCollection::save(QSettings &settings) const
+{
+ settings.beginWriteArray("favourites", items.size());
+ for(int i = 0; i < items.size(); i++)
+ {
+ settings.setArrayIndex(i);
+ settings.setValue("url", items[i].toString());
+ }
+ settings.endArray();
+ return true;
+}
+
+bool FavouriteCollection::load(const QString &fileName)
+{
+ QFile file(fileName);
+ if(not file.open(QFile::ReadOnly))
+ return false;
+ auto data = file.readAll();
+
+ beginResetModel();
+
+ items.clear();
+ for(auto line : data.split('\n')) {
+ if(line.size() > 0) {
+ items.push_back(QUrl(QString::fromUtf8(line)));
+ }
+ }
+ endResetModel();
+
+ return true;
+}
+
+bool FavouriteCollection::load(QSettings & settings)
+{
+ int len = settings.beginReadArray("favourites");
+ items.resize(len);
+ for(int i = 0; i < items.size(); i++)
+ {
+ settings.setArrayIndex(i);
+ items[i] = settings.value("url").toString();
+ }
+ settings.endArray();
+ return true;
+}
+
+int FavouriteCollection::rowCount(const QModelIndex &parent) const
+{
+ return items.size();
+}
+
+bool FavouriteCollection::setData(const QModelIndex &index, const QVariant &value, int role)
+{
+ return false;
+}
+
+QVariant FavouriteCollection::data(const QModelIndex &index, int role) const
+{
+ if(role != Qt::DisplayRole) {
+ return QVariant{};
+ }
+ return items.at(index.row()).toString();
+}
+
diff --git a/src/favouritecollection.hpp b/src/favouritecollection.hpp
new file mode 100644
index 0000000..73afa1f
--- /dev/null
+++ b/src/favouritecollection.hpp
@@ -0,0 +1,44 @@
+#ifndef FAVOURITECOLLECTION_HPP
+#define FAVOURITECOLLECTION_HPP
+
+#include <QObject>
+#include <QAbstractListModel>
+#include <QUrl>
+#include <QSettings>
+
+
+class FavouriteCollection : public QAbstractListModel
+{
+ Q_OBJECT
+public:
+ explicit FavouriteCollection(QObject *parent = nullptr);
+
+ void add(QUrl const & url);
+
+ void remove(QUrl const & url);
+
+ bool contains(QUrl const & url);
+
+ QUrl get(QModelIndex const & index) const ;
+
+ bool save(QString const & fileName) const;
+ bool save(QSettings & settings) const;
+
+ bool load(QString const & fileName);
+ bool load(QSettings & settings);
+
+public:
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+
+ bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override;
+
+ QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+
+signals:
+
+private:
+ QVector<QUrl> items;
+
+};
+
+#endif // FAVOURITECOLLECTION_HPP
diff --git a/src/geminiclient.cpp b/src/geminiclient.cpp
new file mode 100644
index 0000000..44fa864
--- /dev/null
+++ b/src/geminiclient.cpp
@@ -0,0 +1,252 @@
+#include "geminiclient.hpp"
+
+#include <QDebug>
+
+GeminiClient::GeminiClient(QObject *parent) : QObject(parent)
+{
+ connect(&socket, &QSslSocket::encrypted, this, &GeminiClient::socketEncrypted);
+ connect(&socket, &QSslSocket::readyRead, this, &GeminiClient::socketReadyRead);
+ connect(&socket, &QSslSocket::disconnected, this, &GeminiClient::socketDisconnected);
+ connect(&socket, QOverload<const QList<QSslError> &>::of(&QSslSocket::sslErrors), this, &GeminiClient::sslErrors);
+ connect(&socket, QOverload<QAbstractSocket::SocketError>::of(&QSslSocket::error), this, &GeminiClient::socketError);
+}
+
+GeminiClient::~GeminiClient()
+{
+ is_receiving_body = false;
+}
+
+bool GeminiClient::startRequest(const QUrl &url)
+{
+ if(socket.isOpen())
+ return false;
+
+ socket.connectToHostEncrypted(url.host(), url.port(1965));
+
+ buffer.clear();
+ body.clear();
+ is_receiving_body = false;
+
+ if(not socket.isOpen())
+ return false;
+
+ target_url = url;
+ mime_type = "<invalid>";
+
+ return true;
+}
+
+bool GeminiClient::isInProgress() const
+{
+ return socket.isOpen();
+}
+
+bool GeminiClient::cancelRequest()
+{
+ this->is_receiving_body = false;
+ this->socket.close();
+ this->buffer.clear();
+ this->body.clear();
+ return true;
+}
+
+void GeminiClient::socketEncrypted()
+{
+ QString request = target_url.toString() + "\r\n";
+
+ QByteArray request_bytes = request.toUtf8();
+
+ qint64 offset = 0;
+ while(offset < request_bytes.size()) {
+ auto const len = socket.write(request_bytes.constData() + offset, request_bytes.size() - offset);
+ if(len <= 0)
+ {
+ socket.close();
+ return;
+ }
+ offset += len;
+ }
+}
+
+void GeminiClient::socketReadyRead()
+{
+ QByteArray response = socket.readAll();
+
+ if(is_receiving_body)
+ {
+ body.append(response);
+ }
+ else
+ {
+ for(int i = 0; i < response.size(); i++)
+ {
+ if(response[i] == '\n') {
+ buffer.append(response.data(), i);
+ body.append(response.data() + i + 1, response.size() - i - 1);
+
+ // "XY " <META> <CR> <LF>
+ if(buffer.size() <= 5) {
+ socket.close();
+ qDebug() << buffer;
+ emit protocolViolation("Line is too short for valid protocol");
+ return;
+ }
+ if(buffer[buffer.size() - 1] != '\r') {
+ socket.close();
+ qDebug() << buffer;
+ emit protocolViolation("Line does not end with <CR> <LF>");
+ return;
+ }
+ if(not isdigit(buffer[0])) {
+ socket.close();
+ qDebug() << buffer;
+ emit protocolViolation("First character is not a digit.");
+ return;
+ }
+ if(not isdigit(buffer[1])) {
+ socket.close();
+ qDebug() << buffer;
+ emit protocolViolation("Second character is not a digit.");
+ return;
+ }
+ // TODO: Implement stricter version
+ // if(buffer[2] != ' ') {
+ if(not isspace(buffer[2])) {
+ socket.close();
+ qDebug() << buffer;
+ emit protocolViolation("Third character is not a space.");
+ return;
+ }
+
+ QString meta = QString::fromUtf8(buffer.data() + 3, buffer.size() - 4);
+
+ int primary_code = buffer[0] - '0';
+ int secondary_code = buffer[1] - '0';
+
+ qDebug() << primary_code << secondary_code << meta;
+
+ // We don't need to receive any data after that.
+ if(primary_code != 2)
+ socket.close();
+
+ switch(primary_code)
+ {
+ case 1: // requesting input
+ emit inputRequired(meta);
+ return;
+
+ case 2: // success
+ is_receiving_body = true;
+ mime_type = meta;
+ return;
+
+ case 3: { // redirect
+ QUrl new_url(meta);
+ if(new_url.isValid()) {
+ if(new_url.isRelative())
+ new_url = target_url.resolved(new_url);
+ assert(not new_url.isRelative());
+
+ emit redirected(new_url, (secondary_code == 1));
+ }
+ else {
+ emit protocolViolation("Invalid URL for redirection!");
+ }
+ return;
+ }
+
+ case 4: { // temporary failure
+ TemporaryFailure type = TemporaryFailure::unspecified;
+ switch(secondary_code)
+ {
+ case 1: type = TemporaryFailure::server_unavailable; break;
+ case 2: type = TemporaryFailure::cgi_error; break;
+ case 3: type = TemporaryFailure::proxy_error; break;
+ case 4: type = TemporaryFailure::slow_down; break;
+ }
+ emit temporaryFailure(type, meta);
+ return;
+ }
+
+ case 5: { // permanent failure
+ PermanentFailure type = PermanentFailure::unspecified;
+ switch(secondary_code)
+ {
+ case 1: type = PermanentFailure::not_found; break;
+ case 2: type = PermanentFailure::gone; break;
+ case 3: type = PermanentFailure::proxy_request_required; break;
+ case 9: type = PermanentFailure::bad_request; break;
+ }
+ emit permanentFailure(type, meta);
+ return;
+ }
+
+ case 6: // client certificate required
+ switch(secondary_code)
+ {
+ case 1:
+ emit transientCertificateRequested(meta);
+ return;
+
+ case 2:
+ emit authorisedCertificateRequested(meta);
+ return;
+
+ case 3:
+ emit certificateRejected(CertificateRejection::not_accepted, meta);
+ return;
+
+ case 4:
+ emit certificateRejected(CertificateRejection::future_certificate_rejected, meta);
+ return;
+
+ case 5:
+ emit certificateRejected(CertificateRejection::expired_certificate_rejected, meta);
+ return;
+
+ default:
+ emit certificateRejected(CertificateRejection::unspecified, meta);
+ return;
+ }
+ return;
+
+ default:
+ emit protocolViolation("Unspecified status code used!");
+ return;
+ }
+
+ assert(false and "unreachable");
+ }
+ }
+ buffer.append(response);
+ }
+}
+
+void GeminiClient::socketDisconnected()
+{
+ if(is_receiving_body) {
+ body.append(socket.readAll());
+ emit requestComplete(body, mime_type);
+ }
+}
+
+void GeminiClient::sslErrors(const QList<QSslError> &errors)
+{
+ for(auto const & error : errors) {
+ qWarning() << error.errorString() ;
+ }
+
+ socket.ignoreSslErrors(errors);
+}
+
+void GeminiClient::socketError(QAbstractSocket::SocketError socketError)
+{
+ // When remote host closes TLS session, the client closes the socket.
+ // This is more sane then erroring out here as it's a perfectly legal
+ // state and we know the TLS connection has ended.
+ if(socketError == QAbstractSocket::RemoteHostClosedError) {
+ socket.close();
+ } else {
+ qWarning() << socketError << socket.errorString();
+ }
+}
diff --git a/src/geminiclient.hpp b/src/geminiclient.hpp
new file mode 100644
index 0000000..590eb5b
--- /dev/null
+++ b/src/geminiclient.hpp
@@ -0,0 +1,89 @@
+#ifndef GEMINICLIENT_HPP
+#define GEMINICLIENT_HPP
+
+#include <QObject>
+#include <QMimeType>
+#include <QSslSocket>
+#include <QUrl>
+
+enum class TemporaryFailure {
+ unspecified,
+ server_unavailable,
+ cgi_error,
+ proxy_error,
+ slow_down,
+};
+
+enum class PermanentFailure {
+ unspecified,
+ not_found,
+ gone,
+ proxy_request_required,
+ bad_request,
+};
+
+enum class CertificateRejection {
+ unspecified,
+ not_accepted,
+ future_certificate_rejected,
+ expired_certificate_rejected,
+};
+
+class GeminiClient : public QObject
+{
+private:
+ Q_OBJECT
+public:
+ explicit GeminiClient(QObject *parent = nullptr);
+
+ ~GeminiClient() override;
+
+ bool startRequest(QUrl const & url);
+
+ bool isInProgress() const;
+
+ bool cancelRequest();
+
+signals:
+ void requestComplete(QByteArray const & data, QString const & mime);
+
+ void protocolViolation(QString const & reason);
+
+ void inputRequired(QString const & query);
+
+ void redirected(QUrl const & uri, bool is_permanent);
+
+ void temporaryFailure(TemporaryFailure reason, QString const & info);
+
+ void permanentFailure(PermanentFailure reason, QString const & info);
+
+ void transientCertificateRequested(QString const & reason);
+
+ void authorisedCertificateRequested(QString const & reason);
+
+ void certificateRejected(CertificateRejection reason, QString const & info);
+
+private slots:
+
+ void socketEncrypted();
+
+ void socketReadyRead();
+
+ void socketDisconnected();
+
+ void sslErrors(const QList<QSslError> &errors);
+
+ void socketError(QAbstractSocket::SocketError socketError);
+
+
+private:
+ bool is_receiving_body;
+
+ QUrl target_url;
+ QSslSocket socket;
+ QByteArray buffer;
+ QByteArray body;
+ QString mime_type;
+};
+
+#endif // GEMINICLIENT_HPP
diff --git a/src/geminirenderer.cpp b/src/geminirenderer.cpp
new file mode 100644
index 0000000..811a946
--- /dev/null
+++ b/src/geminirenderer.cpp
@@ -0,0 +1,404 @@
+#include "geminirenderer.hpp"
+
+#include <QTextList>
+#include <QCryptographicHash>
+#include <QTextBlock>
+#include <QDebug>
+#include <cmath>
+
+static QByteArray trim_whitespace(QByteArray items)
+{
+ int start = 0;
+ while (start < items.size() and isspace(items.at(start)))
+ {
+ start += 1;
+ }
+ int end = items.size() - 1;
+ while (end > 0 and isspace(items.at(end)))
+ {
+ end -= 1;
+ }
+ return items.mid(start, end - start + 1);
+}
+
+GeminiStyle::GeminiStyle() : theme(Fixed),
+ standard_font(),
+ h1_font(),
+ h2_font(),
+ h3_font(),
+ preformatted_font(),
+ background_color("#edefff"),
+ standard_color(0x00, 0x00, 0x00),
+ preformatted_color(0x00, 0x00, 0x00),
+ h1_color("#022f90"),
+ h2_color("#022f90"),
+ h3_color("#022f90"),
+ internal_link_color("#0e8fff"),
+ external_link_color("#0e8fff"),
+ cross_scheme_link_color("#0960a7"),
+ internal_link_prefix("→ "),
+ external_link_prefix("⇒ "),
+ margin(55.0)
+{
+ preformatted_font.setFamily("monospace");
+ preformatted_font.setPointSizeF(10.0);
+
+ standard_font.setFamily("sans");
+ standard_font.setPointSizeF(10.0);
+
+ h1_font.setFamily("sans");
+ h1_font.setBold(true);
+ h1_font.setPointSizeF(20.0);
+
+ h2_font.setFamily("sans");
+ h2_font.setBold(true);
+ h2_font.setPointSizeF(15.0);
+
+ h3_font.setFamily("sans");
+ h3_font.setBold(true);
+ h3_font.setPointSizeF(12.0);
+}
+
+bool GeminiStyle::save(QSettings &settings) const
+{
+ settings.beginGroup("Theme");
+
+ settings.setValue("standard_font", standard_font.toString());
+ settings.setValue("h1_font", h1_font.toString());
+ settings.setValue("h2_font", h2_font.toString());
+ settings.setValue("h3_font", h3_font.toString());
+ settings.setValue("preformatted_font", preformatted_font.toString());
+
+ settings.setValue("background_color", background_color.name());
+ settings.setValue("standard_color", standard_color.name());
+ settings.setValue("preformatted_color", preformatted_color.name());
+ settings.setValue("h1_color", h1_color.name());
+ settings.setValue("h2_color", h2_color.name());
+ settings.setValue("h3_color", h3_color.name());
+ settings.setValue("internal_link_color", internal_link_color.name());
+ settings.setValue("external_link_color", external_link_color.name());
+ settings.setValue("cross_scheme_link_color", cross_scheme_link_color.name());
+
+ settings.setValue("internal_link_prefix", internal_link_prefix);
+ settings.setValue("external_link_prefix", external_link_prefix);
+
+ settings.setValue("margins", margin);
+ settings.setValue("theme", int(theme));
+
+ settings.endGroup();
+ return true;
+}
+
+bool GeminiStyle::load(QSettings &settings)
+{
+ settings.beginGroup("Theme");
+
+ if(settings.contains("standard_color"))
+ {
+ standard_font.fromString(settings.value("standard_font").toString());
+ h1_font.fromString(settings.value("h1_font").toString());
+ h2_font.fromString(settings.value("h2_font").toString());
+ h3_font.fromString(settings.value("h3_font").toString());
+ preformatted_font.fromString(settings.value("preformatted_font").toString());
+
+ background_color = QColor(settings.value("background_color").toString());
+ standard_color = QColor(settings.value("standard_color").toString());
+ preformatted_color = QColor(settings.value("preformatted_color").toString());
+ h1_color = QColor(settings.value("h1_color").toString());
+ h2_color = QColor(settings.value("h2_color").toString());
+ h3_color = QColor(settings.value("h3_color").toString());
+ internal_link_color = QColor(settings.value("internal_link_color").toString());
+ external_link_color = QColor(settings.value("external_link_color").toString());
+ cross_scheme_link_color = QColor(settings.value("cross_scheme_link_color").toString());
+
+ internal_link_prefix = settings.value("internal_link_prefix").toString();
+ external_link_prefix = settings.value("external_link_prefix").toString();
+
+ margin = settings.value("margins").toDouble();
+ theme = Theme(settings.value("theme").toInt());
+ }
+
+ settings.endGroup();
+ return true;
+}
+
+GeminiStyle GeminiStyle::derive(const QUrl &url) const
+{
+ if (this->theme == Fixed)
+ return *this;
+
+ QByteArray hash = QCryptographicHash::hash(url.host().toUtf8(), QCryptographicHash::Md5);
+
+ std::array<uint8_t, 16> items;
+ assert(items.size() == hash.size());
+ memcpy(items.data(), hash.data(), items.size());
+
+ float hue = (items[0] + items[1]) / 510.0;
+ float saturation = items[2] / 255.0;
+
+ double tmp;
+ GeminiStyle themed = *this;
+ switch (this->theme)
+ {
+ case AutoDarkTheme:
+ {
+ themed.background_color = QColor::fromHslF(hue, saturation, 0.25f);
+ themed.standard_color = QColor{0xFF, 0xFF, 0xFF};
+
+ themed.h1_color = QColor::fromHslF(std::modf(hue + 0.5, &tmp), 1.0 - saturation, 0.75);
+ themed.h2_color = QColor::fromHslF(std::modf(hue + 0.5, &tmp), 1.0 - saturation, 0.75);
+ themed.h3_color = QColor::fromHslF(std::modf(hue + 0.5, &tmp), 1.0 - saturation, 0.75);
+
+ themed.external_link_color = QColor::fromHslF(std::modf(hue + 0.25, &tmp), 1.0, 0.75);
+ themed.internal_link_color = themed.external_link_color.lighter(110);
+ themed.cross_scheme_link_color = themed.external_link_color.darker(110);
+
+ break;
+ }
+
+ case AutoLightTheme:
+ {
+ themed.background_color = QColor::fromHslF(hue, items[2] / 255.0, 0.85);
+ themed.standard_color = QColor{0x00, 0x00, 0x00};
+
+ themed.h1_color = QColor::fromHslF(std::modf(hue + 0.5, &tmp), 1.0 - saturation, 0.25);
+ themed.h2_color = QColor::fromHslF(std::modf(hue + 0.5, &tmp), 1.0 - saturation, 0.25);
+ themed.h3_color = QColor::fromHslF(std::modf(hue + 0.5, &tmp), 1.0 - saturation, 0.25);
+
+ themed.external_link_color = QColor::fromHslF(std::modf(hue + 0.25, &tmp), 1.0, 0.25);
+ themed.internal_link_color = themed.external_link_color.darker(110);
+ themed.cross_scheme_link_color = themed.external_link_color.lighter(110);
+
+ break;
+ }
+
+ case Fixed:
+ assert(false);
+ }
+
+ // Same for all themes
+ themed.preformatted_color = themed.standard_color;
+
+ return themed;
+}
+
+GeminiRenderer::GeminiRenderer(GeminiStyle const &_style) : style(_style)
+{
+}
+
+std::unique_ptr<GeminiDocument> GeminiRenderer::render(const QByteArray &input, QUrl const &root_url, DocumentOutlineModel &outline)
+{
+ auto themed_style = style.derive(root_url);
+
+ QTextCharFormat preformatted;
+ preformatted.setFont(themed_style.preformatted_font);
+ preformatted.setForeground(themed_style.preformatted_color);
+
+ QTextCharFormat standard;
+ standard.setFont(themed_style.standard_font);
+ standard.setForeground(themed_style.standard_color);
+
+ QTextCharFormat standard_link;
+ standard_link.setFont(themed_style.standard_font);
+ standard_link.setForeground(QBrush(themed_style.internal_link_color));
+
+ QTextCharFormat external_link;
+ external_link.setFont(themed_style.standard_font);
+ external_link.setForeground(QBrush(themed_style.external_link_color));
+
+ QTextCharFormat cross_protocol_link;
+ cross_protocol_link.setFont(themed_style.standard_font);
+ cross_protocol_link.setForeground(QBrush(themed_style.cross_scheme_link_color));
+
+ QTextCharFormat standard_h1;
+ standard_h1.setFont(themed_style.h1_font);
+ standard_h1.setForeground(QBrush(themed_style.h1_color));
+
+ QTextCharFormat standard_h2;
+ standard_h2.setFont(themed_style.h2_font);
+ standard_h2.setForeground(QBrush(themed_style.h2_color));
+
+ QTextCharFormat standard_h3;
+ standard_h3.setFont(themed_style.h3_font);
+ standard_h3.setForeground(QBrush(themed_style.h3_color));
+
+ std::unique_ptr<GeminiDocument> result = std::make_unique<GeminiDocument>();
+ result->setDocumentMargin(themed_style.margin);
+ result->background_color = themed_style.background_color;
+
+ QTextCursor cursor{result.get()};
+
+ QTextBlockFormat non_list_format = cursor.blockFormat();
+
+ bool verbatim = false;
+ QTextList *current_list = nullptr;
+
+ outline.beginBuild();
+
+ int anchor_id = 0;
+
+ auto unique_anchor_name = [&]() -> QString {
+ return QString("auto-title-%1").arg(++anchor_id);
+ };
+
+ QList<QByteArray> lines = input.split('\n');
+ for (auto const &line : lines)
+ {
+ if (verbatim)
+ {
+ if (line.startsWith("```"))
+ {
+ verbatim = false;
+ }
+ else
+ {
+ cursor.setCharFormat(preformatted);
+ cursor.insertText(line + "\n");
+ }
+ }
+ else
+ {
+ if (line.startsWith("*"))
+ {
+ if (current_list == nullptr)
+ {
+ cursor.deletePreviousChar();
+ current_list = cursor.insertList(QTextListFormat::ListDisc);
+ }
+ else
+ {
+ cursor.insertBlock();
+ }
+
+ QString item = trim_whitespace(line.mid(1));
+
+ cursor.insertText(item, standard);
+ continue;
+ }
+ else
+ {
+ if (current_list != nullptr)
+ {
+ cursor.insertBlock();
+ cursor.setBlockFormat(non_list_format);
+ }
+ current_list = nullptr;
+ }
+
+ if (line.startsWith("###"))
+ {
+ auto heading = trim_whitespace(line.mid(3));
+
+ auto id = unique_anchor_name();
+ auto fmt = standard_h3;
+ fmt.setAnchor(true);
+ fmt.setAnchorNames(QStringList { id });
+
+ cursor.insertText(heading + "\n", fmt);
+ outline.appendH3(heading, id);
+ }
+ else if (line.startsWith("##"))
+ {
+ auto heading = trim_whitespace(line.mid(2));
+
+ auto id = unique_anchor_name();
+ auto fmt = standard_h2;
+ fmt.setAnchor(true);
+ fmt.setAnchorNames(QStringList { id });
+
+ cursor.insertText(heading + "\n", fmt);
+ outline.appendH2(heading, id);
+ }
+ else if (line.startsWith("#"))
+ {
+ auto heading = trim_whitespace(line.mid(1));
+
+ auto id = unique_anchor_name();
+ auto fmt = standard_h1;
+ fmt.setAnchor(true);
+ fmt.setAnchorNames(QStringList { id });
+
+ cursor.insertText(heading + "\n", fmt);
+ outline.appendH1(heading, id);
+ }
+ else if (line.startsWith("=>"))
+ {
+ auto const part = line.mid(2).trimmed();
+
+ QByteArray link, title;
+
+ int index = -1;
+ for (int i = 0; i < part.size(); i++)
+ {
+ if (isspace(part[i]))
+ {
+ index = i;
+ break;
+ }
+ }
+
+ if (index > 0)
+ {
+ link = trim_whitespace(part.mid(0, index));
+ title = trim_whitespace(part.mid(index + 1));
+ }
+ else
+ {
+ link = trim_whitespace(part);
+ title = trim_whitespace(part);
+ }
+
+ auto local_url = QUrl(link);
+
+ auto absolute_url = root_url.resolved(QUrl(link));
+
+ // qDebug() << link << title;
+
+ auto fmt = standard_link;
+
+ QString prefix;
+ if (absolute_url.host() == root_url.host())
+ {
+ prefix = themed_style.internal_link_prefix;
+ fmt = standard_link;
+ }
+ else
+ {
+ prefix = themed_style.external_link_prefix;
+ fmt = external_link;
+ }
+
+ QString suffix = "";
+ if (absolute_url.scheme() != root_url.scheme())
+ {
+ suffix = " [" + absolute_url.scheme().toUpper() + "]";
+ fmt = cross_protocol_link;
+ }
+
+ fmt.setAnchor(true);
+ fmt.setAnchorHref(absolute_url.toString());
+ cursor.insertText(prefix + title + suffix + "\n", fmt);
+ }
+ else if (line.startsWith("```"))
+ {
+ verbatim = true;
+ }
+ else
+ {
+ cursor.insertText(line + "\n", standard);
+ }
+ }
+ }
+
+ outline.endBuild();
+ return result;
+}
+
+GeminiDocument::GeminiDocument(QObject *parent) : QTextDocument(parent),
+ background_color(0x00, 0x00, 0x00)
+{
+}
+
+GeminiDocument::~GeminiDocument()
+{
+}
diff --git a/src/geminirenderer.hpp b/src/geminirenderer.hpp
new file mode 100644
index 0000000..2ec1651
--- /dev/null
+++ b/src/geminirenderer.hpp
@@ -0,0 +1,76 @@
+#ifndef GEMINIRENDERER_HPP
+#define GEMINIRENDERER_HPP
+
+#include <QTextDocument>
+#include <QColor>
+#include <QSettings>
+
+#include "documentoutlinemodel.hpp"
+
+struct GeminiStyle
+{
+ enum Theme {
+ Fixed = 0,
+ AutoDarkTheme = 1,
+ AutoLightTheme = 2
+ };
+
+ GeminiStyle();
+
+ Theme theme;
+
+ QFont standard_font;
+ QFont h1_font;
+ QFont h2_font;
+ QFont h3_font;
+ QFont preformatted_font;
+
+ QColor background_color;
+ QColor standard_color;
+ QColor preformatted_color;
+ QColor h1_color;
+ QColor h2_color;
+ QColor h3_color;
+
+ QColor internal_link_color;
+ QColor external_link_color;
+ QColor cross_scheme_link_color;
+
+ QString internal_link_prefix;
+ QString external_link_prefix;
+
+ double margin;
+
+ bool save(QSettings & settings) const;
+ bool load(QSettings & settings);
+
+ //! Create a new style with auto-generated colors for the given
+ //! url. The colors are based on the host name
+ GeminiStyle derive(QUrl const & url) const;
+};
+
+class GeminiDocument :
+ public QTextDocument
+{
+ Q_OBJECT
+public:
+ explicit GeminiDocument(QObject * parent = nullptr);
+ ~GeminiDocument() override;
+
+ QColor background_color;
+};
+
+class GeminiRenderer
+{
+ GeminiStyle style;
+public:
+ GeminiRenderer(GeminiStyle const & style = GeminiStyle{});
+
+ std::unique_ptr<GeminiDocument> render(
+ QByteArray const & input,
+ QUrl const & root_url,
+ DocumentOutlineModel & outline
+ );
+};
+
+#endif // GEMINIRENDERER_HPP
diff --git a/src/icons.qrc b/src/icons.qrc
new file mode 100644
index 0000000..a0daae0
--- /dev/null
+++ b/src/icons.qrc
@@ -0,0 +1,15 @@
+<RCC>
+ <qresource prefix="/">
+ <file>icons/arrow-left.svg</file>
+ <file>icons/arrow-right.svg</file>
+ <file>icons/heart-outline.svg</file>
+ <file>icons/heart.svg</file>
+ <file>icons/menu.svg</file>
+ <file>icons/refresh.svg</file>
+ <file>icons/close.svg</file>
+ <file>icons/format-font.svg</file>
+ <file>icons/palette.svg</file>
+ <file>icons/kristall.svg</file>
+ <file>icons/settings.svg</file>
+ </qresource>
+</RCC>
diff --git a/src/icons/arrow-left.svg b/src/icons/arrow-left.svg
new file mode 100644
index 0000000..72f5e6d
--- /dev/null
+++ b/src/icons/arrow-left.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z" /></svg> \ No newline at end of file
diff --git a/src/icons/arrow-right.svg b/src/icons/arrow-right.svg
new file mode 100644
index 0000000..22dc526
--- /dev/null
+++ b/src/icons/arrow-right.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z" /></svg> \ No newline at end of file
diff --git a/src/icons/close.svg b/src/icons/close.svg
new file mode 100644
index 0000000..18691d7
--- /dev/null
+++ b/src/icons/close.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" /></svg> \ No newline at end of file
diff --git a/src/icons/format-font.svg b/src/icons/format-font.svg
new file mode 100644
index 0000000..c88cb71
--- /dev/null
+++ b/src/icons/format-font.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M17,8H20V20H21V21H17V20H18V17H14L12.5,20H14V21H10V20H11L17,8M18,9L14.5,16H18V9M5,3H10C11.11,3 12,3.89 12,5V16H9V11H6V16H3V5C3,3.89 3.89,3 5,3M6,5V9H9V5H6Z" /></svg> \ No newline at end of file
diff --git a/src/icons/heart-outline.svg b/src/icons/heart-outline.svg
new file mode 100644
index 0000000..26b2df3
--- /dev/null
+++ b/src/icons/heart-outline.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z" /></svg> \ No newline at end of file
diff --git a/src/icons/heart.svg b/src/icons/heart.svg
new file mode 100644
index 0000000..2cad9fc
--- /dev/null
+++ b/src/icons/heart.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z" /></svg> \ No newline at end of file
diff --git a/src/icons/kristall.svg b/src/icons/kristall.svg
new file mode 100644
index 0000000..60be0f6
--- /dev/null
+++ b/src/icons/kristall.svg
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ height="24"
+ width="24"
+ version="1.1">
+ <path
+ d="M 3.1369986,7.8561215 6.2997316,2.8956626 17.476275,2.7919947 20.786815,7.9230405 11.777946,20.606669 Z"
+ style="fill:#7595ff" />
+ <path
+ d="m 16,9 h 3 l -5,7 M 10,9 h 4 l -2,8 M 5,9 H 8.0000002 L 10,16 M 15,4 h 2 l 2,3 H 16 M 11,4 h 2 l 1,3 H 10 M 7,4 H 9 L 8,7 H 5 M 6,2 2,8 12,22 22,8 18,2 Z" />
+</svg>
diff --git a/src/icons/menu.svg b/src/icons/menu.svg
new file mode 100644
index 0000000..64844e7
--- /dev/null
+++ b/src/icons/menu.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z" /></svg> \ No newline at end of file
diff --git a/src/icons/palette.svg b/src/icons/palette.svg
new file mode 100644
index 0000000..ebf6936
--- /dev/null
+++ b/src/icons/palette.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M17.5,12A1.5,1.5 0 0,1 16,10.5A1.5,1.5 0 0,1 17.5,9A1.5,1.5 0 0,1 19,10.5A1.5,1.5 0 0,1 17.5,12M14.5,8A1.5,1.5 0 0,1 13,6.5A1.5,1.5 0 0,1 14.5,5A1.5,1.5 0 0,1 16,6.5A1.5,1.5 0 0,1 14.5,8M9.5,8A1.5,1.5 0 0,1 8,6.5A1.5,1.5 0 0,1 9.5,5A1.5,1.5 0 0,1 11,6.5A1.5,1.5 0 0,1 9.5,8M6.5,12A1.5,1.5 0 0,1 5,10.5A1.5,1.5 0 0,1 6.5,9A1.5,1.5 0 0,1 8,10.5A1.5,1.5 0 0,1 6.5,12M12,3A9,9 0 0,0 3,12A9,9 0 0,0 12,21A1.5,1.5 0 0,0 13.5,19.5C13.5,19.11 13.35,18.76 13.11,18.5C12.88,18.23 12.73,17.88 12.73,17.5A1.5,1.5 0 0,1 14.23,16H16A5,5 0 0,0 21,11C21,6.58 16.97,3 12,3Z" /></svg> \ No newline at end of file
diff --git a/src/icons/refresh.svg b/src/icons/refresh.svg
new file mode 100644
index 0000000..ebe3f16
--- /dev/null
+++ b/src/icons/refresh.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z" /></svg> \ No newline at end of file
diff --git a/src/icons/settings.svg b/src/icons/settings.svg
new file mode 100644
index 0000000..731a5a7
--- /dev/null
+++ b/src/icons/settings.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z" /></svg> \ No newline at end of file
diff --git a/src/kristall.pro b/src/kristall.pro
new file mode 100644
index 0000000..6aa5f60
--- /dev/null
+++ b/src/kristall.pro
@@ -0,0 +1,53 @@
+QT += core gui
+
+greaterThan(QT_MAJOR_VERSION, 4): QT += widgets network
+
+CONFIG += c++17
+
+# The following define makes your compiler emit warnings if you use
+# any Qt feature that has been marked deprecated (the exact warnings
+# depend on your compiler). Please consult the documentation of the
+# deprecated API in order to know how to port your code away from it.
+DEFINES += QT_DEPRECATED_WARNINGS
+
+# You can also make your code fail to compile if it uses deprecated APIs.
+# In order to do so, uncomment the following line.
+# You can also select to disable deprecated APIs only up to a certain version of Qt.
+#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0
+
+SOURCES += \
+ browsertab.cpp \
+ documentoutlinemodel.cpp \
+ favouritecollection.cpp \
+ geminiclient.cpp \
+ geminirenderer.cpp \
+ main.cpp \
+ mainwindow.cpp \
+ settingsdialog.cpp \
+ tabbrowsinghistory.cpp
+
+HEADERS += \
+ browsertab.hpp \
+ documentoutlinemodel.hpp \
+ favouritecollection.hpp \
+ geminiclient.hpp \
+ geminirenderer.hpp \
+ mainwindow.hpp \
+ settingsdialog.hpp \
+ tabbrowsinghistory.hpp
+
+FORMS += \
+ browsertab.ui \
+ mainwindow.ui \
+ settingsdialog.ui
+
+TRANSLATIONS += \
+ kristall_en_US.ts
+
+# Default rules for deployment.
+qnx: target.path = /tmp/$${TARGET}/bin
+else: unix:!android: target.path = /opt/$${TARGET}/bin
+!isEmpty(target.path): INSTALLS += target
+
+RESOURCES += \
+ icons.qrc
diff --git a/src/kristall_en_US.ts b/src/kristall_en_US.ts
new file mode 100644
index 0000000..ef2593b
--- /dev/null
+++ b/src/kristall_en_US.ts
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="kristall_en_US"></TS>
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..31cc7da
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,18 @@
+#include "mainwindow.hpp"
+
+#include <QApplication>
+#include <QUrl>
+#include <QSettings>
+
+int main(int argc, char *argv[])
+{
+ QApplication a(argc, argv);
+ MainWindow w;
+
+
+
+ w.addNewTab(true, QUrl("gemini://gemini.circumlunar.space/"));
+
+ w.show();
+ return a.exec();
+}
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
new file mode 100644
index 0000000..19dd922
--- /dev/null
+++ b/src/mainwindow.cpp
@@ -0,0 +1,259 @@
+#include "mainwindow.hpp"
+#include "ui_mainwindow.h"
+#include "browsertab.hpp"
+#include "settingsdialog.hpp"
+
+#include <QMessageBox>
+#include <memory>
+#include <QShortcut>
+#include <QKeySequence>
+
+MainWindow::MainWindow(QWidget *parent) :
+ QMainWindow(parent),
+ settings("xqTechnologies", "Kristall"),
+ ui(new Ui::MainWindow),
+ url_status(new QLabel())
+{
+ ui->setupUi(this);
+
+ this->statusBar()->addWidget(this->url_status);
+
+ this->favourites.load(settings);
+ this->current_style.load(settings);
+
+ ui->favourites_view->setModel(&favourites);
+
+ // this->ui->history_window->setVisible(false);
+ this->ui->clientcert_window->setVisible(false);
+ this->ui->bookmarks_window->setVisible(true);
+
+ for(QDockWidget * dock : findChildren<QDockWidget *>())
+ {
+ QAction * act = this->ui->menuView ->addAction(dock->windowTitle());
+ act->setCheckable(true);
+ act->setChecked(dock->isVisible());
+ act->setData(QVariant::fromValue(dock));
+ connect(act, QOverload<bool>::of(&QAction::triggered), dock, &QDockWidget::setVisible);
+ }
+
+ connect(this->ui->menuView, &QMenu::aboutToShow, [this]() {
+ for(QAction * act : this->ui->menuView->actions())
+ {
+ auto * dock = qvariant_cast<QDockWidget*>(act->data());
+ act->setChecked(dock->isVisible());
+ }
+ });
+
+
+ {
+ settings.beginGroup("Window State");
+ if(settings.contains("geometry")) {
+ restoreGeometry(settings.value("geometry").toByteArray());
+ }
+ if(settings.contains("state")) {
+ restoreState(settings.value("state").toByteArray());
+ }
+ settings.endGroup();
+ }
+}
+
+MainWindow::~MainWindow()
+{
+
+ this->saveSettings();
+ delete ui;
+}
+
+BrowserTab * MainWindow::addEmptyTab(bool focus_new)
+{
+ BrowserTab * tab = new BrowserTab(this);
+
+ connect(tab, &BrowserTab::titleChanged, this, &MainWindow::on_tab_titleChanged);
+
+ int index = this->ui->browser_tabs->addTab(tab, "Page");
+
+ if(focus_new) {
+ this->ui->browser_tabs->setCurrentIndex(index);
+ }
+
+ return tab;
+}
+
+BrowserTab * MainWindow::addNewTab(bool focus_new, QUrl const & url)
+{
+ auto tab = addEmptyTab(focus_new);
+ tab->navigateTo(url, BrowserTab::PushImmediate);
+ return tab;
+}
+
+void MainWindow::setUrlPreview(const QUrl &url)
+{
+ if(url.isValid()) {
+ auto str = url.toString();
+ if(str.length() > 300) {
+ str = str.mid(0, 300) + "...";
+ }
+ this->url_status->setText(str);
+ }
+ else {
+ this->url_status->setText("");
+ }
+}
+
+void MainWindow::saveSettings()
+{
+ this->favourites.save(settings);
+ this->current_style.save(settings);
+
+ {
+ settings.beginGroup("Window State");
+
+ settings.setValue("geometry", saveGeometry());
+ settings.setValue("state", saveState());
+
+ settings.endGroup();
+ }
+}
+
+void MainWindow::on_browser_tabs_currentChanged(int index)
+{
+ if(index >= 0) {
+ BrowserTab * tab = qobject_cast<BrowserTab*>(this->ui->browser_tabs->widget(index));
+
+ if(tab != nullptr) {
+ this->ui->outline_view->setModel(&tab->outline);
+ this->ui->outline_view->expandAll();
+
+ this->ui->history_view->setModel(&tab->history);
+ } else {
+ this->ui->outline_view->setModel(nullptr);
+ this->ui->history_view->setModel(nullptr);
+ }
+ } else {
+ this->ui->outline_view->setModel(nullptr);
+ this->ui->history_view->setModel(nullptr);
+ }
+}
+
+void MainWindow::on_favourites_view_doubleClicked(const QModelIndex &index)
+{
+ if(auto url = this->favourites.get(index); url.isValid()) {
+ this->addNewTab(true, url);
+ }
+}
+
+void MainWindow::on_browser_tabs_tabCloseRequested(int index)
+{
+ delete this->ui->browser_tabs->widget(index);
+}
+
+void MainWindow::on_history_view_doubleClicked(const QModelIndex &index)
+{
+ BrowserTab * tab = qobject_cast<BrowserTab*>(this->ui->browser_tabs->currentWidget());
+ if(tab != nullptr) {
+ tab->navigateBack(index);
+ }
+}
+
+void MainWindow::on_tab_titleChanged(const QString &title)
+{
+ auto * tab = qobject_cast<BrowserTab*>(sender());
+ if(tab != nullptr) {
+ int index = this->ui->browser_tabs->indexOf(tab);
+ assert(index >= 0);
+ this->ui->browser_tabs->setTabText(index, title);
+ }
+}
+
+void MainWindow::on_tab_locationChanged(const QUrl &url)
+{
+ auto * tab = qobject_cast<BrowserTab*>(sender());
+ if(tab != nullptr) {
+ int index = this->ui->browser_tabs->indexOf(tab);
+ assert(index >= 0);
+ this->ui->browser_tabs->setTabToolTip(index, url.toString());
+ }
+}
+
+void MainWindow::on_outline_view_clicked(const QModelIndex &index)
+{
+ BrowserTab * tab = qobject_cast<BrowserTab*>(this->ui->browser_tabs->currentWidget());
+ if(tab != nullptr) {
+
+ auto anchor = tab->outline.getAnchor(index);
+ if(not anchor.isEmpty()) {
+ tab->scrollToAnchor(anchor);
+ }
+ }
+}
+
+void MainWindow::on_actionSettings_triggered()
+{
+ SettingsDialog dialog;
+
+ dialog.setGeminiStyle(this->current_style);
+
+ if(dialog.exec() == QDialog::Accepted) {
+ this->current_style = dialog.geminiStyle();
+ this->saveSettings();
+ }
+}
+
+void MainWindow::on_actionNew_Tab_triggered()
+{
+ this->addEmptyTab(true);
+}
+
+void MainWindow::on_actionQuit_triggered()
+{
+ QApplication::quit();
+}
+
+void MainWindow::on_actionAbout_triggered()
+{
+ QMessageBox::about(this,
+ "Kristall",
+R"about(Kristall, an OpenSource Gemini browser.
+Made by Felix "xq" Queißner
+
+This is free software. You can get the source code at
+https://github.com/MasterQ32/Kristall)about"
+ );
+}
+
+void MainWindow::on_actionClose_Tab_triggered()
+{
+ BrowserTab * tab = qobject_cast<BrowserTab*>(this->ui->browser_tabs->currentWidget());
+ if(tab != nullptr) {
+ delete tab;
+ }
+}
+
+void MainWindow::on_actionForward_triggered()
+{
+ BrowserTab * tab = qobject_cast<BrowserTab*>(this->ui->browser_tabs->currentWidget());
+ if(tab != nullptr) {
+ tab->navOneForward();
+ }
+}
+
+void MainWindow::on_actionBackward_triggered()
+{
+ BrowserTab * tab = qobject_cast<BrowserTab*>(this->ui->browser_tabs->currentWidget());
+ if(tab != nullptr) {
+ tab->navOneBackback();
+ }
+}
+
+void MainWindow::on_actionRefresh_triggered()
+{
+ BrowserTab * tab = qobject_cast<BrowserTab*>(this->ui->browser_tabs->currentWidget());
+ if(tab != nullptr) {
+ tab->reloadPage();
+ }
+}
+
+void MainWindow::on_actionAbout_Qt_triggered()
+{
+ QMessageBox::aboutQt(this, "Kristall");
+}
diff --git a/src/mainwindow.hpp b/src/mainwindow.hpp
new file mode 100644
index 0000000..41b6e2c
--- /dev/null
+++ b/src/mainwindow.hpp
@@ -0,0 +1,78 @@
+#ifndef MAINWINDOW_HPP
+#define MAINWINDOW_HPP
+
+#include <QMainWindow>
+#include <QLabel>
+#include <QSettings>
+
+
+#include "favouritecollection.hpp"
+#include "geminirenderer.hpp"
+
+QT_BEGIN_NAMESPACE
+namespace Ui { class MainWindow; }
+QT_END_NAMESPACE
+
+class BrowserTab;
+
+class MainWindow : public QMainWindow
+{
+ Q_OBJECT
+
+public:
+ MainWindow(QWidget *parent = nullptr);
+ ~MainWindow();
+
+ BrowserTab * addEmptyTab(bool focus_new);
+ BrowserTab * addNewTab(bool focus_new, QUrl const & url);
+
+ void setUrlPreview(QUrl const & url);
+
+ void saveSettings();
+
+public:
+ FavouriteCollection favourites;
+
+private slots:
+ void on_browser_tabs_currentChanged(int index);
+
+ void on_favourites_view_doubleClicked(const QModelIndex &index);
+
+ void on_browser_tabs_tabCloseRequested(int index);
+
+ void on_history_view_doubleClicked(const QModelIndex &index);
+
+ void on_tab_titleChanged(QString const & title);
+
+ void on_tab_locationChanged(QUrl const & url);
+
+ void on_outline_view_clicked(const QModelIndex &index);
+
+ void on_actionSettings_triggered();
+
+ void on_actionNew_Tab_triggered();
+
+ void on_actionQuit_triggered();
+
+ void on_actionAbout_triggered();
+
+ void on_actionClose_Tab_triggered();
+
+ void on_actionForward_triggered();
+
+ void on_actionBackward_triggered();
+
+ void on_actionRefresh_triggered();
+
+ void on_actionAbout_Qt_triggered();
+
+public:
+ QSettings settings;
+ GeminiStyle current_style;
+
+private:
+ Ui::MainWindow *ui;
+
+ QLabel * url_status;
+};
+#endif // MAINWINDOW_HPP
diff --git a/src/mainwindow.ui b/src/mainwindow.ui
new file mode 100644
index 0000000..08ba787
--- /dev/null
+++ b/src/mainwindow.ui
@@ -0,0 +1,309 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MainWindow</class>
+ <widget class="QMainWindow" name="MainWindow">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>800</width>
+ <height>600</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Kristall Browser</string>
+ </property>
+ <property name="windowIcon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/kristall.svg</normaloff>:/icons/kristall.svg</iconset>
+ </property>
+ <property name="tabShape">
+ <enum>QTabWidget::Rounded</enum>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="browser_tabs">
+ <property name="currentIndex">
+ <number>-1</number>
+ </property>
+ <property name="documentMode">
+ <bool>true</bool>
+ </property>
+ <property name="tabsClosable">
+ <bool>true</bool>
+ </property>
+ <property name="movable">
+ <bool>true</bool>
+ </property>
+ <property name="tabBarAutoHide">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QStatusBar" name="statusBar"/>
+ <widget class="QDockWidget" name="outline_window">
+ <property name="windowTitle">
+ <string>Document Outline</string>
+ </property>
+ <attribute name="dockWidgetArea">
+ <number>1</number>
+ </attribute>
+ <widget class="QWidget" name="dockWidgetContents">
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTreeView" name="outline_view">
+ <property name="autoExpandDelay">
+ <number>0</number>
+ </property>
+ <attribute name="headerVisible">
+ <bool>false</bool>
+ </attribute>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ <widget class="QDockWidget" name="bookmarks_window">
+ <property name="windowIcon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/heart.svg</normaloff>:/icons/heart.svg</iconset>
+ </property>
+ <property name="windowTitle">
+ <string>Bookmarks</string>
+ </property>
+ <attribute name="dockWidgetArea">
+ <number>2</number>
+ </attribute>
+ <widget class="QWidget" name="dockWidgetContents_2">
+ <layout class="QVBoxLayout" name="verticalLayout_3">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QListView" name="favourites_view"/>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ <widget class="QDockWidget" name="history_window">
+ <property name="windowTitle">
+ <string>History</string>
+ </property>
+ <attribute name="dockWidgetArea">
+ <number>2</number>
+ </attribute>
+ <widget class="QWidget" name="dockWidgetContents_3">
+ <layout class="QVBoxLayout" name="verticalLayout_4">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QListView" name="history_view"/>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ <widget class="QDockWidget" name="clientcert_window">
+ <property name="windowTitle">
+ <string>Client Certificates</string>
+ </property>
+ <attribute name="dockWidgetArea">
+ <number>2</number>
+ </attribute>
+ <widget class="QWidget" name="dockWidgetContents_4">
+ <layout class="QVBoxLayout" name="verticalLayout_5">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTreeView" name="clientcert_view">
+ <property name="headerHidden">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ <widget class="QMenuBar" name="menuBar">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>800</width>
+ <height>20</height>
+ </rect>
+ </property>
+ <widget class="QMenu" name="menuFile">
+ <property name="title">
+ <string>File</string>
+ </property>
+ <addaction name="actionNew_Tab"/>
+ <addaction name="actionClose_Tab"/>
+ <addaction name="separator"/>
+ <addaction name="actionSettings"/>
+ <addaction name="separator"/>
+ <addaction name="actionQuit"/>
+ </widget>
+ <widget class="QMenu" name="menuHelp">
+ <property name="title">
+ <string>Help</string>
+ </property>
+ <addaction name="actionAbout"/>
+ <addaction name="actionAbout_Qt"/>
+ </widget>
+ <widget class="QMenu" name="menuView">
+ <property name="title">
+ <string>View</string>
+ </property>
+ </widget>
+ <widget class="QMenu" name="menuNavigation">
+ <property name="title">
+ <string>Navigation</string>
+ </property>
+ <addaction name="actionBackward"/>
+ <addaction name="actionForward"/>
+ <addaction name="separator"/>
+ <addaction name="actionRefresh"/>
+ </widget>
+ <addaction name="menuFile"/>
+ <addaction name="menuNavigation"/>
+ <addaction name="menuView"/>
+ <addaction name="menuHelp"/>
+ </widget>
+ <action name="actionAbout">
+ <property name="text">
+ <string>About...</string>
+ </property>
+ </action>
+ <action name="actionQuit">
+ <property name="text">
+ <string>Quit</string>
+ </property>
+ </action>
+ <action name="actionNew_Tab">
+ <property name="text">
+ <string>New Tab</string>
+ </property>
+ <property name="shortcut">
+ <string>Ctrl+T</string>
+ </property>
+ </action>
+ <action name="actionClose_Tab">
+ <property name="text">
+ <string>Close Tab</string>
+ </property>
+ <property name="shortcut">
+ <string>Ctrl+W</string>
+ </property>
+ </action>
+ <action name="actionSettings">
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/settings.svg</normaloff>:/icons/settings.svg</iconset>
+ </property>
+ <property name="text">
+ <string>Settings</string>
+ </property>
+ </action>
+ <action name="actionBackward">
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/arrow-left.svg</normaloff>:/icons/arrow-left.svg</iconset>
+ </property>
+ <property name="text">
+ <string>Backward</string>
+ </property>
+ <property name="shortcut">
+ <string>Alt+Left</string>
+ </property>
+ </action>
+ <action name="actionForward">
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/arrow-right.svg</normaloff>:/icons/arrow-right.svg</iconset>
+ </property>
+ <property name="text">
+ <string>Forward</string>
+ </property>
+ <property name="shortcut">
+ <string>Alt+Right</string>
+ </property>
+ </action>
+ <action name="actionRefresh">
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/refresh.svg</normaloff>:/icons/refresh.svg</iconset>
+ </property>
+ <property name="text">
+ <string>Refresh</string>
+ </property>
+ <property name="shortcut">
+ <string>F5</string>
+ </property>
+ </action>
+ <action name="actionAbout_Qt">
+ <property name="text">
+ <string>About Qt...</string>
+ </property>
+ </action>
+ </widget>
+ <resources>
+ <include location="icons.qrc"/>
+ </resources>
+ <connections/>
+</ui>
diff --git a/src/settingsdialog.cpp b/src/settingsdialog.cpp
new file mode 100644
index 0000000..2078e7c
--- /dev/null
+++ b/src/settingsdialog.cpp
@@ -0,0 +1,255 @@
+#include "settingsdialog.hpp"
+#include "ui_settingsdialog.h"
+#include <QFontDialog>
+#include <QColorDialog>
+#include <QStyle>
+
+SettingsDialog::SettingsDialog(QWidget *parent) :
+ QDialog(parent),
+ ui(new Ui::SettingsDialog),
+ current_style()
+{
+ ui->setupUi(this);
+
+ static_assert(GeminiStyle::Fixed == 0);
+ static_assert(GeminiStyle::AutoDarkTheme == 1);
+ static_assert(GeminiStyle::AutoLightTheme == 2);
+
+ this->ui->auto_theme->clear();
+ this->ui->auto_theme->addItem("Disabled", QVariant::fromValue<int>(GeminiStyle::Fixed));
+ this->ui->auto_theme->addItem("Dark Theme", QVariant::fromValue<int>(GeminiStyle::AutoDarkTheme));
+ this->ui->auto_theme->addItem("Light Theme", QVariant::fromValue<int>(GeminiStyle::AutoLightTheme));
+
+ setGeminiStyle(GeminiStyle { });
+}
+
+SettingsDialog::~SettingsDialog()
+{
+ delete ui;
+}
+
+static QString formatFont(QFont const & font)
+{
+ QString style;
+ if(font.italic() and font.bold())
+ style = "bold, italic";
+ else if(font.italic())
+ style = "italic";
+ else if(font.bold())
+ style = "bold";
+ else
+ style = "regular";
+
+ return QString("%1 (%2pt, %3)")
+ .arg(font.family())
+ .arg(font.pointSizeF())
+ .arg(style);
+}
+
+void SettingsDialog::setGeminiStyle(const GeminiStyle &style)
+{
+ static const QString COLOR_STYLE("border: 1px solid black; padding: 4px; background-color : %1; color : %2;");
+
+ this->current_style = style;
+
+ this->ui->auto_theme->setCurrentIndex(this->current_style.theme);
+
+ this->ui->page_margin->setValue(this->current_style.margin);
+
+ auto setFontAndColor = [this](QLabel * label, QFont font, QColor color)
+ {
+ label->setText(formatFont(font));
+ label->setStyleSheet(COLOR_STYLE
+ .arg(this->current_style.background_color.name())
+ .arg(color.name()));
+ };
+
+ ui->bg_preview->setStyleSheet(COLOR_STYLE
+ .arg(this->current_style.background_color.name())
+ .arg("#FF00FF"));
+
+ ui->link_local_preview->setStyleSheet(COLOR_STYLE
+ .arg(this->current_style.background_color.name())
+ .arg(this->current_style.internal_link_color.name()));
+
+ ui->link_foreign_preview->setStyleSheet(COLOR_STYLE
+ .arg(this->current_style.background_color.name())
+ .arg(this->current_style.external_link_color.name()));
+
+ ui->link_cross_preview->setStyleSheet(COLOR_STYLE
+ .arg(this->current_style.background_color.name())
+ .arg(this->current_style.cross_scheme_link_color.name()));
+
+ setFontAndColor(this->ui->std_preview, this->current_style.standard_font, this->current_style.standard_color);
+ setFontAndColor(this->ui->pre_preview, this->current_style.preformatted_font, this->current_style.preformatted_color);
+ setFontAndColor(this->ui->h1_preview, this->current_style.h1_font, this->current_style.h1_color);
+ setFontAndColor(this->ui->h2_preview, this->current_style.h2_font, this->current_style.h2_color);
+ setFontAndColor(this->ui->h3_preview, this->current_style.h3_font, this->current_style.h3_color);
+
+ this->reloadStylePreview();
+}
+
+void SettingsDialog::reloadStylePreview()
+{
+ auto const document = R"gemini(# H1 Header
+## H2 Header
+### H3 Header
+Plain text document here.
+* List A
+* List B
+=> rela-link Same-Site Link
+=> //foreign.host/ Foreign Site Link
+=> https://foreign.host/ Cross-Protocol Link
+```
+ ▄▄▄ ██▀███ ▄▄▄█████▓
+ ▒████▄ ▓██ ▒ ██▒▓ ██▒ ▓▒
+ ▒██ ▀█▄ ▓██ ░▄█ ▒▒ ▓██░ ▒░
+ ░██▄▄▄▄██ ▒██▀▀█▄ ░ ▓██▓ ░
+ ▓█ ▓██▒░██▓ ▒██▒ ▒██▒ ░
+ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ▒ ░░
+ ▒ ▒▒ ░ ░▒ ░ ▒░ ░
+ ░ ▒ ░░ ░ ░
+ ░ ░ ░
+)gemini";
+
+ QString host = this->ui->preview_url->text();
+ if(host.length() == 0)
+ host = "preview";
+
+ DocumentOutlineModel outline;
+ auto doc = GeminiRenderer { current_style }.render(
+ document,
+ QUrl(QString("about://%1/foobar").arg(host)),
+ outline
+ );
+
+ ui->style_preview->setStyleSheet(QString("QTextBrowser { background-color: %1; }")
+ .arg(doc->background_color.name()));
+ ui->style_preview->setDocument(doc.get());
+ preview_document = std::move(doc);
+}
+
+void SettingsDialog::updateFont(QFont & input)
+{
+ QFontDialog dialog { this };
+
+ dialog.setCurrentFont(input);
+
+ if(dialog.exec() == QDialog::Accepted) {
+ input = dialog.currentFont();
+ setGeminiStyle(current_style);
+ }
+}
+
+void SettingsDialog::on_std_change_font_clicked()
+{
+ updateFont(current_style.standard_font);
+}
+
+void SettingsDialog::on_pre_change_font_clicked()
+{
+ updateFont(current_style.preformatted_font);
+}
+
+void SettingsDialog::on_h1_change_font_clicked()
+{
+ updateFont(current_style.h1_font);
+}
+
+void SettingsDialog::on_h2_change_font_clicked()
+{
+ updateFont(current_style.h2_font);
+}
+
+void SettingsDialog::on_h3_change_font_clicked()
+{
+ updateFont(current_style.h3_font);
+}
+
+void SettingsDialog::updateColor(QColor &input)
+{
+ QColorDialog dialog { this };
+
+ dialog.setCurrentColor(input);
+
+ if(dialog.exec() == QDialog::Accepted) {
+ input = dialog.currentColor();
+ setGeminiStyle(current_style);
+ }
+}
+
+void SettingsDialog::on_std_change_color_clicked()
+{
+ updateColor(current_style.standard_color);
+}
+
+void SettingsDialog::on_pre_change_color_clicked()
+{
+ updateColor(current_style.preformatted_color);
+}
+
+void SettingsDialog::on_h1_change_color_clicked()
+{
+ updateColor(current_style.h1_color);
+}
+
+void SettingsDialog::on_h2_change_color_clicked()
+{
+ updateColor(current_style.h2_color);
+}
+
+void SettingsDialog::on_h3_change_color_clicked()
+{
+ updateColor(current_style.h3_color);
+}
+
+void SettingsDialog::on_bg_change_color_clicked()
+{
+ updateColor(current_style.background_color);
+}
+
+void SettingsDialog::on_link_local_change_color_clicked()
+{
+ updateColor(current_style.internal_link_color);
+}
+
+void SettingsDialog::on_link_foreign_change_color_clicked()
+{
+ updateColor(current_style.external_link_color);
+}
+
+void SettingsDialog::on_link_cross_change_color_clicked()
+{
+ updateColor(current_style.cross_scheme_link_color);
+}
+
+void SettingsDialog::on_link_local_prefix_textChanged(const QString &text)
+{
+ current_style.internal_link_prefix = text;
+ reloadStylePreview();
+}
+
+void SettingsDialog::on_link_foreign_prefix_textChanged(const QString &text)
+{
+ current_style.external_link_prefix = text;
+ reloadStylePreview();
+}
+
+void SettingsDialog::on_auto_theme_currentIndexChanged(int index)
+{
+ if(index >= 0) {
+ current_style.theme = GeminiStyle::Theme(index);
+ reloadStylePreview();
+ }
+}
+
+void SettingsDialog::on_preview_url_textChanged(const QString &arg1)
+{
+ this->reloadStylePreview();
+}
+
+void SettingsDialog::on_page_margin_valueChanged(double value)
+{
+ this->current_style.margin = value;
+ this->reloadStylePreview();
+}
diff --git a/src/settingsdialog.hpp b/src/settingsdialog.hpp
new file mode 100644
index 0000000..5f56961
--- /dev/null
+++ b/src/settingsdialog.hpp
@@ -0,0 +1,79 @@
+#ifndef SETTINGSDIALOG_HPP
+#define SETTINGSDIALOG_HPP
+
+#include <QDialog>
+
+#include "geminirenderer.hpp"
+
+namespace Ui {
+class SettingsDialog;
+}
+
+class SettingsDialog : public QDialog
+{
+ Q_OBJECT
+
+public:
+ explicit SettingsDialog(QWidget *parent = nullptr);
+ ~SettingsDialog();
+
+ void setGeminiStyle(GeminiStyle const & style);
+
+ GeminiStyle geminiStyle() const {
+ return current_style;
+ }
+
+private slots:
+ void on_std_change_font_clicked();
+
+ void on_pre_change_font_clicked();
+
+ void on_h1_change_font_clicked();
+
+ void on_h2_change_font_clicked();
+
+ void on_h3_change_font_clicked();
+
+ void on_std_change_color_clicked();
+
+ void on_pre_change_color_clicked();
+
+ void on_h1_change_color_clicked();
+
+ void on_h2_change_color_clicked();
+
+ void on_h3_change_color_clicked();
+
+ void on_bg_change_color_clicked();
+
+ void on_link_local_change_color_clicked();
+
+ void on_link_foreign_change_color_clicked();
+
+ void on_link_cross_change_color_clicked();
+
+ void on_link_local_prefix_textChanged(const QString &arg1);
+
+ void on_link_foreign_prefix_textChanged(const QString &arg1);
+
+ void on_auto_theme_currentIndexChanged(int index);
+
+ void on_preview_url_textChanged(const QString &arg1);
+
+ void on_page_margin_valueChanged(double arg1);
+
+private:
+ void reloadStylePreview();
+
+ void updateFont(QFont & input);
+
+ void updateColor(QColor & input);
+
+private:
+ Ui::SettingsDialog *ui;
+
+ GeminiStyle current_style;
+ std::unique_ptr<QTextDocument> preview_document;
+};
+
+#endif // SETTINGSDIALOG_HPP
diff --git a/src/settingsdialog.ui b/src/settingsdialog.ui
new file mode 100644
index 0000000..aee3959
--- /dev/null
+++ b/src/settingsdialog.ui
@@ -0,0 +1,517 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>SettingsDialog</class>
+ <widget class="QDialog" name="SettingsDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>800</width>
+ <height>520</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Settings</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="style_tab">
+ <attribute name="title">
+ <string>Style</string>
+ </attribute>
+ <layout class="QHBoxLayout" name="horizontalLayout_23">
+ <item>
+ <layout class="QFormLayout" name="formLayout_3">
+ <property name="leftMargin">
+ <number>5</number>
+ </property>
+ <item row="0" column="1">
+ <layout class="QHBoxLayout" name="horizontalLayout_13">
+ <item>
+ <widget class="QLabel" name="bg_preview">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::PlainText</enum>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="bg_change_color">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/palette.svg</normaloff>:/icons/palette.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="label_2">
+ <property name="text">
+ <string>Background Color</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>Standard Font</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <layout class="QHBoxLayout" name="horizontalLayout_14">
+ <item>
+ <widget class="QLabel" name="std_preview">
+ <property name="frameShape">
+ <enum>QFrame::NoFrame</enum>
+ </property>
+ <property name="text">
+ <string>This text will be displayed for normal text.</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="std_change_font">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/format-font.svg</normaloff>:/icons/format-font.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="std_change_color">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/palette.svg</normaloff>:/icons/palette.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="label_3">
+ <property name="text">
+ <string>Preformatted Font</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <layout class="QHBoxLayout" name="horizontalLayout_15">
+ <item>
+ <widget class="QLabel" name="pre_preview">
+ <property name="frameShape">
+ <enum>QFrame::NoFrame</enum>
+ </property>
+ <property name="text">
+ <string>This text will be displayed for preformatted text.</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="pre_change_font">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/format-font.svg</normaloff>:/icons/format-font.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="pre_change_color">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/palette.svg</normaloff>:/icons/palette.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="3" column="0">
+ <widget class="QLabel" name="label_4">
+ <property name="text">
+ <string>H1 Font</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="1">
+ <layout class="QHBoxLayout" name="horizontalLayout_16">
+ <item>
+ <widget class="QLabel" name="h1_preview">
+ <property name="frameShape">
+ <enum>QFrame::NoFrame</enum>
+ </property>
+ <property name="text">
+ <string>This text will be displayed for a level 1 heading.</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="h1_change_font">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/format-font.svg</normaloff>:/icons/format-font.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="h1_change_color">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/palette.svg</normaloff>:/icons/palette.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="4" column="0">
+ <widget class="QLabel" name="label_5">
+ <property name="text">
+ <string>H2 Font</string>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="1">
+ <layout class="QHBoxLayout" name="horizontalLayout_18">
+ <item>
+ <widget class="QLabel" name="h2_preview">
+ <property name="frameShape">
+ <enum>QFrame::NoFrame</enum>
+ </property>
+ <property name="text">
+ <string>This text will be displayed for a level 2 heading.</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="h2_change_font">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/format-font.svg</normaloff>:/icons/format-font.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="h2_change_color">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/palette.svg</normaloff>:/icons/palette.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="5" column="0">
+ <widget class="QLabel" name="label_6">
+ <property name="text">
+ <string>H3 Font</string>
+ </property>
+ </widget>
+ </item>
+ <item row="5" column="1">
+ <layout class="QHBoxLayout" name="horizontalLayout_19">
+ <item>
+ <widget class="QLabel" name="h3_preview">
+ <property name="styleSheet">
+ <string notr="true">border: 1px solid black;</string>
+ </property>
+ <property name="frameShape">
+ <enum>QFrame::NoFrame</enum>
+ </property>
+ <property name="text">
+ <string>This text will be displayed for a level 3 heading.</string>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::PlainText</enum>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="h3_change_font">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/format-font.svg</normaloff>:/icons/format-font.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="h3_change_color">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/palette.svg</normaloff>:/icons/palette.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="6" column="0">
+ <widget class="QLabel" name="label_7">
+ <property name="text">
+ <string>Local Link Color</string>
+ </property>
+ </widget>
+ </item>
+ <item row="7" column="0">
+ <widget class="QLabel" name="label_8">
+ <property name="text">
+ <string>Foreign Link Color</string>
+ </property>
+ </widget>
+ </item>
+ <item row="8" column="0">
+ <widget class="QLabel" name="label_9">
+ <property name="text">
+ <string>Cross-Scheme-Color</string>
+ </property>
+ </widget>
+ </item>
+ <item row="9" column="0">
+ <widget class="QLabel" name="label_10">
+ <property name="text">
+ <string>Local Link Prefix</string>
+ </property>
+ </widget>
+ </item>
+ <item row="10" column="0">
+ <widget class="QLabel" name="label_11">
+ <property name="text">
+ <string>Extern Link Prefix</string>
+ </property>
+ </widget>
+ </item>
+ <item row="10" column="1">
+ <widget class="QLineEdit" name="link_foreign_prefix">
+ <property name="text">
+ <string>⇒ </string>
+ </property>
+ </widget>
+ </item>
+ <item row="9" column="1">
+ <widget class="QLineEdit" name="link_local_prefix">
+ <property name="text">
+ <string>→ </string>
+ </property>
+ </widget>
+ </item>
+ <item row="6" column="1">
+ <layout class="QHBoxLayout" name="horizontalLayout_20">
+ <item>
+ <widget class="QLabel" name="link_local_preview">
+ <property name="text">
+ <string>This is a local reference</string>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::PlainText</enum>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="link_local_change_color">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/palette.svg</normaloff>:/icons/palette.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="7" column="1">
+ <layout class="QHBoxLayout" name="horizontalLayout_21">
+ <item>
+ <widget class="QLabel" name="link_foreign_preview">
+ <property name="text">
+ <string>This is a foreign reference</string>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::PlainText</enum>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="link_foreign_change_color">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/palette.svg</normaloff>:/icons/palette.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="8" column="1">
+ <layout class="QHBoxLayout" name="horizontalLayout_22">
+ <item>
+ <widget class="QLabel" name="link_cross_preview">
+ <property name="text">
+ <string>This reference is cross-scheme</string>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::PlainText</enum>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="link_cross_change_color">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="icons.qrc">
+ <normaloff>:/icons/palette.svg</normaloff>:/icons/palette.svg</iconset>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="11" column="0">
+ <widget class="QLabel" name="label_12">
+ <property name="text">
+ <string>Auto-Theme Generation</string>
+ </property>
+ </widget>
+ </item>
+ <item row="11" column="1">
+ <widget class="QComboBox" name="auto_theme"/>
+ </item>
+ <item row="12" column="0">
+ <widget class="QLabel" name="label_13">
+ <property name="text">
+ <string>Page Margin</string>
+ </property>
+ </widget>
+ </item>
+ <item row="12" column="1">
+ <widget class="QDoubleSpinBox" name="page_margin">
+ <property name="suffix">
+ <string> px</string>
+ </property>
+ <property name="decimals">
+ <number>0</number>
+ </property>
+ <property name="maximum">
+ <double>350.000000000000000</double>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <item>
+ <widget class="QTextBrowser" name="style_preview">
+ <property name="openLinks">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="preview_url">
+ <property name="placeholderText">
+ <string>host.name</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="generic">
+ <attribute name="title">
+ <string>Generic</string>
+ </attribute>
+ </widget>
+ </widget>
+ </item>
+ <item>
+ <widget class="QDialogButtonBox" name="buttonBox">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="standardButtons">
+ <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources>
+ <include location="icons.qrc"/>
+ </resources>
+ <connections>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>rejected()</signal>
+ <receiver>SettingsDialog</receiver>
+ <slot>reject()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>325</x>
+ <y>470</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>286</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>accepted()</signal>
+ <receiver>SettingsDialog</receiver>
+ <slot>accept()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>257</x>
+ <y>470</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>157</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ </connections>
+</ui>
diff --git a/src/tabbrowsinghistory.cpp b/src/tabbrowsinghistory.cpp
new file mode 100644
index 0000000..435bca7
--- /dev/null
+++ b/src/tabbrowsinghistory.cpp
@@ -0,0 +1,79 @@
+#include "tabbrowsinghistory.hpp"
+
+TabBrowsingHistory::TabBrowsingHistory()
+{
+
+}
+
+bool TabBrowsingHistory::canGoBack() const
+{
+ return this->history.size() > 0;
+}
+
+bool TabBrowsingHistory::canGoForward() const
+{
+ return false;
+}
+
+QModelIndex TabBrowsingHistory::pushUrl(QModelIndex const & position, const QUrl &url)
+{
+ this->beginInsertRows(QModelIndex{}, this->history.length(),this->history.length() + 1);
+
+ if(position.isValid()) {
+ this->history.resize(position.row() + 1);
+ }
+
+ this->history.push_back(url);
+
+ this->endInsertRows();
+
+ return this->createIndex(this->history.size() - 1, 0);
+}
+
+QUrl TabBrowsingHistory::get(const QModelIndex &index) const
+{
+ if(not index.isValid())
+ return QUrl { };
+
+ if(index.row() >= history.size())
+ return QUrl { };
+ else
+ return history.at(index.row());
+}
+
+QModelIndex TabBrowsingHistory::oneForward(QModelIndex index) const
+{
+ if(not index.isValid())
+ return QModelIndex{};
+ if(index.row() >= history.size() - 1)
+ return QModelIndex{};
+ return createIndex(index.row() + 1, index.column());
+}
+
+QModelIndex TabBrowsingHistory::oneBackward(QModelIndex index) const
+{
+ if(not index.isValid())
+ return QModelIndex{};
+ if(index.row() == 0)
+ return QModelIndex{};
+ return createIndex(index.row() - 1, index.column());
+}
+
+int TabBrowsingHistory::rowCount(const QModelIndex &parent) const
+{
+ return history.size();
+}
+
+bool TabBrowsingHistory::setData(const QModelIndex &index, const QVariant &value, int role)
+{
+ return false;
+}
+
+QVariant TabBrowsingHistory::data(const QModelIndex &index, int role) const
+{
+ if(role != Qt::DisplayRole) {
+ return QVariant{};
+ }
+ return history.at(index.row()).toString();
+}
+
diff --git a/src/tabbrowsinghistory.hpp b/src/tabbrowsinghistory.hpp
new file mode 100644
index 0000000..4305218
--- /dev/null
+++ b/src/tabbrowsinghistory.hpp
@@ -0,0 +1,39 @@
+#ifndef TABBROWSINGHISTORY_HPP
+#define TABBROWSINGHISTORY_HPP
+
+#include <QAbstractListModel>
+#include <QVector>
+#include <QUrl>
+
+class TabBrowsingHistory :
+ public QAbstractListModel
+{
+ Q_OBJECT
+public:
+ TabBrowsingHistory();
+
+ bool canGoBack() const;
+
+ bool canGoForward() const;
+
+ QModelIndex pushUrl(QModelIndex const & position, QUrl const & url);
+
+ QUrl get(QModelIndex const & index) const;
+
+ QModelIndex oneForward(QModelIndex index) const;
+
+ QModelIndex oneBackward(QModelIndex index) const;
+
+public:
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+
+ bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override;
+
+ QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+
+
+private:
+ QVector<QUrl> history;
+};
+
+#endif // TABBROWSINGHISTORY_HPP