Initial support for backwards pagination

This commit is contained in:
Konstantinos Sideris 2017-05-12 15:43:35 +03:00
parent ff611c1b39
commit 0368d854cf
10 changed files with 388 additions and 94 deletions

View file

@ -91,6 +91,7 @@ set(SRC_FILES
src/MatrixClient.cc src/MatrixClient.cc
src/Profile.cc src/Profile.cc
src/RoomInfoListItem.cc src/RoomInfoListItem.cc
src/RoomMessages.cc
src/RoomList.cc src/RoomList.cc
src/RoomState.cc src/RoomState.cc
src/Register.cc src/Register.cc

View file

@ -21,6 +21,7 @@
#include <QtNetwork/QNetworkAccessManager> #include <QtNetwork/QNetworkAccessManager>
#include "Profile.h" #include "Profile.h"
#include "RoomMessages.h"
#include "Sync.h" #include "Sync.h"
/* /*
@ -43,6 +44,7 @@ public:
void fetchRoomAvatar(const QString &roomid, const QUrl &avatar_url); void fetchRoomAvatar(const QString &roomid, const QUrl &avatar_url);
void fetchOwnAvatar(const QUrl &avatar_url); void fetchOwnAvatar(const QUrl &avatar_url);
void downloadImage(const QString &event_id, const QUrl &url); void downloadImage(const QString &event_id, const QUrl &url);
void messages(const QString &room_id, const QString &from_token) noexcept;
inline QUrl getHomeServer(); inline QUrl getHomeServer();
inline int transactionId(); inline int transactionId();
@ -77,19 +79,21 @@ signals:
void syncCompleted(const SyncResponse &response); void syncCompleted(const SyncResponse &response);
void syncFailed(const QString &msg); void syncFailed(const QString &msg);
void messageSent(const QString &event_id, const QString &roomid, const int txn_id); void messageSent(const QString &event_id, const QString &roomid, const int txn_id);
void messagesRetrieved(const QString &room_id, const RoomMessages &msgs);
private slots: private slots:
void onResponse(QNetworkReply *reply); void onResponse(QNetworkReply *reply);
private: private:
enum class Endpoint { enum class Endpoint {
GetOwnProfile,
GetOwnAvatar, GetOwnAvatar,
GetOwnProfile,
GetProfile, GetProfile,
Image, Image,
InitialSync, InitialSync,
Login, Login,
Logout, Logout,
Messages,
Register, Register,
RoomAvatar, RoomAvatar,
SendTextMessage, SendTextMessage,
@ -109,6 +113,7 @@ private:
void onSyncResponse(QNetworkReply *reply); void onSyncResponse(QNetworkReply *reply);
void onRoomAvatarResponse(QNetworkReply *reply); void onRoomAvatarResponse(QNetworkReply *reply);
void onImageResponse(QNetworkReply *reply); void onImageResponse(QNetworkReply *reply);
void onMessagesResponse(QNetworkReply *reply);
// Client API prefix. // Client API prefix.
QString api_url_; QString api_url_;

56
include/RoomMessages.h Normal file
View file

@ -0,0 +1,56 @@
/*
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef ROOM_MESSAGES_H
#define ROOM_MESSAGES_H
#include <QJsonArray>
#include <QJsonDocument>
#include "Deserializable.h"
class RoomMessages : public Deserializable
{
public:
void deserialize(const QJsonDocument &data) override;
inline QString start() const;
inline QString end() const;
inline QJsonArray chunk() const;
private:
QString start_;
QString end_;
QJsonArray chunk_;
};
inline QString RoomMessages::start() const
{
return start_;
}
inline QString RoomMessages::end() const
{
return end_;
}
inline QJsonArray RoomMessages::chunk() const
{
return chunk_;
}
#endif // ROOM_MESSAGES_H

View file

@ -51,32 +51,50 @@ struct PendingMessage {
} }
}; };
// In which place new TimelineItems should be inserted.
enum class TimelineDirection {
Top,
Bottom,
};
class TimelineView : public QWidget class TimelineView : public QWidget
{ {
Q_OBJECT Q_OBJECT
public: public:
TimelineView(QSharedPointer<MatrixClient> client, QWidget *parent = 0); TimelineView(const Timeline &timeline, QSharedPointer<MatrixClient> client, const QString &room_id, QWidget *parent = 0);
TimelineView(const QJsonArray &events, QSharedPointer<MatrixClient> client, QWidget *parent = 0);
~TimelineView();
void addHistoryItem(const events::MessageEvent<msgs::Image> &e, const QString &color, bool with_sender); TimelineItem *createTimelineItem(const events::MessageEvent<msgs::Image> &e, const QString &color, bool with_sender);
void addHistoryItem(const events::MessageEvent<msgs::Notice> &e, const QString &color, bool with_sender); TimelineItem *createTimelineItem(const events::MessageEvent<msgs::Notice> &e, const QString &color, bool with_sender);
void addHistoryItem(const events::MessageEvent<msgs::Text> &e, const QString &color, bool with_sender); TimelineItem *createTimelineItem(const events::MessageEvent<msgs::Text> &e, const QString &color, bool with_sender);
int addEvents(const QJsonArray &events); // Add new events at the end of the timeline.
int addEvents(const Timeline &timeline);
void addUserTextMessage(const QString &msg, int txn_id); void addUserTextMessage(const QString &msg, int txn_id);
void updatePendingMessage(int txn_id, QString event_id); void updatePendingMessage(int txn_id, QString event_id);
void scrollDown();
void clear(); void clear();
public slots: public slots:
void sliderRangeChanged(int min, int max); void sliderRangeChanged(int min, int max);
void sliderMoved(int position);
// Add old events at the top of the timeline.
void addBackwardsEvents(const QString &room_id, const RoomMessages &msgs);
private: private:
void init(); void init();
void removePendingMessage(const events::MessageEvent<msgs::Text> &e); void removePendingMessage(const events::MessageEvent<msgs::Text> &e);
void addTimelineItem(TimelineItem *item, TimelineDirection direction);
void updateLastSender(const QString &user_id, TimelineDirection direction);
// Used to determine whether or not we should prefix a message with the sender's name.
bool isSenderRendered(const QString &user_id, TimelineDirection direction);
bool isPendingMessage(const events::MessageEvent<msgs::Text> &e, const QString &userid); bool isPendingMessage(const events::MessageEvent<msgs::Text> &e, const QString &userid);
// Return nullptr if the event couldn't be parsed.
TimelineItem *parseMessageEvent(const QJsonObject &event, TimelineDirection direction);
QVBoxLayout *top_layout_; QVBoxLayout *top_layout_;
QVBoxLayout *scroll_layout_; QVBoxLayout *scroll_layout_;
@ -84,6 +102,19 @@ private:
QWidget *scroll_widget_; QWidget *scroll_widget_;
QString last_sender_; QString last_sender_;
QString last_sender_backwards_;
QString room_id_;
QString prev_batch_token_;
QString local_user_;
bool isPaginationInProgress_ = false;
bool isInitialized = false;
bool isTimelineFinished = false;
const int SCROLL_BAR_GAP = 300;
int scroll_height_ = 0;
int previous_max_height_ = 0;
QList<PendingMessage> pending_msgs_; QList<PendingMessage> pending_msgs_;
QSharedPointer<MatrixClient> client_; QSharedPointer<MatrixClient> client_;

View file

@ -108,6 +108,7 @@ void MainWindow::showChatPage(QString userid, QString homeserver, QString token)
if (progress_modal_ == nullptr) { if (progress_modal_ == nullptr) {
progress_modal_ = new OverlayModal(this, spinner_); progress_modal_ = new OverlayModal(this, spinner_);
progress_modal_->fadeIn(); progress_modal_->fadeIn();
progress_modal_->setDuration(300);
} }
login_page_->reset(); login_page_->reset();

View file

@ -333,6 +333,32 @@ void MatrixClient::onImageResponse(QNetworkReply *reply)
emit imageDownloaded(event_id, pixmap); emit imageDownloaded(event_id, pixmap);
} }
void MatrixClient::onMessagesResponse(QNetworkReply *reply)
{
reply->deleteLater();
int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status == 0 || status >= 400) {
qWarning() << reply->errorString();
return;
}
auto data = reply->readAll();
auto room_id = reply->property("room_id").toString();
RoomMessages msgs;
try {
msgs.deserialize(QJsonDocument::fromJson(data));
} catch (const DeserializationException &e) {
qWarning() << "Room messages from" << room_id << e.what();
return;
}
emit messagesRetrieved(room_id, msgs);
}
void MatrixClient::onResponse(QNetworkReply *reply) void MatrixClient::onResponse(QNetworkReply *reply)
{ {
switch (static_cast<Endpoint>(reply->property("endpoint").toInt())) { switch (static_cast<Endpoint>(reply->property("endpoint").toInt())) {
@ -369,6 +395,9 @@ void MatrixClient::onResponse(QNetworkReply *reply)
case Endpoint::GetOwnAvatar: case Endpoint::GetOwnAvatar:
onGetOwnAvatarResponse(reply); onGetOwnAvatarResponse(reply);
break; break;
case Endpoint::Messages:
onMessagesResponse(reply);
break;
default: default:
break; break;
} }
@ -581,3 +610,21 @@ void MatrixClient::fetchOwnAvatar(const QUrl &avatar_url)
QNetworkReply *reply = get(avatar_request); QNetworkReply *reply = get(avatar_request);
reply->setProperty("endpoint", static_cast<int>(Endpoint::GetOwnAvatar)); reply->setProperty("endpoint", static_cast<int>(Endpoint::GetOwnAvatar));
} }
void MatrixClient::messages(const QString &room_id, const QString &from_token) noexcept
{
QUrlQuery query;
query.addQueryItem("access_token", token_);
query.addQueryItem("from", from_token);
query.addQueryItem("dir", "b");
QUrl endpoint(server_);
endpoint.setPath(api_url_ + QString("/rooms/%1/messages").arg(room_id));
endpoint.setQuery(query);
QNetworkRequest request(QString(endpoint.toEncoded()));
QNetworkReply *reply = get(request);
reply->setProperty("endpoint", static_cast<int>(Endpoint::Messages));
reply->setProperty("room_id", room_id);
}

42
src/RoomMessages.cc Normal file
View file

@ -0,0 +1,42 @@
/*
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "RoomMessages.h"
void RoomMessages::deserialize(const QJsonDocument &data)
{
if (!data.isObject())
throw DeserializationException("response is not a JSON object");
QJsonObject object = data.object();
if (!object.contains("start"))
throw DeserializationException("start key is missing");
if (!object.contains("end"))
throw DeserializationException("end key is missing");
if (!object.contains("chunk"))
throw DeserializationException("chunk key is missing");
if (!object.value("chunk").isArray())
throw DeserializationException("chunk isn't a JSON array");
start_ = object.value("start").toString();
end_ = object.value("end").toString();
chunk_ = object.value("chunk").toArray();
}

View file

@ -34,19 +34,19 @@
namespace events = matrix::events; namespace events = matrix::events;
namespace msgs = matrix::events::messages; namespace msgs = matrix::events::messages;
TimelineView::TimelineView(const QJsonArray &events, QSharedPointer<MatrixClient> client, QWidget *parent) TimelineView::TimelineView(const Timeline &timeline,
QSharedPointer<MatrixClient> client,
const QString &room_id,
QWidget *parent)
: QWidget(parent) : QWidget(parent)
, room_id_{room_id}
, client_{client} , client_{client}
{ {
init(); QSettings settings;
addEvents(events); local_user_ = settings.value("auth/user_id").toString();
}
TimelineView::TimelineView(QSharedPointer<MatrixClient> client, QWidget *parent)
: QWidget(parent)
, client_{client}
{
init(); init();
addEvents(timeline);
} }
void TimelineView::clear() void TimelineView::clear()
@ -58,83 +58,175 @@ void TimelineView::clear()
void TimelineView::sliderRangeChanged(int min, int max) void TimelineView::sliderRangeChanged(int min, int max)
{ {
Q_UNUSED(min); Q_UNUSED(min);
scroll_area_->verticalScrollBar()->setValue(max);
if (!scroll_area_->verticalScrollBar()->isVisible())
return;
if (max - scroll_area_->verticalScrollBar()->value() < SCROLL_BAR_GAP)
scroll_area_->verticalScrollBar()->setValue(max);
} }
int TimelineView::addEvents(const QJsonArray &events) void TimelineView::scrollDown()
{ {
QSettings settings; int current = scroll_area_->verticalScrollBar()->value();
auto local_user = settings.value("auth/user_id").toString(); int max = scroll_area_->verticalScrollBar()->maximum();
int message_count = 0; // The first time we enter the room move the scroll bar to the bottom.
events::EventType ty; if (!isInitialized) {
scroll_area_->ensureVisible(0, scroll_widget_->size().height(), 0, 0);
isInitialized = true;
return;
}
for (const auto &event : events) { // If the gap is small enough move the scroll bar down. e.g when a new message appears.
ty = events::extractEventType(event.toObject()); if (max - current < SCROLL_BAR_GAP)
scroll_area_->verticalScrollBar()->setValue(max);
}
if (ty == events::EventType::RoomMessage) { void TimelineView::sliderMoved(int position)
events::MessageEventType msg_type = events::extractMessageEventType(event.toObject()); {
if (!scroll_area_->verticalScrollBar()->isVisible())
return;
if (msg_type == events::MessageEventType::Text) { // The scrollbar is high enough so we can start retrieving old events.
events::MessageEvent<msgs::Text> text; if (position < SCROLL_BAR_GAP) {
if (isTimelineFinished)
return;
try { // Prevent user from moving up when there is pagination in progress.
text.deserialize(event.toObject()); if (isPaginationInProgress_) {
} catch (const DeserializationException &e) { scroll_area_->verticalScrollBar()->setValue(SCROLL_BAR_GAP);
qWarning() << e.what() << event; return;
continue; }
}
if (isPendingMessage(text, local_user)) { isPaginationInProgress_ = true;
removePendingMessage(text); scroll_height_ = scroll_area_->verticalScrollBar()->value();
continue; previous_max_height_ = scroll_area_->verticalScrollBar()->maximum();
}
auto with_sender = last_sender_ != text.sender(); // FIXME: Maybe move this to TimelineViewManager to remove the extra calls?
auto color = TimelineViewManager::getUserColor(text.sender()); client_.data()->messages(room_id_, prev_batch_token_);
}
}
addHistoryItem(text, color, with_sender); void TimelineView::addBackwardsEvents(const QString &room_id, const RoomMessages &msgs)
last_sender_ = text.sender(); {
if (room_id_ != room_id)
return;
message_count += 1; if (msgs.chunk().count() == 0) {
} else if (msg_type == events::MessageEventType::Notice) { isTimelineFinished = true;
events::MessageEvent<msgs::Notice> notice; return;
}
try { isTimelineFinished = false;
notice.deserialize(event.toObject()); last_sender_backwards_.clear();
} catch (const DeserializationException &e) { QList<TimelineItem *> items;
qWarning() << e.what() << event;
continue;
}
auto with_sender = last_sender_ != notice.sender(); // Parse in reverse order to determine where we should not show sender's name.
auto color = TimelineViewManager::getUserColor(notice.sender()); auto it = msgs.chunk().constEnd();
while (it != msgs.chunk().constBegin()) {
--it;
addHistoryItem(notice, color, with_sender); TimelineItem *item = parseMessageEvent((*it).toObject(), TimelineDirection::Top);
last_sender_ = notice.sender();
message_count += 1; if (item != nullptr)
} else if (msg_type == events::MessageEventType::Image) { items.push_back(item);
events::MessageEvent<msgs::Image> img; }
try { // Reverse again to render them.
img.deserialize(event.toObject()); std::reverse(items.begin(), items.end());
} catch (const DeserializationException &e) {
qWarning() << e.what() << event;
continue;
}
auto with_sender = last_sender_ != img.sender(); for (const auto &item : items)
auto color = TimelineViewManager::getUserColor(img.sender()); addTimelineItem(item, TimelineDirection::Top);
addHistoryItem(img, color, with_sender); prev_batch_token_ = msgs.end();
isPaginationInProgress_ = false;
}
last_sender_ = img.sender(); TimelineItem *TimelineView::parseMessageEvent(const QJsonObject &event, TimelineDirection direction)
message_count += 1; {
} else if (msg_type == events::MessageEventType::Unknown) { events::EventType ty = events::extractEventType(event);
qWarning() << "Unknown message type" << event.toObject();
continue; if (ty == events::EventType::RoomMessage) {
events::MessageEventType msg_type = events::extractMessageEventType(event);
if (msg_type == events::MessageEventType::Text) {
events::MessageEvent<msgs::Text> text;
try {
text.deserialize(event);
} catch (const DeserializationException &e) {
qWarning() << e.what() << event;
return nullptr;
} }
if (isPendingMessage(text, local_user_)) {
removePendingMessage(text);
return nullptr;
}
auto with_sender = isSenderRendered(text.sender(), direction);
updateLastSender(text.sender(), direction);
auto color = TimelineViewManager::getUserColor(text.sender());
last_sender_ = text.sender();
return createTimelineItem(text, color, with_sender);
} else if (msg_type == events::MessageEventType::Notice) {
events::MessageEvent<msgs::Notice> notice;
try {
notice.deserialize(event);
} catch (const DeserializationException &e) {
qWarning() << e.what() << event;
return nullptr;
}
auto with_sender = isSenderRendered(notice.sender(), direction);
updateLastSender(notice.sender(), direction);
auto color = TimelineViewManager::getUserColor(notice.sender());
last_sender_ = notice.sender();
return createTimelineItem(notice, color, with_sender);
} else if (msg_type == events::MessageEventType::Image) {
events::MessageEvent<msgs::Image> img;
try {
img.deserialize(event);
} catch (const DeserializationException &e) {
qWarning() << e.what() << event;
return nullptr;
}
auto with_sender = isSenderRendered(img.sender(), direction);
updateLastSender(img.sender(), direction);
auto color = TimelineViewManager::getUserColor(img.sender());
last_sender_ = img.sender();
return createTimelineItem(img, color, with_sender);
} else if (msg_type == events::MessageEventType::Unknown) {
qWarning() << "Unknown message type" << event;
return nullptr;
}
}
return nullptr;
}
int TimelineView::addEvents(const Timeline &timeline)
{
int message_count = 0;
prev_batch_token_ = timeline.previousBatch();
for (const auto &event : timeline.events()) {
TimelineItem *item = parseMessageEvent(event.toObject(), TimelineDirection::Bottom);
if (item != nullptr) {
message_count += 1;
addTimelineItem(item, TimelineDirection::Bottom);
} }
} }
@ -165,35 +257,59 @@ void TimelineView::init()
setLayout(top_layout_); setLayout(top_layout_);
connect(scroll_area_->verticalScrollBar(), connect(client_.data(), &MatrixClient::messagesRetrieved, this, &TimelineView::addBackwardsEvents);
SIGNAL(rangeChanged(int, int)),
this, connect(scroll_area_->verticalScrollBar(), SIGNAL(valueChanged(int)), this, SLOT(sliderMoved(int)));
SLOT(sliderRangeChanged(int, int))); connect(scroll_area_->verticalScrollBar(), SIGNAL(rangeChanged(int, int)), this, SLOT(sliderRangeChanged(int, int)));
} }
void TimelineView::addHistoryItem(const events::MessageEvent<msgs::Image> &event, const QString &color, bool with_sender) void TimelineView::updateLastSender(const QString &user_id, TimelineDirection direction)
{
if (direction == TimelineDirection::Bottom)
last_sender_ = user_id;
else
last_sender_backwards_ = user_id;
}
bool TimelineView::isSenderRendered(const QString &user_id, TimelineDirection direction)
{
if (direction == TimelineDirection::Bottom)
return last_sender_ != user_id;
else
return last_sender_backwards_ != user_id;
}
TimelineItem *TimelineView::createTimelineItem(const events::MessageEvent<msgs::Image> &event, const QString &color, bool with_sender)
{ {
auto image = new ImageItem(client_, event); auto image = new ImageItem(client_, event);
if (with_sender) { if (with_sender) {
auto item = new TimelineItem(image, event, color, scroll_widget_); auto item = new TimelineItem(image, event, color, scroll_widget_);
scroll_layout_->addWidget(item); return item;
} else {
auto item = new TimelineItem(image, event, scroll_widget_);
scroll_layout_->addWidget(item);
} }
auto item = new TimelineItem(image, event, scroll_widget_);
return item;
} }
void TimelineView::addHistoryItem(const events::MessageEvent<msgs::Notice> &event, const QString &color, bool with_sender) TimelineItem *TimelineView::createTimelineItem(const events::MessageEvent<msgs::Notice> &event, const QString &color, bool with_sender)
{ {
TimelineItem *item = new TimelineItem(event, with_sender, color, scroll_widget_); TimelineItem *item = new TimelineItem(event, with_sender, color, scroll_widget_);
scroll_layout_->addWidget(item); return item;
} }
void TimelineView::addHistoryItem(const events::MessageEvent<msgs::Text> &event, const QString &color, bool with_sender) TimelineItem *TimelineView::createTimelineItem(const events::MessageEvent<msgs::Text> &event, const QString &color, bool with_sender)
{ {
TimelineItem *item = new TimelineItem(event, with_sender, color, scroll_widget_); TimelineItem *item = new TimelineItem(event, with_sender, color, scroll_widget_);
scroll_layout_->addWidget(item); return item;
}
void TimelineView::addTimelineItem(TimelineItem *item, TimelineDirection direction)
{
if (direction == TimelineDirection::Bottom)
scroll_layout_->addWidget(item);
else
scroll_layout_->insertWidget(0, item);
} }
void TimelineView::updatePendingMessage(int txn_id, QString event_id) void TimelineView::updatePendingMessage(int txn_id, QString event_id)
@ -254,7 +370,3 @@ void TimelineView::addUserTextMessage(const QString &body, int txn_id)
pending_msgs_.push_back(message); pending_msgs_.push_back(message);
} }
TimelineView::~TimelineView()
{
}

View file

@ -78,10 +78,9 @@ void TimelineViewManager::initialize(const Rooms &rooms)
{ {
for (auto it = rooms.join().constBegin(); it != rooms.join().constEnd(); it++) { for (auto it = rooms.join().constBegin(); it != rooms.join().constEnd(); it++) {
auto roomid = it.key(); auto roomid = it.key();
auto events = it.value().timeline().events();
// Create a history view with the room events. // Create a history view with the room events.
TimelineView *view = new TimelineView(events, client_); TimelineView *view = new TimelineView(it.value().timeline(), client_, it.key());
views_.insert(it.key(), view); views_.insert(it.key(), view);
// Add the view in the widget stack. // Add the view in the widget stack.
@ -100,9 +99,8 @@ void TimelineViewManager::sync(const Rooms &rooms)
} }
auto view = views_.value(roomid); auto view = views_.value(roomid);
auto events = it.value().timeline().events();
int msgs_added = view->addEvents(events); int msgs_added = view->addEvents(it.value().timeline());
if (msgs_added > 0) { if (msgs_added > 0) {
// TODO: When the app window gets active the current // TODO: When the app window gets active the current
@ -124,6 +122,7 @@ void TimelineViewManager::setHistoryView(const QString &room_id)
active_room_ = room_id; active_room_ = room_id;
auto widget = views_.value(room_id); auto widget = views_.value(room_id);
widget->scrollDown();
setCurrentWidget(widget); setCurrentWidget(widget);
} }

View file

@ -43,7 +43,7 @@ int main(int argc, char *argv[])
app.setStyleSheet( app.setStyleSheet(
"QScrollBar:vertical { background-color: #f8fbfe; width: 8px; border: none; margin: 2px; }" "QScrollBar:vertical { background-color: #f8fbfe; width: 8px; border: none; margin: 2px; }"
"QScrollBar::handle:vertical { background-color : #d6dde3; }" "QScrollBar::handle:vertical { min-height: 40px; background-color : #d6dde3; }"
"QScrollBar::add-line:vertical { border: none; background: none; }" "QScrollBar::add-line:vertical { border: none; background: none; }"
"QScrollBar::sub-line:vertical { border: none; background: none; }"); "QScrollBar::sub-line:vertical { border: none; background: none; }");