609 lines
18 KiB
C++
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 ®ex, 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
|