Discord-like Quick Switcher (#1588)

* Proof of Concept for Quick Switcher

* Fix crash when suggestions are empty

* QuickSwitcher: Use tab name instead of a single channel

* Rebase later

* Add missing include for <functional>

* Move QuickSwitcher related classes into own subfolder

* Refactor switcher list items

Now, items are responsible for taking the right action when selected in
the switcher list. This should allow for more focused code and
responsibilities.

* Add note about memory management

* Add option to open channel in a new tab

* Add support for using the mouse

* Spawn switcher popup in the middle of the window

Works reliably on i3 at least. Might need some additional testing on
other WMs (and especially on Windows!).

* Add some icons for switcher items

Note that the final design of the list is not final but I do plan to
incorporate these in the future.

* Set Qt::Dialog window flag on switcher popup

Prevents tiling window managers like i3 from trying to tile the window.

* Rename "SwitcherItem" to "AbstractSwitcherItem"

* Add comments about what items are inserted

* Use custom model and view

Still missing: Currently selected item is not highlighted yet. You can
move between selected items with tab and arrow keys though.

* Add helper function to convert QVariant to AbstractSwitcherItem *

* Remove useless constant

* Highlight currently selected switcher item

* Use a different method for centering QuickSwitcherPopup window

* QuickSwitcherModel: Add documentation

* Add default parameter to QuickSwitcherModel::rowCount

* QuickSwitcherPopup: Add comments

* Remove outdated TODO

* QuickSwitcherModel: Init vector with default capacity

* Remove outdated comment

* Add comment about 0 ms timeout interval

* NewTabItem: Simplify interface

* Only fetch opened splits once

This is better than the prior approach since opened splits cannot change
anyways while the switcher is open.

* Use SplitContainer to pass information instead of custom type

* Allow searching for tab titles as well

Before this commit, only channel names could be searched.

* Refactor switcher item interface to be more flexible

Also show tab name and channel name in the switcher list.

* Add documentation for AbstractSwitcherItem

* Add documentation for NewTabItem

* Add comments about {begin,end}{Insert,Remove}Rows

* Remove unused method

* Replace magic size with named constant

* Add change log entry

Co-authored-by: fourtf <tf.four@gmail.com>
This commit is contained in:
Leon Richardt
2020-08-13 19:25:51 +02:00
committed by GitHub
parent ce57ad9b0d
commit a9080ceb3c
20 changed files with 927 additions and 11 deletions
@@ -0,0 +1,20 @@
#include "widgets/dialogs/switcher/AbstractSwitcherItem.hpp"
#include "Application.hpp"
namespace chatterino {
const QSize AbstractSwitcherItem::ICON_SIZE(32, 32);
AbstractSwitcherItem *AbstractSwitcherItem::fromVariant(const QVariant &variant)
{
// See https://stackoverflow.com/a/44503822 .
return static_cast<AbstractSwitcherItem *>(variant.value<void *>());
}
AbstractSwitcherItem::AbstractSwitcherItem(const QIcon &icon)
: icon_(icon)
{
}
} // namespace chatterino
@@ -0,0 +1,45 @@
#pragma once
namespace chatterino {
class AbstractSwitcherItem
{
public:
/**
* @brief Attempt to obtain an AbstractSwitcherItem * from the passed QVariant.
*
* @param variant variant to try to convert to AbstractSwitcherItem *
*
* @return an AbstractSwitcherItem * if the QVariant could be converted,
* or nullptr if the variant did not contain AbstractSwitcherItem *
*/
static AbstractSwitcherItem *fromVariant(const QVariant &variant);
virtual ~AbstractSwitcherItem() = default;
/**
* @brief Since all switcher items are required to have an icon, we require it
* in the base class constructor.
*
* @param icon icon to be displayed in the switcher list
*/
AbstractSwitcherItem(const QIcon &icon);
/**
* @brief Action to perform when this item is activated. Must be implemented in
* subclasses.
*/
virtual void action() = 0;
virtual void paint(QPainter *painter, const QRect &rect) const = 0;
virtual QSize sizeHint(const QRect &rect) const = 0;
protected:
QIcon icon_;
static const QSize ICON_SIZE;
};
} // namespace chatterino
// This allows us to store AbstractSwitcherItem * as a QVariant
Q_DECLARE_METATYPE(chatterino::AbstractSwitcherItem *);
@@ -0,0 +1,60 @@
#include "widgets/dialogs/switcher/NewTabItem.hpp"
#include "Application.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
#include "singletons/Fonts.hpp"
#include "singletons/Theme.hpp"
#include "singletons/WindowManager.hpp"
#include "widgets/Notebook.hpp"
#include "widgets/Window.hpp"
#include "widgets/helper/NotebookTab.hpp"
#include "widgets/splits/Split.hpp"
namespace chatterino {
NewTabItem::NewTabItem(const QString &channelName)
: AbstractSwitcherItem(QIcon(":/switcher/plus.svg"))
, channelName_(channelName)
, text_(QString(TEXT_FORMAT).arg(channelName))
{
}
void NewTabItem::action()
{
auto &nb = getApp()->windows->getMainWindow().getNotebook();
SplitContainer *container = nb.addPage(true);
Split *split = new Split(container);
split->setChannel(
getApp()->twitch.server->getOrAddChannel(this->channelName_));
container->appendSplit(split);
}
void NewTabItem::paint(QPainter *painter, const QRect &rect) const
{
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
// TODO(leon): Right pen/brush/font settings?
painter->setPen(getApp()->themes->splits.header.text);
painter->setBrush(Qt::SolidPattern);
painter->setFont(getApp()->fonts->getFont(FontStyle::UiMediumBold, 1.0));
QRect iconRect(rect.topLeft(), ICON_SIZE);
this->icon_.paint(painter, iconRect, Qt::AlignLeft | Qt::AlignVCenter);
QRect textRect =
QRect(iconRect.topRight(),
QSize(rect.width() - iconRect.width(), iconRect.height()));
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, this->text_);
painter->restore();
}
QSize NewTabItem::sizeHint(const QRect &rect) const
{
return QSize(rect.width(), ICON_SIZE.height());
}
} // namespace chatterino
@@ -0,0 +1,32 @@
#pragma once
#include "widgets/dialogs/switcher/AbstractSwitcherItem.hpp"
namespace chatterino {
class NewTabItem : public AbstractSwitcherItem
{
public:
/**
* @brief Construct a new NewTabItem that opens a passed channel in a new
* tab.
*
* @param channelName name of channel to open
*/
NewTabItem(const QString &channelName);
/**
* @brief Open the channel passed in the constructor in a new tab.
*/
virtual void action() override;
virtual void paint(QPainter *painter, const QRect &rect) const;
virtual QSize sizeHint(const QRect &rect) const;
private:
static constexpr const char *TEXT_FORMAT = "Open channel \"%1\" in new tab";
QString channelName_;
QString text_;
};
} // namespace chatterino
@@ -0,0 +1,61 @@
#include "widgets/dialogs/switcher/QuickSwitcherModel.hpp"
namespace chatterino {
QuickSwitcherModel::QuickSwitcherModel(QWidget *parent)
: QAbstractListModel(parent)
, items_(INITIAL_ITEMS_SIZE)
{
}
QuickSwitcherModel::~QuickSwitcherModel()
{
for (AbstractSwitcherItem *item : this->items_)
{
delete item;
}
}
int QuickSwitcherModel::rowCount(const QModelIndex &parent) const
{
return this->items_.size();
}
QVariant QuickSwitcherModel::data(const QModelIndex &index,
int /* role */) const
{
if (!index.isValid())
return QVariant();
if (index.row() >= this->items_.size())
return QVariant();
auto item = this->items_.at(index.row());
// See https://stackoverflow.com/a/44503822 .
return QVariant::fromValue(static_cast<void *>(item));
}
void QuickSwitcherModel::addItem(AbstractSwitcherItem *item)
{
// {begin,end}InsertRows needs to be called to notify attached views
this->beginInsertRows(QModelIndex(), this->items_.size(),
this->items_.size());
this->items_.append(item);
this->endInsertRows();
}
void QuickSwitcherModel::clear()
{
// {begin,end}RemoveRows needs to be called to notify attached views
this->beginRemoveRows(QModelIndex(), 0, this->items_.size() - 1);
for (AbstractSwitcherItem *item : this->items_)
{
delete item;
}
this->items_.clear();
this->endRemoveRows();
}
} // namespace chatterino
@@ -0,0 +1,59 @@
#pragma once
#include "widgets/dialogs/switcher/AbstractSwitcherItem.hpp"
namespace chatterino {
class QuickSwitcherModel : public QAbstractListModel
{
public:
QuickSwitcherModel(QWidget *parent = nullptr);
~QuickSwitcherModel();
/**
* @brief Reimplements QAbstractItemModel::rowCount.
*
* @return number of items currrently present in this model
*/
int rowCount(const QModelIndex &parent = QModelIndex()) const;
/**
* @brief Reimplements QAbstractItemModel::data. Currently, the role parameter
* is not used and an AbstractSwitcherItem * is always returned.
*
* @param index index of item to fetch data from
* @param role (not used)
*
* @return AbstractSwitcherItem * (wrapped as QVariant) at index
*/
QVariant data(const QModelIndex &index, int role) const;
/**
* @brief Add an item to this QuickSwitcherModel. It will be displayed in
* attached views.
*
* NOTE: The model will take ownership of the pointer. In particular,
* the same item should not be passed to multiple QuickSwitcherModels.
*
* @param item item to add to the model
*/
void addItem(AbstractSwitcherItem *item);
/**
* @brief Clears this QuickSwitcherModel of all items. This will delete all
* AbstractSwitcherItems added after the last invokation of
* QuickSwitcherModel::clear (and invalidate their pointers).
*/
void clear();
private:
/*
* On my system, the default QVector capacity is 0. 20 is an attempt at preventing
* frequent reallocations. The number is not backed by any user data but rather a
* guess at how many switcher items are probably going to be added.
*/
static constexpr int INITIAL_ITEMS_SIZE = 20;
QVector<AbstractSwitcherItem *> items_;
};
} // namespace chatterino
@@ -0,0 +1,200 @@
#include "widgets/dialogs/switcher/QuickSwitcherPopup.hpp"
#include "Application.hpp"
#include "singletons/WindowManager.hpp"
#include "util/LayoutCreator.hpp"
#include "widgets/Notebook.hpp"
#include "widgets/Window.hpp"
#include "widgets/dialogs/switcher/NewTabItem.hpp"
#include "widgets/dialogs/switcher/SwitchSplitItem.hpp"
#include "widgets/helper/NotebookTab.hpp"
namespace chatterino {
namespace {
using namespace chatterino;
QSet<SplitContainer *> openPages()
{
QSet<SplitContainer *> pages;
auto &nb = getApp()->windows->getMainWindow().getNotebook();
for (int i = 0; i < nb.getPageCount(); ++i)
{
pages.insert(static_cast<SplitContainer *>(nb.getPageAt(i)));
}
return pages;
}
} // namespace
const QSize QuickSwitcherPopup::MINIMUM_SIZE(500, 300);
QuickSwitcherPopup::QuickSwitcherPopup(QWidget *parent)
: BasePopup(FlagsEnum<BaseWindow::Flags>{BaseWindow::Flags::Frameless,
BaseWindow::Flags::TopMost},
parent)
, switcherModel_(this)
, switcherItemDelegate_(this)
, openPages_(openPages())
{
this->setWindowFlag(Qt::Dialog);
this->setActionOnFocusLoss(BaseWindow::ActionOnFocusLoss::Delete);
this->setMinimumSize(QuickSwitcherPopup::MINIMUM_SIZE);
this->initWidgets();
this->setStayInScreenRect(true);
const QRect geom = parent->geometry();
// This places the popup in the middle of the parent widget
this->setGeometry(QStyle::alignedRect(Qt::LeftToRight, Qt::AlignCenter,
this->size(), geom));
}
QuickSwitcherPopup::~QuickSwitcherPopup()
{
}
void QuickSwitcherPopup::initWidgets()
{
LayoutCreator<QWidget> creator(this->BaseWindow::getLayoutContainer());
auto vbox = creator.setLayoutType<QVBoxLayout>();
{
vbox.emplace<QLineEdit>().assign(&this->ui_.searchEdit);
QObject::connect(this->ui_.searchEdit, &QLineEdit::textChanged, this,
&QuickSwitcherPopup::updateSuggestions);
this->ui_.searchEdit->installEventFilter(this);
}
{
vbox.emplace<QListView>().assign(&this->ui_.list);
this->ui_.list->setSelectionMode(QAbstractItemView::SingleSelection);
this->ui_.list->setSelectionBehavior(QAbstractItemView::SelectItems);
this->ui_.list->setModel(&this->switcherModel_);
this->ui_.list->setItemDelegate(&this->switcherItemDelegate_);
/*
* I also tried handling key events using the according slots but
* it lead to all kind of problems that did not occur with the
* eventFilter approach.
*/
QObject::connect(
this->ui_.list, &QListView::clicked, this,
[this](const QModelIndex &index) {
auto *item = AbstractSwitcherItem::fromVariant(index.data());
item->action();
this->close();
});
}
}
void QuickSwitcherPopup::updateSuggestions(const QString &text)
{
this->switcherModel_.clear();
// Add items for navigating to different splits
for (auto *sc : this->openPages_)
{
const QString &tabTitle = sc->getTab()->getTitle();
const auto splits = sc->getSplits();
// First, check for splits on this page
for (auto *split : splits)
{
if (split->getChannel()->getName().contains(text,
Qt::CaseInsensitive))
{
SwitchSplitItem *item = new SwitchSplitItem(split);
this->switcherModel_.addItem(item);
// We want to continue the outer loop so we need a goto
goto nextPage;
}
}
// Then check if tab title matches
if (tabTitle.contains(text, Qt::CaseInsensitive))
{
SwitchSplitItem *item = new SwitchSplitItem(sc);
this->switcherModel_.addItem(item);
continue;
}
nextPage:;
}
// Add item for opening a channel in a new tab
if (!text.isEmpty())
{
NewTabItem *item = new NewTabItem(text);
this->switcherModel_.addItem(item);
}
const auto &startIdx = this->switcherModel_.index(0);
this->ui_.list->setCurrentIndex(startIdx);
/*
* Timeout interval 0 means the call will be delayed until all window events
* have been processed (cf. https://doc.qt.io/qt-5/qtimer.html#interval-prop).
*/
QTimer::singleShot(0, [this] { this->adjustSize(); });
}
bool QuickSwitcherPopup::eventFilter(QObject *watched, QEvent *event)
{
if (event->type() == QEvent::KeyPress)
{
auto *keyEvent = static_cast<QKeyEvent *>(event);
int key = keyEvent->key();
const QModelIndex &curIdx = this->ui_.list->currentIndex();
const int curRow = curIdx.row();
const int count = this->switcherModel_.rowCount(curIdx);
if (key == Qt::Key_Down || key == Qt::Key_Tab)
{
if (count <= 0)
return true;
const int newRow = (curRow + 1) % count;
this->ui_.list->setCurrentIndex(curIdx.siblingAtRow(newRow));
return true;
}
else if (key == Qt::Key_Up || key == Qt::Key_Backtab)
{
if (count <= 0)
return true;
int newRow = curRow - 1;
if (newRow < 0)
newRow += count;
this->ui_.list->setCurrentIndex(curIdx.siblingAtRow(newRow));
return true;
}
else if (key == Qt::Key_Enter || key == Qt::Key_Return)
{
if (count <= 0)
return true;
const auto index = this->ui_.list->currentIndex();
auto *item = AbstractSwitcherItem::fromVariant(index.data());
item->action();
this->close();
return true;
}
else
{
return false;
}
}
return false;
}
} // namespace chatterino
@@ -0,0 +1,49 @@
#pragma once
#include "common/Channel.hpp"
#include "widgets/BasePopup.hpp"
#include "widgets/dialogs/switcher/QuickSwitcherModel.hpp"
#include "widgets/dialogs/switcher/SwitcherItemDelegate.hpp"
#include "widgets/splits/Split.hpp"
#include "widgets/splits/SplitContainer.hpp"
#include <functional>
namespace chatterino {
class QuickSwitcherPopup : public BasePopup
{
public:
/**
* @brief Construct a new QuickSwitcherPopup.
*
* @param parent Parent widget of the popup. The popup will be placed
* in the center of the parent widget.
*/
explicit QuickSwitcherPopup(QWidget *parent = nullptr);
~QuickSwitcherPopup();
protected:
virtual bool eventFilter(QObject *watched, QEvent *event) override;
public slots:
void updateSuggestions(const QString &text);
private:
static const QSize MINIMUM_SIZE;
struct {
QLineEdit *searchEdit{};
QListView *list{};
} ui_;
QuickSwitcherModel switcherModel_;
SwitcherItemDelegate switcherItemDelegate_;
QSet<SplitContainer *> openPages_;
void initWidgets();
};
} // namespace chatterino
@@ -0,0 +1,90 @@
#include "widgets/dialogs/switcher/SwitchSplitItem.hpp"
#include "Application.hpp"
#include "singletons/Fonts.hpp"
#include "singletons/Theme.hpp"
#include "widgets/helper/NotebookTab.hpp"
namespace chatterino {
SwitchSplitItem::SwitchSplitItem(Split *split)
: AbstractSwitcherItem(QIcon(":switcher/switch.svg"))
, split_(split)
, container_(split->getContainer())
{
}
SwitchSplitItem::SwitchSplitItem(SplitContainer *container)
: AbstractSwitcherItem(QIcon(":switcher/switch.svg"))
, container_(container)
{
}
void SwitchSplitItem::action()
{
auto &nb = getApp()->windows->getMainWindow().getNotebook();
nb.select(this->container_);
/*
* If the item is referring to a specific channel, select the
* corresponding split.
*/
if (this->split_)
{
this->container_->setSelected(this->split_);
}
}
void SwitchSplitItem::paint(QPainter *painter, const QRect &rect) const
{
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
// TODO(leon): Right pen/brush/font settings?
painter->setPen(getApp()->themes->splits.header.text);
painter->setBrush(Qt::SolidPattern);
painter->setFont(getApp()->fonts->getFont(FontStyle::UiMediumBold, 1.0));
QRect iconRect(rect.topLeft(), ICON_SIZE);
this->icon_.paint(painter, iconRect, Qt::AlignLeft | Qt::AlignVCenter);
if (this->split_)
{
// Draw channel name and name of the containing tab
const auto availableTextWidth = rect.width() - iconRect.width();
QRect leftTextRect =
QRect(iconRect.topRight(),
QSize(0.3 * availableTextWidth, iconRect.height()));
painter->drawText(leftTextRect, Qt::AlignLeft | Qt::AlignVCenter,
this->split_->getChannel()->getName());
QRect rightTextRect =
QRect(leftTextRect.topRight(),
QSize(0.7 * availableTextWidth, iconRect.height()));
painter->setFont(getApp()->fonts->getFont(FontStyle::UiMedium, 1.0));
painter->drawText(rightTextRect, Qt::AlignRight | Qt::AlignVCenter,
this->container_->getTab()->getTitle());
}
else if (!this->split_ && this->container_)
{
// Only draw name of tab
QRect textRect =
QRect(iconRect.topRight(),
QSize(rect.width() - iconRect.width(), iconRect.height()));
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter,
this->container_->getTab()->getTitle());
}
painter->restore();
}
QSize SwitchSplitItem::sizeHint(const QRect &rect) const
{
return QSize(rect.width(), ICON_SIZE.height());
}
} // namespace chatterino
@@ -0,0 +1,29 @@
#pragma once
#include "widgets/dialogs/switcher/AbstractSwitcherItem.hpp"
#include "singletons/WindowManager.hpp"
#include "widgets/Notebook.hpp"
#include "widgets/Window.hpp"
#include "widgets/helper/NotebookTab.hpp"
#include "widgets/splits/Split.hpp"
namespace chatterino {
class SwitchSplitItem : public AbstractSwitcherItem
{
public:
SwitchSplitItem(Split *split);
SwitchSplitItem(SplitContainer *container);
virtual void action() override;
virtual void paint(QPainter *painter, const QRect &rect) const;
virtual QSize sizeHint(const QRect &rect) const;
private:
Split *split_{};
SplitContainer *container_{};
};
} // namespace chatterino
@@ -0,0 +1,48 @@
#include "widgets/dialogs/switcher/SwitcherItemDelegate.hpp"
#include "widgets/dialogs/switcher/AbstractSwitcherItem.hpp"
namespace chatterino {
SwitcherItemDelegate::SwitcherItemDelegate(QObject *parent)
: QStyledItemDelegate(parent)
{
}
SwitcherItemDelegate::~SwitcherItemDelegate()
{
}
void SwitcherItemDelegate::paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
auto *item = AbstractSwitcherItem::fromVariant(index.data());
if (item)
{
if (option.state & QStyle::State_Selected)
painter->fillRect(option.rect, option.palette.highlight());
item->paint(painter, option.rect);
}
else
{
QStyledItemDelegate::paint(painter, option, index);
}
}
QSize SwitcherItemDelegate::sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
auto *item = AbstractSwitcherItem::fromVariant(index.data());
if (item)
{
return item->sizeHint(option.rect);
}
return QStyledItemDelegate::sizeHint(option, index);
}
} // namespace chatterino
@@ -0,0 +1,22 @@
#pragma once
#include <QStyledItemDelegate>
namespace chatterino {
class SwitcherItemDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
SwitcherItemDelegate(QObject *parent = nullptr);
~SwitcherItemDelegate();
void paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
};
} // namespace chatterino