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:
@@ -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)
|
||||
|
||||
+21
-9
@@ -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
Submodule lib/libcommuni updated: f3e7f97914...a31ffb037e
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user