mumble-voip_mumble/src/mumble/SearchDialog.cpp

609 lines
18 KiB
C++

// Copyright 2021-2023 The Mumble Developers. All rights reserved.
// Use of this source code is governed by a BSD-style license
// that can be found in the LICENSE file at the root of the
// Mumble source tree or at <https://www.mumble.info/LICENSE>.
#include "SearchDialog.h"
#include "Accessibility.h"
#include "Channel.h"
#include "ClientUser.h"
#include "MainWindow.h"
#include "SearchDialogItemDelegate.h"
#include "ServerHandler.h"
#include "UserModel.h"
#include "Global.h"
#include <QContextMenuEvent>
#include <QFontMetrics>
#include <QHash>
#include <QHideEvent>
#include <QIcon>
#include <QKeyEvent>
#include <QReadLocker>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QShowEvent>
#include <QTreeWidgetItem>
#include <stack>
namespace Search {
QString SearchDialog::toString(UserAction action) {
switch (action) {
case UserAction::NONE:
return SearchDialog::tr("None");
case UserAction::JOIN:
return SearchDialog::tr("Join");
}
throw "Function incomplete or invalid enum value passed";
}
QString SearchDialog::toString(ChannelAction action) {
switch (action) {
case ChannelAction::NONE:
return SearchDialog::tr("None");
case ChannelAction::JOIN:
return SearchDialog::tr("Join");
}
throw "Function incomplete or invalid enum value passed";
}
class SearchResultItem : public QTreeWidgetItem {
Q_DISABLE_COPY(SearchResultItem)
public:
template< typename parent_t >
SearchResultItem(const SearchResult &result, unsigned int id, parent_t parent,
QTreeWidgetItem *precedingItem = nullptr)
: QTreeWidgetItem(parent, precedingItem), m_result(result), m_id(id) {
constexpr int TYPE_COLUMN = 0;
constexpr int MATCH_COLUMN = 1;
if (m_result.type == SearchType::User) {
QIcon userIcon = QIcon(QLatin1String("skin:talking_off.svg"));
setIcon(TYPE_COLUMN, userIcon);
}
setChildIndicatorPolicy(QTreeWidgetItem::DontShowIndicator);
QString matchText =
m_result.fullText.replace(m_result.begin, m_result.length,
"<b>" + m_result.fullText.midRef(m_result.begin, m_result.length) + "</b>");
setData(MATCH_COLUMN, Qt::DisplayRole, std::move(matchText));
setData(MATCH_COLUMN, SearchDialogItemDelegate::CHANNEL_TREE_ROLE, m_result.channelHierarchy);
setTextAlignment(MATCH_COLUMN, Qt::AlignLeft | Qt::AlignVCenter);
}
unsigned int getID() const { return m_id; }
const SearchResult &getResult() const { return m_result; }
private:
SearchResult m_result;
unsigned int m_id;
};
class ChannelItem : public QTreeWidgetItem {
Q_DISABLE_COPY(ChannelItem)
public:
template< typename parent_t >
ChannelItem(const Channel *chan, parent_t parent = nullptr, QTreeWidgetItem *precedingItem = nullptr)
: QTreeWidgetItem(parent, precedingItem), m_chanID(chan->iId) {
constexpr int NAME_COLUMN = 0;
setText(NAME_COLUMN, chan->qsName);
setTextAlignment(NAME_COLUMN, Qt::AlignLeft | Qt::AlignVCenter);
}
unsigned int getChannelID() const { return m_chanID; }
private:
unsigned int m_chanID;
};
SearchDialog::SearchDialog(QWidget *parent) : QWidget(parent), m_itemDelegate(new SearchDialogItemDelegate()) {
setupUi(this);
// Make the search dialog be always on top and is shown as a Window
setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint | Qt::Window);
// Set the correct icon for the options button
toggleOptions->setIcon(QIcon("skin:config_basic.png"));
// We can only init this after the UI has been set up
m_searchFieldStyleWrapper = MultiStyleWidgetWrapper(searchField);
// Init options
userOption->setChecked(Global::get().s.searchForUsers);
channelOption->setChecked(Global::get().s.searchForChannels);
caseSensitiveOption->setChecked(Global::get().s.searchCaseSensitive);
regexOption->setChecked(Global::get().s.searchAsRegex);
searchOptionBox->setVisible(Global::get().s.searchOptionsShown);
// This makes sure that our contextMenuEvent function gets called for creating the context menu
searchResultTree->setContextMenuPolicy(Qt::DefaultContextMenu);
// We have to use a custom ItemDelegate in order to be able to display rich text
searchResultTree->setItemDelegate(m_itemDelegate.get());
searchResultTree->setItemsExpandable(false);
// Remove icons for expanding items
searchResultTree->setRootIsDecorated(false);
searchResultTree->header()->setMinimumSectionSize(0);
searchResultTree->header()->hide();
QFontMetrics metric(searchResultTree->font());
searchResultTree->header()->resizeSection(0, static_cast< int >(metric.height() * 1.2));
searchResultTree->setIconSize(QSize(metric.ascent(), metric.ascent()));
if (Global::get().mw) {
QObject::connect(Global::get().mw, &MainWindow::serverSynchronized, this,
&SearchDialog::on_serverConnectionSynchronized);
// Add the action to toggle the search dialog to this dialof as well in order to make sure that
// toggling it off again also works when the search dialog has focus.
addAction(Global::get().mw->qaSearch);
}
if (Global::get().sh) {
QObject::connect(Global::get().sh.get(), &ServerHandler::disconnected, this,
&SearchDialog::on_serverDisconnected);
}
}
void SearchDialog::on_toggleOptions_clicked() {
// Toggle the search option's visibility
Global::get().s.searchOptionsShown = !searchOptionBox->isVisible();
searchOptionBox->setVisible(Global::get().s.searchOptionsShown);
}
void SearchDialog::on_searchField_returnPressed() {
// Then focus the search results (but only if there are actually
// search results available)
if (searchResultTree->topLevelItemCount() > 0) {
searchResultTree->setFocus();
if (!searchResultTree->currentItem()) {
// Select the first result
searchResultTree->setCurrentItem(searchResultTree->topLevelItem(0));
}
SearchResultItem *selectedItem = static_cast< SearchResultItem * >(searchResultTree->currentItem());
if (selectedItem) {
performDefaultAction(*selectedItem);
}
}
}
void SearchDialog::on_searchField_textChanged(const QString &text) {
// Reset the search field's background color (might have been changed to indicate an error)
m_searchFieldStyleWrapper.clearBackgroundColor();
search(text);
}
void SearchDialog::on_userOption_clicked(bool checked) {
Global::get().s.searchForUsers = checked;
searchAgain();
}
void SearchDialog::on_channelOption_clicked(bool checked) {
Global::get().s.searchForChannels = checked;
searchAgain();
}
void SearchDialog::on_caseSensitiveOption_clicked(bool checked) {
Global::get().s.searchCaseSensitive = checked;
searchAgain();
}
void SearchDialog::on_regexOption_clicked(bool checked) {
Global::get().s.searchAsRegex = checked;
searchAgain();
}
void SearchDialog::on_searchResultTree_currentItemChanged(QTreeWidgetItem *c, QTreeWidgetItem *) {
if (!c || Global::get().uiSession == 0) {
return;
}
SearchResultItem &item = static_cast< SearchResultItem & >(*c);
if (item.getResult().type == SearchType::User) {
const ClientUser *user = ClientUser::get(item.getID());
if (user) {
// Only try to select the user if (s)he still exists
Global::get().mw->pmModel->setSelectedUser(user->uiSession);
item.setData(1, Qt::AccessibleTextRole, Mumble::Accessibility::userToText(user));
}
} else {
const Channel *channel = Channel::get(item.getID());
if (channel) {
// Only try to select the channel if it still exists
Global::get().mw->pmModel->setSelectedChannel(channel->iId);
item.setData(1, Qt::AccessibleTextRole, Mumble::Accessibility::channelToText(channel));
}
}
// Hack to make screen readers read search results...
if (searchResultTree->currentColumn() == 1) {
searchResultTree->setCurrentItem(c, 0);
}
}
void SearchDialog::on_searchResultTree_itemActivated(QTreeWidgetItem *item, int) {
if (item) {
const SearchResultItem &activatedItem = static_cast< SearchResultItem & >(*item);
performDefaultAction(activatedItem);
}
}
void SearchDialog::searchAgain() {
search(searchField->text());
}
void SearchDialog::clearSearchResults() {
searchResultTree->clear();
}
SearchResult regularSearch(const QString &source, const QString &searchTerm, SearchType type, bool caseSensitive) {
constexpr int FROM = 0;
int startIndex = source.indexOf(searchTerm, FROM, caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive);
if (startIndex >= 0) {
int length = searchTerm.size();
return { startIndex, length, type, source, "" };
} else {
// Not found
return {};
}
}
SearchResult regexSearch(const QString &source, const QRegularExpression &regex, SearchType type) {
QRegularExpressionMatch match = regex.match(source);
if (match.hasMatch()) {
// Found
int startIndex = match.capturedStart();
int length = match.capturedEnd() - startIndex;
return { startIndex, length, type, source, "" };
} else {
// Not found
return {};
}
}
QString getChannelHierarchy(const Channel &channel, bool includeSource) {
std::stack< const Channel * > channels;
if (includeSource) {
channels.push(&channel);
}
const Channel *currentChannel = &channel;
;
while ((currentChannel = currentChannel->cParent)) {
channels.push(currentChannel);
}
const QString separator = Global::get().s.qsHierarchyChannelSeparator;
QString hierarchy;
while (!channels.empty()) {
hierarchy += channels.top()->qsName;
channels.pop();
if (!channels.empty()) {
hierarchy += separator;
}
}
return hierarchy;
}
void SearchDialog::search(const QString &searchTerm) {
if (searchTerm.size() == 0 || Global::get().uiSession == 0) {
// The search bar is either empty or there is no (fully synchronized) server connection
clearSearchResults();
return;
}
// Copy the values from the settings to ensure that the following code is not affected by
// settings changing mid-execution.
const bool searchUsers = Global::get().s.searchForUsers;
const bool searchChannels = Global::get().s.searchForChannels;
const bool caseSensitive = Global::get().s.searchCaseSensitive;
const bool useRegEx = Global::get().s.searchAsRegex;
// Build and validate the RegEx if required
QRegularExpression regex;
if (useRegEx) {
regex.setPattern(searchTerm);
QRegularExpression::PatternOptions options = QRegularExpression::UseUnicodePropertiesOption;
if (!caseSensitive) {
options |= QRegularExpression::CaseInsensitiveOption;
}
regex.setPatternOptions(options);
// Check that the provided RegEx is actually valid and usable
if (!regex.isValid()) {
// Indicate that there is an error by changing the search field's background color
m_searchFieldStyleWrapper.setBackgroundColor("#fc5555");
clearSearchResults();
return;
}
}
SearchResultMap matches;
// Start by searching for users
if (searchUsers) {
QReadLocker userLock(&ClientUser::c_qrwlUsers);
QHash< unsigned int, ClientUser * >::const_iterator it = ClientUser::c_qmUsers.constBegin();
while (it != ClientUser::c_qmUsers.constEnd()) {
const ClientUser *currentUser = it.value();
SearchResult result;
if (useRegEx) {
result = regexSearch(currentUser->qsName, regex, SearchType::User);
} else {
result = regularSearch(currentUser->qsName, searchTerm, SearchType::User, caseSensitive);
}
if (result) {
result.channelHierarchy = getChannelHierarchy(*currentUser->cChannel, true);
matches.insert({ result, currentUser->uiSession });
}
it++;
}
}
// Continue doing the same for channels
if (searchChannels) {
QReadLocker userLock(&Channel::c_qrwlChannels);
QHash< unsigned int, Channel * >::const_iterator it = Channel::c_qhChannels.constBegin();
while (it != Channel::c_qhChannels.constEnd()) {
const Channel *currentChannel = it.value();
SearchResult result;
if (useRegEx) {
result = regexSearch(currentChannel->qsName, regex, SearchType::Channel);
} else {
result = regularSearch(currentChannel->qsName, searchTerm, SearchType::Channel, caseSensitive);
}
if (result) {
result.channelHierarchy = getChannelHierarchy(*currentChannel, false);
// As the channel ID is never negative, we can safely cast it to an unsigned int
matches.insert({ result, static_cast< unsigned int >(currentChannel->iId) });
}
it++;
}
}
setSearchResults(matches);
}
void SearchDialog::on_serverConnectionSynchronized() {
searchAgain();
if (Global::get().sh) {
// Connect signal for clearing all search results on disconnect
QObject::connect(Global::get().sh.get(), &ServerHandler::disconnected, this,
&SearchDialog::on_serverDisconnected);
}
}
void SearchDialog::on_serverDisconnected() {
clearSearchResults();
}
void SearchDialog::on_clientDisconnected(unsigned int userSession) {
removeSearchResult(userSession, true);
}
void SearchDialog::on_channelRemoved(unsigned int channelID) {
removeSearchResult(channelID, false);
}
void SearchDialog::setSearchResults(const SearchResultMap &results) {
// First clear all existing results
clearSearchResults();
if (results.size() > 0) {
SearchResultMap::const_iterator it = results.cbegin();
SearchResultItem *previousItem = nullptr;
while (it != results.cend()) {
// Move the SearchResult out of the map
const SearchResult currentResult = std::move(it->first);
const unsigned int currentID = it->second;
// Constructing this instance is enough to set everything up and adding it to the tree
// We have to add a pointer to the previous item so that this item is actually appended after
// the preceding one and thus the order of the map is preserved. Without this, the order would
// be reversed.
previousItem = new SearchResultItem(currentResult, currentID, searchResultTree, previousItem);
it++;
}
}
}
void SearchDialog::hideEvent(QHideEvent *event) {
QWidget::hideEvent(event);
if (!event->spontaneous()) {
// Clear results and search string
clearSearchResults();
searchField->clear();
}
}
void SearchDialog::showEvent(QShowEvent *event) {
QWidget::showEvent(event);
// We have to activate the window to make sure that it currently has focus and (more importantly)
// is able to receive keyboard events. Without this function the window will appear but keyboard
// events will still be sent to whatever window had focus at the time this was made visible (at
// least of that previously focused window was not Mumble).
//
// Note however that this DOES NOT WORK on Windows!
// Citing from https://doc.qt.io/qt-5/qwidget.html#activateWindow:
// "On Windows, if you are calling this when the application is not currently the active one then it will not make
// it the active window. It will change the color of the taskbar entry to indicate that the window has changed in
// some way. This is because Microsoft does not allow an application to interrupt what the user is currently doing
// in another application."
activateWindow();
// Focus the search box so that the user can directly start typing
searchField->setFocus();
}
void SearchDialog::keyPressEvent(QKeyEvent *event) {
auto it = m_relayedKeyEvents.find(event);
if (it != m_relayedKeyEvents.cend()) {
// This is an KeyEvent that we relayed, but that did not get accepted by the TreeWidget and thus it is
// proagating back to us. Thus we will accept it here in order to keep it from propagating further up
// but we don't act on it since this here is not the originally intended target anyways.
event->accept();
m_relayedKeyEvents.erase(it);
return;
}
#if QT_VERSION >= QT_VERSION_CHECK(5, 6, 0)
if (event->matches(QKeySequence::Cancel)) {
#else
// Before Qt 5.6, no standard key for the cancel operation was defined. Thus, in these cases, we hardcode it to be
// Escape
if (event->key() == Qt::Key_Escape) {
#endif
event->accept();
// Mimic behavior of dialogs (close on Esc)
close();
}
if (event->key() == Qt::Key_Up || event->key() == Qt::Key_Down || event->key() == Qt::Key_PageUp
|| event->key() == Qt::Key_PageDown) {
QKeyEvent *copy = new QKeyEvent(event->type(), event->key(), event->modifiers(), event->nativeScanCode(),
event->nativeVirtualKey(), event->nativeScanCode(), event->text(),
event->isAutoRepeat(), static_cast< ushort >(event->count()));
m_relayedKeyEvents.insert(copy);
qApp->postEvent(searchResultTree, copy);
}
}
void SearchDialog::contextMenuEvent(QContextMenuEvent *event) {
if (Global::get().mw && searchResultTree->currentIndex().isValid() && Global::get().uiSession > 0) {
// We pretend as if the user had clicked on the client in the MainWindow. For this to work we map the global
// mouse position to the local coordinate system of the UserView in the MainWindow. The function will use
// some internal logic to determine the user to invoke the context menu on but if that fails (which in this
// case it will), it'll fall back to the currently selected item.
// As we synchronize the selection from the search dialog to the MainWindow, this will already be what we want
// it to be.
QMetaObject::invokeMethod(Global::get().mw, "on_qtvUsers_customContextMenuRequested", Qt::QueuedConnection,
Q_ARG(QPoint, Global::get().mw->qtvUsers->mapFromGlobal(event->globalPos())),
Q_ARG(bool, false));
event->accept();
}
}
void SearchDialog::performDefaultAction(const SearchResultItem &item) {
MainWindow *mainWindow = Global::get().mw;
if (!mainWindow) {
return;
}
if (item.getResult().type == SearchType::User) {
ClientUser *selectedUser = mainWindow->pmModel->getSelectedUser();
if (selectedUser) {
mainWindow->cuContextUser = selectedUser;
switch (Global::get().s.searchUserAction) {
case UserAction::NONE:
break;
case UserAction::JOIN:
mainWindow->on_qaUserJoin_triggered();
break;
}
// Reset to avoid side-effects
mainWindow->cuContextUser = nullptr;
}
} else {
Channel *selectedChannel = mainWindow->pmModel->getSelectedChannel();
if (selectedChannel) {
mainWindow->cContextChannel = selectedChannel;
switch (Global::get().s.searchChannelAction) {
case ChannelAction::NONE:
break;
case ChannelAction::JOIN:
mainWindow->on_qaChannelJoin_triggered();
break;
}
// Reset to avoid side-effects
mainWindow->cContextChannel = nullptr;
}
}
// Close dialog after the default action
close();
}
bool SearchDialog::removeSearchResult(unsigned int id, bool isUser) {
for (int i = 0; i < searchResultTree->topLevelItemCount(); i++) {
SearchResultItem *item = static_cast< SearchResultItem * >(searchResultTree->topLevelItem(i));
if (item) {
bool typeMatches = (isUser && item->getResult().type == SearchType::User)
|| (!isUser && item->getResult().type == SearchType::Channel);
if (typeMatches && item->getID() == id) {
// Remove this item
QTreeWidgetItem *removedItem = searchResultTree->takeTopLevelItem(i);
delete removedItem;
return true;
}
}
}
return false;
}
} // namespace Search