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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 927 additions and 11 deletions

View file

@ -4,6 +4,7 @@
- Major: We now support image thumbnails coming from the link resolver. This feature is off by default and can be enabled in the settings with the "Show link thumbnail" setting. This feature also requires the "Show link info when hovering" setting to be enabled (#1664)
- Major: Added image upload functionality to i.nuuls.com with an ability to change upload destination. This works by dragging and dropping an image into a split, or pasting an image into the text edit field. (#1332, #1741)
- Minor: Add a switcher widget, similar to Discord. It can be opened by pressing Ctrl+K. (#1588)
- Major: Added option to display tabs vertically. (#1815)
- Minor: Clicking on `Open in browser` in a whisper split will now open your whispers on twitch. (#1828)
- Minor: Clicking on @mentions will open the User Popup. (#1674)

View file

@ -156,10 +156,10 @@ SOURCES += \
src/messages/MessageColor.cpp \
src/messages/MessageContainer.cpp \
src/messages/MessageElement.cpp \
src/messages/SharedMessageBuilder.cpp \
src/messages/search/AuthorPredicate.cpp \
src/messages/search/LinkPredicate.cpp \
src/messages/search/SubstringPredicate.cpp \
src/messages/SharedMessageBuilder.cpp \
src/providers/bttv/BttvEmotes.cpp \
src/providers/bttv/LoadBttvChannelEmote.cpp \
src/providers/chatterino/ChatterinoBadges.cpp \
@ -218,11 +218,11 @@ SOURCES += \
src/util/IncognitoBrowser.cpp \
src/util/InitUpdateButton.cpp \
src/util/JsonQuery.cpp \
src/util/RapidjsonHelpers.cpp \
src/util/StreamLink.cpp \
src/util/StreamerMode.cpp \
src/util/Twitch.cpp \
src/util/NuulsUploader.cpp \
src/util/RapidjsonHelpers.cpp \
src/util/StreamerMode.cpp \
src/util/StreamLink.cpp \
src/util/Twitch.cpp \
src/util/WindowsHelper.cpp \
src/widgets/AccountSwitchPopup.cpp \
src/widgets/AccountSwitchWidget.cpp \
@ -239,6 +239,12 @@ SOURCES += \
src/widgets/dialogs/QualityPopup.cpp \
src/widgets/dialogs/SelectChannelDialog.cpp \
src/widgets/dialogs/SettingsDialog.cpp \
src/widgets/dialogs/switcher/AbstractSwitcherItem.cpp \
src/widgets/dialogs/switcher/NewTabItem.cpp \
src/widgets/dialogs/switcher/QuickSwitcherModel.cpp \
src/widgets/dialogs/switcher/QuickSwitcherPopup.cpp \
src/widgets/dialogs/switcher/SwitcherItemDelegate.cpp \
src/widgets/dialogs/switcher/SwitchSplitItem.cpp \
src/widgets/dialogs/TextInputDialog.cpp \
src/widgets/dialogs/UpdateDialog.cpp \
src/widgets/dialogs/UserInfoPopup.cpp \
@ -358,12 +364,12 @@ HEADERS += \
src/messages/MessageContainer.hpp \
src/messages/MessageElement.hpp \
src/messages/MessageParseArgs.hpp \
src/messages/SharedMessageBuilder.hpp \
src/messages/search/AuthorPredicate.hpp \
src/messages/search/LinkPredicate.hpp \
src/messages/search/MessagePredicate.hpp \
src/messages/search/SubstringPredicate.hpp \
src/messages/Selection.hpp \
src/messages/SharedMessageBuilder.hpp \
src/PrecompiledHeader.hpp \
src/providers/bttv/BttvEmotes.hpp \
src/providers/bttv/LoadBttvChannelEmote.hpp \
@ -433,13 +439,12 @@ HEADERS += \
src/util/JsonQuery.hpp \
src/util/LayoutCreator.hpp \
src/util/LayoutHelper.hpp \
src/util/NuulsUploader.hpp \
src/util/Overloaded.hpp \
src/util/PersistSignalVector.hpp \
src/util/PostToThread.hpp \
src/util/QObjectRef.hpp \
src/util/QStringHash.hpp \
src/util/StreamerMode.hpp \
src/util/Twitch.hpp \
src/util/rangealgorithm.hpp \
src/util/RapidjsonHelpers.hpp \
src/util/RapidJsonSerializeQString.hpp \
@ -449,8 +454,9 @@ HEADERS += \
src/util/SharedPtrElementLess.hpp \
src/util/Shortcut.hpp \
src/util/StandardItemHelper.hpp \
src/util/StreamerMode.hpp \
src/util/StreamLink.hpp \
src/util/NuulsUploader.hpp \
src/util/Twitch.hpp \
src/util/WindowsHelper.hpp \
src/widgets/AccountSwitchPopup.hpp \
src/widgets/AccountSwitchWidget.hpp \
@ -467,6 +473,12 @@ HEADERS += \
src/widgets/dialogs/QualityPopup.hpp \
src/widgets/dialogs/SelectChannelDialog.hpp \
src/widgets/dialogs/SettingsDialog.hpp \
src/widgets/dialogs/switcher/AbstractSwitcherItem.hpp \
src/widgets/dialogs/switcher/NewTabItem.hpp \
src/widgets/dialogs/switcher/QuickSwitcherModel.hpp \
src/widgets/dialogs/switcher/QuickSwitcherPopup.hpp \
src/widgets/dialogs/switcher/SwitcherItemDelegate.hpp \
src/widgets/dialogs/switcher/SwitchSplitItem.hpp \
src/widgets/dialogs/TextInputDialog.hpp \
src/widgets/dialogs/UpdateDialog.hpp \
src/widgets/dialogs/UserInfoPopup.hpp \

@ -1 +1 @@
Subproject commit f3e7f97914d9bf1166d349a83d93a2b4f4743c39
Subproject commit a31ffb037eadac65dba73ad2b2da6dafe31e3bf7

View file

@ -78,6 +78,8 @@
<file>split/move.png</file>
<file>split/right.png</file>
<file>split/up.png</file>
<file>switcher/plus.svg</file>
<file>switcher/switch.svg</file>
<file>tlds.txt</file>
<file>twitch/admin.png</file>
<file>twitch/automod.png</file>

View file

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="210mm"
height="297mm"
viewBox="0 0 210 297"
version="1.1"
id="svg8"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
sodipodi:docname="plus.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.7"
inkscape:cx="237.44516"
inkscape:cy="499.16577"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:measure-start="627.601,711.894"
inkscape:measure-end="628.106,660"
inkscape:pagecheckerboard="true"
inkscape:window-width="1918"
inkscape:window-height="1053"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="1"
inkscape:document-rotation="0">
<inkscape:grid
type="xygrid"
id="grid815" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g12"
transform="matrix(2.1771058,0,0,2.1771058,-134.91094,-141.35877)">
<path
id="path834"
style="fill:#5e5e5e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.786;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 154.50333,127.59624 A 43.750744,43.750744 0 0 1 110.75259,171.34698 43.750744,43.750744 0 0 1 67.001842,127.59624 43.750744,43.750744 0 0 1 110.75259,83.845493 43.750744,43.750744 0 0 1 154.50333,127.59624" />
<path
inkscape:connector-curvature="0"
id="rect817"
d="m 104.89396,104.16173 v 17.57589 H 87.318064 v 11.71725 h 17.575896 v 17.57589 h 11.71727 v -17.57589 h 17.57589 v -11.71725 h -17.57589 v -17.57589 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.435107;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="switch.svg"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
id="svg8"
version="1.1"
viewBox="0 0 210 297"
height="297mm"
width="210mm">
<defs
id="defs2">
<inkscape:path-effect
effect="spiro"
id="path-effect846"
is_visible="true" />
<inkscape:path-effect
is_visible="true"
id="path-effect842"
effect="spiro" />
<inkscape:path-effect
only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
is_visible="true"
id="path-effect838"
effect="bspline" />
</defs>
<sodipodi:namedview
inkscape:document-rotation="0"
showguides="false"
inkscape:window-maximized="1"
inkscape:window-y="25"
inkscape:window-x="0"
inkscape:window-height="1053"
inkscape:window-width="1918"
inkscape:pagecheckerboard="true"
inkscape:measure-end="452.742,560.248"
inkscape:measure-start="452.742,588.189"
showgrid="false"
inkscape:current-layer="layer1"
inkscape:document-units="mm"
inkscape:cy="518.41498"
inkscape:cx="382.6533"
inkscape:zoom="0.35"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#ffffff"
id="base">
<inkscape:grid
id="grid815"
type="xygrid" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
inkscape:groupmode="layer"
inkscape:label="Ebene 1">
<circle
r="95.25"
cy="148.83635"
cx="105.97791"
id="path834"
style="fill:#5e5e5e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.7112;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<g
transform="matrix(1.5315391,0,0,1.5315391,-51.342168,-65.4613)"
id="g1462">
<path
sodipodi:nodetypes="cccccccc"
inkscape:connector-curvature="0"
id="path870"
d="m 64.225375,157.86151 c 0,0 36.412935,10.20456 55.562495,3.37954 v 7.39282 l 21.42726,-21.18896 -21.42722,-6.06995 v 7.39287 c -16.9044,6.0364 -55.562535,-4.13549 -55.562535,-4.13549 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.786;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.786;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 141.21513,121.98463 c 0,0 -36.41293,-10.20456 -55.562491,-3.37954 v -7.39282 l -21.42726,21.18896 21.42722,6.06995 v -7.39287 c 16.904401,-6.0364 55.562531,4.13549 55.562531,4.13549 z"
id="path880"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -17,6 +17,7 @@
#include "widgets/dialogs/SettingsDialog.hpp"
#include "widgets/dialogs/UpdateDialog.hpp"
#include "widgets/dialogs/WelcomeDialog.hpp"
#include "widgets/dialogs/switcher/QuickSwitcherPopup.hpp"
#include "widgets/helper/EffectLabel.hpp"
#include "widgets/helper/NotebookTab.hpp"
#include "widgets/helper/TitlebarButton.hpp"
@ -393,6 +394,12 @@ void Window::addShortcuts()
getSettings()->hideSimilar.setValue(!getSettings()->hideSimilar);
getApp()->windows->forceLayoutChannelViews();
});
createWindowShortcut(this, "CTRL+K", [this] {
auto quickSwitcher =
new QuickSwitcherPopup(&getApp()->windows->getMainWindow());
quickSwitcher->show();
});
}
void Window::addMenuBar()

View file

@ -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

View file

@ -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 *);

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -182,6 +182,7 @@ public:
Position deleteSplit(Split *split);
void selectNextSplit(Direction direction);
void setSelected(Split *selected_);
void decodeFromJson(QJsonObject &obj);
@ -214,7 +215,6 @@ protected:
private:
void layout();
void setSelected(Split *selected_);
void selectSplitRecursive(Node *node, Direction direction);
void focusSplitRecursive(Node *node, Direction direction);
void setPreferedTargetRecursive(Node *node);