diff options
author | Robert Adam <dev@robert-adam.de> | 2021-03-08 20:48:54 +0300 |
---|---|---|
committer | Robert Adam <dev@robert-adam.de> | 2021-05-11 13:35:24 +0300 |
commit | e59b890c365ee92f3abb1164c767f27448e9e9ae (patch) | |
tree | c912ce534d2f7ba4479c8781af2bf71e74ddcd7c | |
parent | 1bf8060d1b37d55e1131fa1c5497e287c4db2964 (diff) |
FEAT(client): Search dialog
This commit introduces a fully featured search dialog.
Note that selections in the search dialog are synchronized with the main
UI and thus "whisper to selection" works by selecting something in the
search dialog as well.
There are also default actions that are executed when a search result is
activated (double-clicked or pressing enter on it). What that action is
exactly, can be configured in the settings.
Furthermore the context menu works as expected when invoked on entries
in the search result list.
In order to disambiguate the results, the full channel hierarchy to the
search result's parent channel is shown below each result.
The search dialog can be toggled via Ctrl+F when Mumble has focus or by
using the new entry in the toolbar. Additionally there is a new global
shortcut that can be configured for this purpose. Note however that on
Windows the search dialog won't obtain focus when toggled in a situation
when Mumble does not have focus already.
-rw-r--r-- | src/mumble/CMakeLists.txt | 9 | ||||
-rw-r--r-- | src/mumble/LookConfig.cpp | 21 | ||||
-rw-r--r-- | src/mumble/LookConfig.ui | 518 | ||||
-rw-r--r-- | src/mumble/MainWindow.cpp | 44 | ||||
-rw-r--r-- | src/mumble/MainWindow.h | 11 | ||||
-rw-r--r-- | src/mumble/MainWindow.ui | 21 | ||||
-rw-r--r-- | src/mumble/Messages.cpp | 15 | ||||
-rw-r--r-- | src/mumble/QtWidgetUtils.cpp | 26 | ||||
-rw-r--r-- | src/mumble/QtWidgetUtils.h | 3 | ||||
-rw-r--r-- | src/mumble/SearchDialog.cpp | 594 | ||||
-rw-r--r-- | src/mumble/SearchDialog.h | 147 | ||||
-rw-r--r-- | src/mumble/SearchDialog.ui | 165 | ||||
-rw-r--r-- | src/mumble/Settings.cpp | 32 | ||||
-rw-r--r-- | src/mumble/Settings.h | 11 | ||||
-rw-r--r-- | src/mumble/widgets/MultiStyleWidgetWrapper.h | 2 | ||||
-rw-r--r-- | src/mumble/widgets/RichTextItemDelegate.cpp | 88 | ||||
-rw-r--r-- | src/mumble/widgets/RichTextItemDelegate.h | 32 | ||||
-rw-r--r-- | src/mumble/widgets/SearchDialogItemDelegate.cpp | 150 | ||||
-rw-r--r-- | src/mumble/widgets/SearchDialogItemDelegate.h | 36 | ||||
-rw-r--r-- | src/mumble/widgets/SearchDialogTree.cpp | 19 | ||||
-rw-r--r-- | src/mumble/widgets/SearchDialogTree.h | 21 | ||||
-rw-r--r-- | themes/MumbleTheme.qrc | 1 |
22 files changed, 1736 insertions, 230 deletions
diff --git a/src/mumble/CMakeLists.txt b/src/mumble/CMakeLists.txt index 34f9d22d3..7047a2fd8 100644 --- a/src/mumble/CMakeLists.txt +++ b/src/mumble/CMakeLists.txt @@ -193,6 +193,9 @@ set(MUMBLE_SOURCES "RichTextEditor.ui" "Screen.cpp" "Screen.h" + "SearchDialog.cpp" + "SearchDialog.h" + "SearchDialog.ui" "ServerHandler.cpp" "ServerHandler.h" "ServerInformation.cpp" @@ -267,6 +270,12 @@ set(MUMBLE_SOURCES "widgets/MUComboBox.h" "widgets/MultiStyleWidgetWrapper.cpp" "widgets/MultiStyleWidgetWrapper.h" + "widgets/RichTextItemDelegate.cpp" + "widgets/RichTextItemDelegate.h" + "widgets/SearchDialogItemDelegate.cpp" + "widgets/SearchDialogItemDelegate.h" + "widgets/SearchDialogTree.cpp" + "widgets/SearchDialogTree.h" "${SHARED_SOURCE_DIR}/ACL.cpp" diff --git a/src/mumble/LookConfig.cpp b/src/mumble/LookConfig.cpp index 66fd4dcd7..ea6b3c4a6 100644 --- a/src/mumble/LookConfig.cpp +++ b/src/mumble/LookConfig.cpp @@ -9,6 +9,7 @@ #include "AudioInput.h" #include "AudioOutput.h" #include "MainWindow.h" +#include "SearchDialog.h" #include "Global.h" #include <QtCore/QFileSystemWatcher> @@ -98,6 +99,19 @@ LookConfig::LookConfig(Settings &st) : ConfigWidget(st) { qlThemesDirectory->setText(tr("<a href=\"%1\">Browse</a>").arg(userThemeDirectoryUrl.toString())); qlThemesDirectory->setOpenExternalLinks(true); } + +#define ADD_SEARCH_USERACTION(name) \ + qcbSearchUserAction->addItem(Search::SearchDialog::toString(Search::SearchDialog::UserAction::name), \ + static_cast< int >(Search::SearchDialog::UserAction::name)) + ADD_SEARCH_USERACTION(NONE); + ADD_SEARCH_USERACTION(JOIN); +#undef ADD_SEARCH_USERACTION +#define ADD_SEARCH_CHANNELACTION(name) \ + qcbSearchChannelAction->addItem(Search::SearchDialog::toString(Search::SearchDialog::ChannelAction::name), \ + static_cast< int >(Search::SearchDialog::ChannelAction::name)) + ADD_SEARCH_CHANNELACTION(NONE); + ADD_SEARCH_CHANNELACTION(JOIN); +#undef ADD_SEARCH_CHANNELACTION } QString LookConfig::title() const { @@ -202,6 +216,9 @@ void LookConfig::load(const Settings &r) { qleAbbreviationReplacement->setText(r.qsTalkingUI_AbbreviationReplacement); qleChannelSeparator->setText(r.qsHierarchyChannelSeparator); + + loadComboBox(qcbSearchUserAction, static_cast< int >(r.searchUserAction)); + loadComboBox(qcbSearchChannelAction, static_cast< int >(r.searchChannelAction)); } void LookConfig::save() const { @@ -270,6 +287,10 @@ void LookConfig::save() const { s.qsTalkingUI_AbbreviationReplacement = qleAbbreviationReplacement->text(); s.qsHierarchyChannelSeparator = qleChannelSeparator->text(); + + s.searchUserAction = static_cast< Search::SearchDialog::UserAction >(qcbSearchUserAction->currentData().toInt()); + s.searchChannelAction = + static_cast< Search::SearchDialog::ChannelAction >(qcbSearchChannelAction->currentData().toInt()); } void LookConfig::accept() const { diff --git a/src/mumble/LookConfig.ui b/src/mumble/LookConfig.ui index a09faa025..63013bc31 100644 --- a/src/mumble/LookConfig.ui +++ b/src/mumble/LookConfig.ui @@ -14,6 +14,149 @@ <string notr="true">Form</string> </property> <layout class="QGridLayout" name="gridLayout_4"> + <item row="3" column="1"> + <widget class="QGroupBox" name="qgbChannel"> + <property name="title"> + <string>Channel Tree</string> + </property> + <layout class="QGridLayout" name="gridLayout_2" columnstretch="0,0"> + <item row="8" column="0" colspan="2"> + <widget class="QCheckBox" name="qcbFilterHidesEmptyChannels"> + <property name="text"> + <string>Filter automatically hides empty channels</string> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="MUComboBox" name="qcbExpand"> + <property name="toolTip"> + <string>When to automatically expand channels</string> + </property> + <property name="whatsThis"> + <string>This sets which channels to automatically expand. <i>None</i> and <i>All</i> will expand no or all channels, while <i>Only with users</i> will expand and collapse channels as users join and leave them.</string> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="MUComboBox" name="qcbUserDrag"> + <property name="toolTip"> + <string>This changes the behavior when moving users.</string> + </property> + <property name="whatsThis"> + <string>This sets the behavior of user drags; it can be used to prevent accidental dragging. <i>Move</i> moves the user without prompting. <i>Do Nothing</i> does nothing and prints an error message. <i>Ask</i> uses a message box to confirm if you really wanted to move the user.</string> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="qliUserDrag"> + <property name="text"> + <string>User Dragging</string> + </property> + </widget> + </item> + <item row="0" column="0"> + <widget class="QLabel" name="qliChannelDrag"> + <property name="text"> + <string>Channel Dragging</string> + </property> + </widget> + </item> + <item row="7" column="0" colspan="2"> + <widget class="QCheckBox" name="qcbChatBarUseSelection"> + <property name="text"> + <string>Use selected item as the chat bar target</string> + </property> + </widget> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="qliExpand"> + <property name="text"> + <string>Expand</string> + </property> + </widget> + </item> + <item row="4" column="0" colspan="2"> + <widget class="QCheckBox" name="qcbShowUserCount"> + <property name="toolTip"> + <string>Show number of users in each channel</string> + </property> + <property name="text"> + <string>Show channel user count</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="MUComboBox" name="qcbChannelDrag"> + <property name="toolTip"> + <string>This changes the behavior when moving channels.</string> + </property> + <property name="whatsThis"> + <string>This sets the behavior of channel drags; it can be used to prevent accidental dragging. <i>Move</i> moves the channel without prompting. <i>Do Nothing</i> does nothing and prints an error message. <i>Ask</i> uses a message box to confirm if you really wanted to move the channel.</string> + </property> + </widget> + </item> + <item row="3" column="0" colspan="2"> + <widget class="QCheckBox" name="qcbUsersTop"> + <property name="toolTip"> + <string>List users above subchannels (requires restart).</string> + </property> + <property name="whatsThis"> + <string><b>If set, users will be shown above subchannels in the channel view.</b><br />A restart of Mumble is required to see the change.</string> + </property> + <property name="text"> + <string>Users above Channels</string> + </property> + </widget> + </item> + <item row="5" column="0" colspan="2"> + <widget class="QCheckBox" name="qcbShowVolumeAdjustments"> + <property name="toolTip"> + <string>Show the local volume adjustment for each user (if any).</string> + </property> + <property name="text"> + <string>Show volume adjustments</string> + </property> + </widget> + </item> + <item row="6" column="0" colspan="2"> + <widget class="QCheckBox" name="qcbShowNicknamesOnly"> + <property name="toolTip"> + <string>Hide the username for each user if they have a nickname.</string> + </property> + <property name="text"> + <string>Show nicknames only</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item row="6" column="1"> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item row="6" column="0"> + <spacer name="verticalSpacer_2"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> <item row="0" column="0" colspan="2"> <widget class="QGroupBox" name="qgbLayout"> <property name="title"> @@ -216,98 +359,6 @@ </layout> </widget> </item> - <item row="1" column="0" rowspan="4"> - <widget class="QGroupBox" name="qgbLookFeel"> - <property name="title"> - <string>Look and Feel</string> - </property> - <layout class="QGridLayout" name="gridLayout"> - <item row="3" column="0" colspan="2"> - <widget class="MUComboBox" name="qcbLanguage"> - <property name="toolTip"> - <string>Language to use (requires restart)</string> - </property> - <property name="whatsThis"> - <string><b>This sets which language Mumble should use.</b><br />You have to restart Mumble to use the new language.</string> - </property> - </widget> - </item> - <item row="0" column="0"> - <widget class="QLabel" name="qliTheme"> - <property name="text"> - <string>Theme</string> - </property> - </widget> - </item> - <item row="1" column="0" colspan="2"> - <widget class="MUComboBox" name="qcbTheme"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> - <horstretch>0</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="toolTip"> - <string>Theme to use to style the user interface</string> - </property> - <property name="whatsThis"> - <string><b>Configures which theme the Mumble user interface should be styled with</b><br />Mumble will pick up themes from certain directories and display them in this list. The one you select will be used to customize the visual appearance of Mumble. This includes colors, icons and more.</string> - </property> - </widget> - </item> - <item row="5" column="0" colspan="2"> - <widget class="QCheckBox" name="qcbShowTransmitModeComboBox"> - <property name="text"> - <string>Show transmit mode dropdown in toolbar</string> - </property> - </widget> - </item> - <item row="4" column="0" colspan="2"> - <widget class="QCheckBox" name="qcbHighContrast"> - <property name="toolTip"> - <string>Apply some high contrast optimizations for visually impaired users</string> - </property> - <property name="text"> - <string>Optimize for high contrast</string> - </property> - </widget> - </item> - <item row="6" column="0"> - <spacer name="verticalSpacer"> - <property name="orientation"> - <enum>Qt::Vertical</enum> - </property> - <property name="sizeHint" stdset="0"> - <size> - <width>20</width> - <height>40</height> - </size> - </property> - </spacer> - </item> - <item row="2" column="0"> - <widget class="QLabel" name="qliLanguage"> - <property name="text"> - <string>Language</string> - </property> - <property name="buddy"> - <cstring>qcbLanguage</cstring> - </property> - </widget> - </item> - <item row="0" column="1"> - <widget class="QLabel" name="qlThemesDirectory"> - <property name="text"> - <string/> - </property> - <property name="alignment"> - <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> - </property> - </widget> - </item> - </layout> - </widget> - </item> <item row="2" column="1"> <widget class="QGroupBox" name="qgbTray"> <property name="title"> @@ -432,117 +483,32 @@ </layout> </widget> </item> - <item row="3" column="1"> - <widget class="QGroupBox" name="qgbChannel"> + <item row="5" column="1"> + <widget class="QGroupBox" name="qgbChannelHierarchyString"> <property name="title"> - <string>Channel Tree</string> + <string>Channel Hierarchy String</string> </property> - <layout class="QGridLayout" name="gridLayout_2" columnstretch="0,0"> - <item row="8" column="0" colspan="2"> - <widget class="QCheckBox" name="qcbFilterHidesEmptyChannels"> - <property name="text"> - <string>Filter automatically hides empty channels</string> - </property> - </widget> - </item> - <item row="2" column="1"> - <widget class="MUComboBox" name="qcbExpand"> - <property name="toolTip"> - <string>When to automatically expand channels</string> - </property> - <property name="whatsThis"> - <string>This sets which channels to automatically expand. <i>None</i> and <i>All</i> will expand no or all channels, while <i>Only with users</i> will expand and collapse channels as users join and leave them.</string> - </property> - </widget> - </item> - <item row="1" column="1"> - <widget class="MUComboBox" name="qcbUserDrag"> - <property name="toolTip"> - <string>This changes the behavior when moving users.</string> - </property> - <property name="whatsThis"> - <string>This sets the behavior of user drags; it can be used to prevent accidental dragging. <i>Move</i> moves the user without prompting. <i>Do Nothing</i> does nothing and prints an error message. <i>Ask</i> uses a message box to confirm if you really wanted to move the user.</string> - </property> - </widget> - </item> - <item row="1" column="0"> - <widget class="QLabel" name="qliUserDrag"> - <property name="text"> - <string>User Dragging</string> - </property> - </widget> - </item> + <layout class="QFormLayout" name="formLayout"> <item row="0" column="0"> - <widget class="QLabel" name="qliChannelDrag"> - <property name="text"> - <string>Channel Dragging</string> - </property> - </widget> - </item> - <item row="7" column="0" colspan="2"> - <widget class="QCheckBox" name="qcbChatBarUseSelection"> - <property name="text"> - <string>Use selected item as the chat bar target</string> - </property> - </widget> - </item> - <item row="2" column="0"> - <widget class="QLabel" name="qliExpand"> - <property name="text"> - <string>Expand</string> - </property> - </widget> - </item> - <item row="4" column="0" colspan="2"> - <widget class="QCheckBox" name="qcbShowUserCount"> + <widget class="QLabel" name="qlChannelSeparator"> <property name="toolTip"> - <string>Show number of users in each channel</string> + <string>String to separate a channel name from its parent's.</string> </property> <property name="text"> - <string>Show channel user count</string> + <string>Channel separator</string> </property> </widget> </item> <item row="0" column="1"> - <widget class="MUComboBox" name="qcbChannelDrag"> - <property name="toolTip"> - <string>This changes the behavior when moving channels.</string> - </property> - <property name="whatsThis"> - <string>This sets the behavior of channel drags; it can be used to prevent accidental dragging. <i>Move</i> moves the channel without prompting. <i>Do Nothing</i> does nothing and prints an error message. <i>Ask</i> uses a message box to confirm if you really wanted to move the channel.</string> - </property> - </widget> - </item> - <item row="3" column="0" colspan="2"> - <widget class="QCheckBox" name="qcbUsersTop"> - <property name="toolTip"> - <string>List users above subchannels (requires restart).</string> - </property> - <property name="whatsThis"> - <string><b>If set, users will be shown above subchannels in the channel view.</b><br />A restart of Mumble is required to see the change.</string> - </property> - <property name="text"> - <string>Users above Channels</string> - </property> - </widget> - </item> - <item row="5" column="0" colspan="2"> - <widget class="QCheckBox" name="qcbShowVolumeAdjustments"> - <property name="toolTip"> - <string>Show the local volume adjustment for each user (if any).</string> - </property> - <property name="text"> - <string>Show volume adjustments</string> + <widget class="QLineEdit" name="qleChannelSeparator"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> </property> - </widget> - </item> - <item row="6" column="0" colspan="2"> - <widget class="QCheckBox" name="qcbShowNicknamesOnly"> <property name="toolTip"> - <string>Hide the username for each user if they have a nickname.</string> - </property> - <property name="text"> - <string>Show nicknames only</string> + <string>String to separate a channel name from its parent's.</string> </property> </widget> </item> @@ -756,50 +722,129 @@ </layout> </widget> </item> - <item row="6" column="0"> - <spacer name="verticalSpacer_2"> - <property name="orientation"> - <enum>Qt::Vertical</enum> - </property> - <property name="sizeHint" stdset="0"> - <size> - <width>20</width> - <height>40</height> - </size> - </property> - </spacer> - </item> - <item row="6" column="1"> - <spacer name="horizontalSpacer"> - <property name="orientation"> - <enum>Qt::Horizontal</enum> - </property> - <property name="sizeHint" stdset="0"> - <size> - <width>40</width> - <height>20</height> - </size> + <item row="1" column="0" rowspan="2"> + <widget class="QGroupBox" name="qgbLookFeel"> + <property name="title"> + <string>Look and Feel</string> </property> - </spacer> + <layout class="QGridLayout" name="gridLayout"> + <item row="3" column="0" colspan="2"> + <widget class="MUComboBox" name="qcbLanguage"> + <property name="toolTip"> + <string>Language to use (requires restart)</string> + </property> + <property name="whatsThis"> + <string><b>This sets which language Mumble should use.</b><br />You have to restart Mumble to use the new language.</string> + </property> + </widget> + </item> + <item row="0" column="0"> + <widget class="QLabel" name="qliTheme"> + <property name="text"> + <string>Theme</string> + </property> + </widget> + </item> + <item row="1" column="0" colspan="2"> + <widget class="MUComboBox" name="qcbTheme"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="toolTip"> + <string>Theme to use to style the user interface</string> + </property> + <property name="whatsThis"> + <string><b>Configures which theme the Mumble user interface should be styled with</b><br />Mumble will pick up themes from certain directories and display them in this list. The one you select will be used to customize the visual appearance of Mumble. This includes colors, icons and more.</string> + </property> + </widget> + </item> + <item row="5" column="0" colspan="2"> + <widget class="QCheckBox" name="qcbShowTransmitModeComboBox"> + <property name="text"> + <string>Show transmit mode dropdown in toolbar</string> + </property> + </widget> + </item> + <item row="4" column="0" colspan="2"> + <widget class="QCheckBox" name="qcbHighContrast"> + <property name="toolTip"> + <string>Apply some high contrast optimizations for visually impaired users</string> + </property> + <property name="text"> + <string>Optimize for high contrast</string> + </property> + </widget> + </item> + <item row="6" column="0"> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="qliLanguage"> + <property name="text"> + <string>Language</string> + </property> + <property name="buddy"> + <cstring>qcbLanguage</cstring> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QLabel" name="qlThemesDirectory"> + <property name="text"> + <string/> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + </layout> + </widget> </item> - <item row="5" column="1"> - <widget class="QGroupBox" name="qgbChannelHierarchyString"> + <item row="3" column="0"> + <widget class="QGroupBox" name="qgbSearch"> <property name="title"> - <string>Channel Hierarchy String</string> + <string>Search</string> </property> - <layout class="QFormLayout" name="formLayout"> + <layout class="QGridLayout" name="gridLayout_6"> + <item row="0" column="1"> + <widget class="QComboBox" name="qcbSearchUserAction"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="toolTip"> + <string>The action to perform when a user is activated (via double-click or enter) in the search dialog.</string> + </property> + </widget> + </item> <item row="0" column="0"> - <widget class="QLabel" name="qlChannelSeparator"> + <widget class="QLabel" name="qlSearchUserAction"> <property name="toolTip"> - <string>String to separate a channel name from its parent's.</string> + <string>The action to perform when a user is activated (via double-click or enter) in the search dialog.</string> </property> <property name="text"> - <string>Channel separator</string> + <string>Action (User):</string> </property> </widget> </item> - <item row="0" column="1"> - <widget class="QLineEdit" name="qleChannelSeparator"> + <item row="1" column="1"> + <widget class="QComboBox" name="qcbSearchChannelAction"> <property name="sizePolicy"> <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> <horstretch>0</horstretch> @@ -807,10 +852,33 @@ </sizepolicy> </property> <property name="toolTip"> - <string>String to separate a channel name from its parent's.</string> + <string>The action to perform when a channel is activated (via double-click or enter) in the search dialog.</string> </property> </widget> </item> + <item row="1" column="0"> + <widget class="QLabel" name="qlSearchChannelAction"> + <property name="toolTip"> + <string>The action to perform when a channel is activated (via double-click or enter) in the search dialog.</string> + </property> + <property name="text"> + <string>Action (Channel):</string> + </property> + </widget> + </item> + <item row="2" column="0"> + <spacer name="verticalSpacer_3"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> </layout> </widget> </item> diff --git a/src/mumble/MainWindow.cpp b/src/mumble/MainWindow.cpp index 306419d57..d4cbbb8c6 100644 --- a/src/mumble/MainWindow.cpp +++ b/src/mumble/MainWindow.cpp @@ -35,9 +35,11 @@ #include "Markdown.h" #include "PTTButtonWidget.h" #include "PluginManager.h" +#include "QtWidgetUtils.h" #include "RichTextEditor.h" #include "SSLCipherInfo.h" #include "Screen.h" +#include "SearchDialog.h" #include "ServerHandler.h" #include "ServerInformation.h" #include "Settings.h" @@ -288,6 +290,11 @@ void MainWindow::createActions() { gsToggleTalkingUI->setObjectName(QLatin1String("gsToggleTalkingUI")); gsToggleTalkingUI->qsWhatsThis = tr("Toggles the visibility of the TalkingUI.", "Global Shortcut"); + gsToggleSearch = new GlobalShortcut(this, idx++, tr("Toggle search dialog", "Global Shortcut")); + gsToggleSearch->setObjectName(QLatin1String("gsToggleSearch")); + gsToggleSearch->qsWhatsThis = + tr("This will open or close the search dialog depending on whether it is currently opened already"); + #ifndef Q_OS_MAC qstiIcon->show(); #endif @@ -528,6 +535,11 @@ void MainWindow::closeEvent(QCloseEvent *e) { Global::get().s.qpTalkingUI_Position = Global::get().talkingUI->pos(); } + if (m_searchDialog) { + // Save position of search dialog + Global::get().s.searchDialogPosition = { m_searchDialog->x(), m_searchDialog->y() }; + } + if (qwPTTButtonWidget) { qwPTTButtonWidget->close(); qwPTTButtonWidget->deleteLater(); @@ -913,6 +925,30 @@ void MainWindow::setTransmissionMode(Settings::AudioTransmit mode) { } } +void MainWindow::on_qaSearch_triggered() { + toggleSearchDialogVisibility(); +} + +void MainWindow::toggleSearchDialogVisibility() { + if (!m_searchDialog) { + m_searchDialog = new Search::SearchDialog(this); + + QPoint position = Global::get().s.searchDialogPosition; + + if (position == Settings::UNSPECIFIED_POSITION) { + // Get MainWindow's position on screen + position = mapToGlobal(QPoint(0, 0)); + } + + if (Mumble::QtUtils::positionIsOnScreen(position)) { + // Move the search dialog to the same origin as the MainWindow is + m_searchDialog->move(position); + } + } + + m_searchDialog->setVisible(!m_searchDialog->isVisible()); +} + static void recreateServerHandler() { // New server connection, so the sync has not happened yet ChannelListener::setInitialServerSyncDone(false); @@ -3066,6 +3102,14 @@ void MainWindow::on_gsToggleTalkingUI_triggered(bool down, QVariant) { } } +void MainWindow::on_gsToggleSearch_triggered(bool down, QVariant) { + if (!down) { + return; + } + + toggleSearchDialogVisibility(); +} + void MainWindow::whisperReleased(QVariant scdata) { if (Global::get().iPushToTalk <= 0) return; diff --git a/src/mumble/MainWindow.h b/src/mumble/MainWindow.h index 9cee1379d..67b356f1d 100644 --- a/src/mumble/MainWindow.h +++ b/src/mumble/MainWindow.h @@ -37,6 +37,9 @@ class Channel; class UserInformation; class VoiceRecorderDialog; class PTTButtonWidget; +namespace Search { +class SearchDialog; +}; struct ShortcutTarget; @@ -85,6 +88,8 @@ public: *gsTransmitModeContinuous, *gsTransmitModeVAD; GlobalShortcut *gsSendTextMessage, *gsSendClipboardTextMessage; GlobalShortcut *gsToggleTalkingUI; + GlobalShortcut *gsToggleSearch; + DockTitleBar *dtbLogDockTitle, *dtbChatDockTitle; ACLEditor *aclEdit; @@ -162,6 +167,8 @@ protected: QAction *qaTransmitMode; QAction *qaTransmitModeSeparator; + Search::SearchDialog *m_searchDialog = nullptr; + void createActions(); void setupGui(); void updateWindowTitle(); @@ -281,6 +288,7 @@ public slots: void on_gsSendTextMessage_triggered(bool, QVariant); void on_gsSendClipboardTextMessage_triggered(bool, QVariant); void on_gsToggleTalkingUI_triggered(bool, QVariant); + void on_gsToggleSearch_triggered(bool, QVariant); void on_Reconnect_timeout(); void on_Icon_activated(QSystemTrayIcon::ActivationReason); void on_qaTalkingUIToggle_triggered(); @@ -323,6 +331,9 @@ public slots: /// /// @param deaf Whether to deafen the user void setAudioDeaf(bool deaf); + // Callback the search action being triggered + void on_qaSearch_triggered(); + void toggleSearchDialogVisibility(); signals: /// Signal emitted when the server and the client have finished /// synchronizing (after a new connection). diff --git a/src/mumble/MainWindow.ui b/src/mumble/MainWindow.ui index 16df83c06..782a0383a 100644 --- a/src/mumble/MainWindow.ui +++ b/src/mumble/MainWindow.ui @@ -6,7 +6,7 @@ <rect> <x>0</x> <y>0</y> - <width>671</width> + <width>735</width> <height>435</height> </rect> </property> @@ -47,7 +47,7 @@ <rect> <x>0</x> <y>0</y> - <width>671</width> + <width>735</width> <height>32</height> </rect> </property> @@ -185,6 +185,8 @@ <addaction name="qaConfigDialog"/> <addaction name="separator"/> <addaction name="qaFilterToggle"/> + <addaction name="separator"/> + <addaction name="qaSearch"/> </widget> <action name="qaQuit"> <property name="text"> @@ -980,6 +982,21 @@ the channel's context menu.</string> <string>Silently disables Text-To-Speech for all text messages from the user.</string> </property> </action> + <action name="qaSearch"> + <property name="icon"> + <iconset> + <normaloff>skin:magnifier.svg</normaloff>skin:magnifier.svg</iconset> + </property> + <property name="text"> + <string>Search</string> + </property> + <property name="toolTip"> + <string>Search for a user or channel (Ctrl+F)</string> + </property> + <property name="shortcut"> + <string>Ctrl+F</string> + </property> + </action> </widget> <customwidgets> <customwidget> diff --git a/src/mumble/Messages.cpp b/src/mumble/Messages.cpp index 2fa5f4d8f..2f8948397 100644 --- a/src/mumble/Messages.cpp +++ b/src/mumble/Messages.cpp @@ -873,11 +873,16 @@ void MainWindow::msgUserRemove(const MumbleProto::UserRemove &msg) { Global::get().l->log(Log::UserLeave, tr("%1 disconnected.").arg(Log::formatClientUser(pDst, Log::Source))); } } - if (pDst != pSelf) - pmModel->removeUser(pDst); QMetaObject::invokeMethod(Global::get().talkingUI, "on_clientDisconnected", Qt::QueuedConnection, Q_ARG(unsigned int, pDst->uiSession)); + if (Global::get().mw->m_searchDialog) { + QMetaObject::invokeMethod(Global::get().mw->m_searchDialog, "on_clientDisconnected", Qt::QueuedConnection, + Q_ARG(unsigned int, pDst->uiSession)); + } + + if (pDst != pSelf) + pmModel->removeUser(pDst); } /// This message is being received when the server informs the local client about channel properties (either during @@ -998,6 +1003,12 @@ void MainWindow::msgChannelRemove(const MumbleProto::ChannelRemove &msg) { Global::get().db->setChannelFiltered(sh->qbaDigest, c->iId, false); c->bFiltered = false; } + + if (Global::get().mw->m_searchDialog) { + QMetaObject::invokeMethod(Global::get().mw->m_searchDialog, "on_channelRemoved", Qt::QueuedConnection, + Q_ARG(int, c->iId)); + } + if (!pmModel->removeChannel(c, true)) { Global::get().l->log(Log::CriticalError, tr("Protocol violation. Server sent remove for occupied channel.")); diff --git a/src/mumble/QtWidgetUtils.cpp b/src/mumble/QtWidgetUtils.cpp index 1e2bbff59..9f5fbcc21 100644 --- a/src/mumble/QtWidgetUtils.cpp +++ b/src/mumble/QtWidgetUtils.cpp @@ -5,8 +5,11 @@ #include "QtWidgetUtils.h" +#include <QFontMetrics> #include <QGuiApplication> #include <QScreen> +#include <QTextCursor> +#include <QTextDocument> namespace Mumble { namespace QtUtils { @@ -27,5 +30,28 @@ namespace QtUtils { bool positionIsOnScreen(QPoint point) { return screenAt(point) != nullptr; } + void elideText(QTextDocument &doc, uint32_t width) { + if (doc.size().width() > width) { + // Elide text + QTextCursor cursor(&doc); + cursor.movePosition(QTextCursor::End); + + const QString elidedPostfix = "..."; + QFontMetrics metric(doc.defaultFont()); +#if QT_VERSION >= QT_VERSION_CHECK(5, 11, 0) + uint32_t postfixWidth = metric.horizontalAdvance(elidedPostfix); +#else + uint32_t postfixWidth = metric.width(elidedPostfix); +#endif + + while (doc.size().width() > std::max(width - postfixWidth, static_cast< uint32_t >(0))) { + cursor.deletePreviousChar(); + doc.adjustSize(); + } + + cursor.insertText(elidedPostfix); + } + } + }; // namespace QtUtils }; // namespace Mumble diff --git a/src/mumble/QtWidgetUtils.h b/src/mumble/QtWidgetUtils.h index 14b82a962..79e6bc5e9 100644 --- a/src/mumble/QtWidgetUtils.h +++ b/src/mumble/QtWidgetUtils.h @@ -9,6 +9,7 @@ #include <QPoint> class QScreen; +class QTextDocument; namespace Mumble { namespace QtUtils { @@ -23,6 +24,8 @@ namespace QtUtils { */ bool positionIsOnScreen(QPoint position); + void elideText(QTextDocument &doc, uint32_t width); + }; // namespace QtUtils }; // namespace Mumble diff --git a/src/mumble/SearchDialog.cpp b/src/mumble/SearchDialog.cpp new file mode 100644 index 000000000..dbd2417fa --- /dev/null +++ b/src/mumble/SearchDialog.cpp @@ -0,0 +1,594 @@ +// Copyright 2021 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 "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 typeColumn = 0; + constexpr int matchColumn = 1; + + if (m_result.type == SearchType::User) { + QIcon userIcon = QIcon(QLatin1String("skin:talking_off.svg")); + setIcon(typeColumn, 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(matchColumn, Qt::DisplayRole, std::move(matchText)); + setData(matchColumn, SearchDialogItemDelegate::CHANNEL_TREE_ROLE, m_result.channelHierarchy); + + setTextAlignment(matchColumn, 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 nameColumn = 0; + + setText(nameColumn, chan->qsName); + + setTextAlignment(nameColumn, Qt::AlignLeft | Qt::AlignVCenter); + } + + int getChannelID() const { return m_chanID; } + +private: + 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, 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() { + // Togle 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); + } + } else { + const Channel *channel = Channel::get(static_cast< int >(item.getID())); + + if (channel) { + // Only try to select the channel if it still exists + Global::get().mw->pmModel->setSelectedChannel(channel->iId); + } + } +} + +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< 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(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 (event->matches(QKeySequence::Cancel)) { + 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(), 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 diff --git a/src/mumble/SearchDialog.h b/src/mumble/SearchDialog.h new file mode 100644 index 000000000..b5d43ef48 --- /dev/null +++ b/src/mumble/SearchDialog.h @@ -0,0 +1,147 @@ +// Copyright 2021 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>. + +#ifndef MUMBLE_MUMBLE_SEARCHDIALOG_H_ +#define MUMBLE_MUMBLE_SEARCHDIALOG_H_ + +#include "MultiStyleWidgetWrapper.h" + +#include <QString> + +#include <map> +#include <memory> +#include <unordered_set> + +#include "ui_SearchDialog.h" + +class QWidget; +class QTreeWidgetItem; +class QKeyEvent; +class QContextMenuEvent; +class QHideEvent; +class QShowEvent; + +class SearchDialogItemDelegate; + +namespace Search { + +/** + * The type of a search result + */ +enum class SearchType { User, Channel }; + +/** + * This struct represents a search result and contains some metainformation + * on it. + */ +struct SearchResult { + int32_t begin = -1; + int32_t length = -1; + SearchType type; + QString fullText; + QString channelHierarchy; + + operator bool() { + // A search result is only valid if it has start and end + return begin >= 0 && length > 0; + } +}; + +class SearchResultItem; +class ChannelItem; + +/** + * This comparator is used to sort search results + */ +struct SearchResultSortComparator { + bool operator()(const SearchResult &lhs, const SearchResult &rhs) const { + // Prioritize longer matches (only relevant if match is obtained via RegEx) + if (lhs.length != rhs.length) { + return lhs.length > rhs.length; + } + + if (lhs.begin != rhs.begin) { + // The closer to the start a match is found, the higher its priority + return lhs.begin < rhs.begin; + } + + if (lhs.fullText != rhs.fullText) { + return lhs.fullText.compare(rhs.fullText) < 0; + } + + if (lhs.channelHierarchy != rhs.channelHierarchy) { + return lhs.channelHierarchy.compare(rhs.channelHierarchy) < 0; + } + + // Order users before channels + return lhs.type == SearchType::User; + } +}; + +using SearchResultMap = std::map< SearchResult, unsigned int, SearchResultSortComparator >; + +/** + * The search result class is the one that pops up when triggering the search functionality + */ +class SearchDialog : public QWidget, private Ui::SearchDialog { + Q_OBJECT; + Q_DISABLE_COPY(SearchDialog); + +public: + SearchDialog(QWidget *parent = nullptr); + ~SearchDialog() = default; + + /** + * Possible actions that can be performed on a selected search + * result, if it is a user. + */ + enum class UserAction { NONE, JOIN }; + /** + * Possible actions that can be performed on a selected search + * result, if it is a channel. + */ + enum class ChannelAction { NONE, JOIN }; + + static QString toString(UserAction action); + static QString toString(ChannelAction action); + +public slots: + void on_toggleOptions_clicked(); + void on_searchField_returnPressed(); + void on_searchField_textChanged(const QString &text); + void on_userOption_clicked(bool checked); + void on_channelOption_clicked(bool checked); + void on_caseSensitiveOption_clicked(bool checked); + void on_regexOption_clicked(bool checked); + void on_searchResultTree_currentItemChanged(QTreeWidgetItem *current, QTreeWidgetItem *previous); + void on_searchResultTree_itemActivated(QTreeWidgetItem *item, int column); + void searchAgain(); + void clearSearchResults(); + void search(const QString &searchTerm); + void on_serverConnectionSynchronized(); + void on_serverDisconnected(); + void on_clientDisconnected(unsigned int userSession); + void on_channelRemoved(int channelID); + +private: + MultiStyleWidgetWrapper m_searchFieldStyleWrapper; + std::unordered_set< void * > m_relayedKeyEvents; + std::unique_ptr< SearchDialogItemDelegate > m_itemDelegate; + + void setSearchResults(const SearchResultMap &results); + void hideEvent(QHideEvent *event) override; + void showEvent(QShowEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void contextMenuEvent(QContextMenuEvent *event) override; + void performDefaultAction(const SearchResultItem &item); + bool removeSearchResult(unsigned int id, bool isUser); +}; + +}; // namespace Search + +Q_DECLARE_METATYPE(Search::SearchDialog::UserAction); +Q_DECLARE_METATYPE(Search::SearchDialog::ChannelAction); + +#endif // MUMBLE_MUMBLE_SEARCHDIALOG_H_ diff --git a/src/mumble/SearchDialog.ui b/src/mumble/SearchDialog.ui new file mode 100644 index 000000000..7a25a0541 --- /dev/null +++ b/src/mumble/SearchDialog.ui @@ -0,0 +1,165 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>SearchDialog</class> + <widget class="QWidget" name="SearchDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>421</width> + <height>283</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>2</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="windowTitle"> + <string>Search</string> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="0" column="0"> + <widget class="QLineEdit" name="searchField"> + <property name="inputMask"> + <string/> + </property> + <property name="placeholderText"> + <string>Enter search String...</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QPushButton" name="toggleOptions"> + <property name="accessibleName"> + <string>Options</string> + </property> + <property name="text"> + <string/> + </property> + <property name="icon"> + <iconset> + <normaloff>.</normaloff>.</iconset> + </property> + <property name="checkable"> + <bool>false</bool> + </property> + <property name="autoDefault"> + <bool>false</bool> + </property> + <property name="flat"> + <bool>false</bool> + </property> + </widget> + </item> + <item row="2" column="0" colspan="2"> + <widget class="SearchDialogTree" name="searchResultTree"> + <column> + <property name="text"> + <string/> + </property> + </column> + <column> + <property name="text"> + <string/> + </property> + </column> + </widget> + </item> + <item row="1" column="0" colspan="2"> + <widget class="QWidget" name="searchOptionBox" native="true"> + <layout class="QGridLayout" name="gridLayout_2"> + <item row="1" column="1"> + <widget class="QCheckBox" name="userOption"> + <property name="toolTip"> + <string>Whether to search for users</string> + </property> + <property name="text"> + <string>&Users</string> + </property> + <property name="checked"> + <bool>false</bool> + </property> + </widget> + </item> + <item row="3" column="1"> + <widget class="QCheckBox" name="caseSensitiveOption"> + <property name="toolTip"> + <string>Whether the search should be performed case-sensitively</string> + </property> + <property name="text"> + <string>Case-&sensitive</string> + </property> + </widget> + </item> + <item row="3" column="0"> + <widget class="QLabel" name="searchOptionsLabel"> + <property name="text"> + <string>Options:</string> + </property> + </widget> + </item> + <item row="3" column="3"> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item row="3" column="2"> + <widget class="QCheckBox" name="regexOption"> + <property name="toolTip"> + <string>Whether the search string should be interpreted as a regular expression</string> + </property> + <property name="text"> + <string>&RegEx</string> + </property> + </widget> + </item> + <item row="1" column="2"> + <widget class="QCheckBox" name="channelOption"> + <property name="toolTip"> + <string>Whether to search for clients</string> + </property> + <property name="text"> + <string>&Channels</string> + </property> + <property name="checked"> + <bool>false</bool> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="searchForLabel"> + <property name="text"> + <string>Search for:</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + </layout> + </widget> + <customwidgets> + <customwidget> + <class>SearchDialogTree</class> + <extends>QTreeWidget</extends> + <header>SearchDialogTree.h</header> + </customwidget> + </customwidgets> + <tabstops> + <tabstop>searchField</tabstop> + <tabstop>toggleOptions</tabstop> + <tabstop>searchResultTree</tabstop> + </tabstops> + <resources/> + <connections/> +</ui> diff --git a/src/mumble/Settings.cpp b/src/mumble/Settings.cpp index d38ca70bc..041b59a4b 100644 --- a/src/mumble/Settings.cpp +++ b/src/mumble/Settings.cpp @@ -293,6 +293,8 @@ Settings::Settings() { qRegisterMetaType< QVariant >("QVariant"); qRegisterMetaType< PluginSetting >("PluginSetting"); qRegisterMetaTypeStreamOperators< PluginSetting >("PluginSetting"); + qRegisterMetaType< Search::SearchDialog::UserAction >("SearchDialog::UserAction"); + qRegisterMetaType< Search::SearchDialog::ChannelAction >("SearchDialog::ChannelAction"); atTransmit = VAD; bTransmitPosition = false; @@ -554,6 +556,16 @@ Settings::Settings() { qmMessages[Log::UserRenamed] = Settings::LogConsole; qmMessages[Log::PluginMessage] = Settings::LogConsole; + // Default search options + searchForUsers = true; + searchForChannels = true; + searchCaseSensitive = false; + searchAsRegex = false; + searchOptionsShown = false; + searchUserAction = Search::SearchDialog::UserAction::JOIN; + searchChannelAction = Search::SearchDialog::ChannelAction::JOIN; + searchDialogPosition = Settings::UNSPECIFIED_POSITION; + // Default theme themeName = QLatin1String("Mumble"); themeStyleName = QLatin1String("Lite"); @@ -979,6 +991,16 @@ void Settings::load(QSettings *settings_ptr) { LOAD(bEnableXboxInput, "shortcut/windows/xbox/enable"); LOAD(bEnableUIAccess, "shortcut/windows/uiaccess/enable"); + // Search options + LOAD(searchForUsers, "search/search_for_users"); + LOAD(searchForChannels, "search/search_for_channels"); + LOAD(searchCaseSensitive, "search/search_case_sensitive"); + LOAD(searchAsRegex, "search/search_as_regex"); + LOAD(searchOptionsShown, "search/search_options_shown"); + LOADFLAG(searchUserAction, "search/search_user_action"); + LOADFLAG(searchChannelAction, "search/search_channel_action"); + LOAD(searchDialogPosition, "search/search_dialog_position"); + int nshorts = settings_ptr->beginReadArray(QLatin1String("shortcuts")); for (int i = 0; i < nshorts; i++) { settings_ptr->setArrayIndex(i); @@ -1375,6 +1397,16 @@ void Settings::save() { SAVE(bEnableXboxInput, "shortcut/windows/xbox/enable"); SAVE(bEnableUIAccess, "shortcut/windows/uiaccess/enable"); + // Search options + SAVE(searchForUsers, "search/search_for_users"); + SAVE(searchForChannels, "search/search_for_channels"); + SAVE(searchCaseSensitive, "search/search_case_sensitive"); + SAVE(searchAsRegex, "search/search_as_regex"); + SAVE(searchOptionsShown, "search/search_options_shown"); + SAVEFLAG(searchUserAction, "search/search_user_action"); + SAVEFLAG(searchChannelAction, "search/search_channel_action"); + SAVE(searchDialogPosition, "search/search_dialog_position"); + settings_ptr->beginWriteArray(QLatin1String("shortcuts")); int idx = 0; foreach (const Shortcut &s, qlShortcuts) { diff --git a/src/mumble/Settings.h b/src/mumble/Settings.h index 165f8b7c9..d312b9c6f 100644 --- a/src/mumble/Settings.h +++ b/src/mumble/Settings.h @@ -18,6 +18,7 @@ #include <QtNetwork/QSslKey> #include "EchoCancelOption.h" +#include "SearchDialog.h" // Global helper classes to spread variables around across threads // especially helpful to initialize things like the stored @@ -358,6 +359,16 @@ struct Settings { QByteArray qbaConnectDialogHeader, qbaConnectDialogGeometry; bool bShowContextMenuInMenuBar; + // Search settings + bool searchForUsers; + bool searchForChannels; + bool searchCaseSensitive; + bool searchAsRegex; + bool searchOptionsShown; + Search::SearchDialog::UserAction searchUserAction; + Search::SearchDialog::ChannelAction searchChannelAction; + QPoint searchDialogPosition; + QString qsUsername; QString qsLastServer; ServerShow ssFilter; diff --git a/src/mumble/widgets/MultiStyleWidgetWrapper.h b/src/mumble/widgets/MultiStyleWidgetWrapper.h index f158e2031..6cb983a0a 100644 --- a/src/mumble/widgets/MultiStyleWidgetWrapper.h +++ b/src/mumble/widgets/MultiStyleWidgetWrapper.h @@ -29,7 +29,7 @@ public: QWidget *operator->(); - MultiStyleWidgetWrapper(QWidget *widget); + MultiStyleWidgetWrapper(QWidget *widget = nullptr); protected: static const uint32_t UNSET_FONTSIZE; diff --git a/src/mumble/widgets/RichTextItemDelegate.cpp b/src/mumble/widgets/RichTextItemDelegate.cpp new file mode 100644 index 000000000..6c30f9b8f --- /dev/null +++ b/src/mumble/widgets/RichTextItemDelegate.cpp @@ -0,0 +1,88 @@ +// Copyright 2021 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 "RichTextItemDelegate.h" + +#include "QtWidgetUtils.h" + +#include <QAbstractTextDocumentLayout> +#include <QApplication> +#include <QModelIndex> +#include <QPainter> +#include <QSizeF> +#include <QStyleOptionViewItem> +#include <QTextDocument> +#include <QVariant> + +#include <cmath> + +void RichTextItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &inOption, + const QModelIndex &index) const { + QStyleOptionViewItem option = inOption; + initStyleOption(&option, index); + + if (option.text.isEmpty()) { + // This is nothing this function is supposed to handle + QStyledItemDelegate::paint(painter, inOption, index); + + return; + } + + QStyle *style = option.widget ? option.widget->style() : QApplication::style(); + + QTextOption textOption; + textOption.setWrapMode(option.features & QStyleOptionViewItem::WrapText ? QTextOption::WordWrap + : QTextOption::ManualWrap); + textOption.setTextDirection(option.direction); + + QTextDocument doc; + doc.setDefaultTextOption(textOption); + doc.setHtml(option.text); + doc.setDefaultFont(option.font); + doc.setDocumentMargin(1); + doc.setTextWidth(option.rect.width()); + doc.adjustSize(); + + if (doc.size().width() > option.rect.width()) { + Mumble::QtUtils::elideText(doc, option.rect.width()); + } + + // Painting item without text (this takes care of painting e.g. the highlighted for selected + // or hovered over items in an ItemView) + option.text = QString(); + style->drawControl(QStyle::CE_ItemViewItem, &option, painter, inOption.widget); + + // Figure out where to render the text in order to follow the requested alignment + QRect textRect = style->subElementRect(QStyle::SE_ItemViewItemText, &option); + QSize documentSize(doc.size().width(), doc.size().height()); // Convert QSizeF to QSize + QRect layoutRect = QStyle::alignedRect(Qt::LayoutDirectionAuto, option.displayAlignment, documentSize, textRect); + + painter->save(); + + // Translate the painter to the origin of the layout rectangle in order for the text to be + // rendered at the correct position + painter->translate(layoutRect.topLeft()); + doc.drawContents(painter, textRect.translated(-textRect.topLeft())); + + painter->restore(); +} + +QSize RichTextItemDelegate::sizeHint(const QStyleOptionViewItem &inOption, const QModelIndex &index) const { + QStyleOptionViewItem option = inOption; + initStyleOption(&option, index); + + if (option.text.isEmpty()) { + // This is nothing this function is supposed to handle + return QStyledItemDelegate::sizeHint(inOption, index); + } + + QTextDocument doc; + doc.setHtml(option.text); + doc.setTextWidth(option.rect.width()); + doc.setDefaultFont(option.font); + doc.setDocumentMargin(1); + + return QSize(doc.idealWidth(), doc.size().height()); +} diff --git a/src/mumble/widgets/RichTextItemDelegate.h b/src/mumble/widgets/RichTextItemDelegate.h new file mode 100644 index 000000000..7c4a29cf1 --- /dev/null +++ b/src/mumble/widgets/RichTextItemDelegate.h @@ -0,0 +1,32 @@ +// Copyright 2021 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>. + +#ifndef MUMBLE_MUMBLE_WIDGETS_RICHTEXTITEMDELEGATE_H_ +#define MUMBLE_MUMBLE_WIDGETS_RICHTEXTITEMDELEGATE_H_ + +#include <QSize> +#include <QStyledItemDelegate> + +class QPainter; +class QStyledItemDelegate; +class QModelIndex; + +/** + * An ItemDelegate that is capable of rendering rich text. It can be used in order to bring + * rich text support to the *View and *Widget objects (e.g. TreeWidget). + */ +class RichTextItemDelegate : public QStyledItemDelegate { + Q_OBJECT; + Q_DISABLE_COPY(RichTextItemDelegate); + +public: + // inherit contructors + using QStyledItemDelegate::QStyledItemDelegate; + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; +}; + +#endif // MUMBLE_MUMBLE_WIDGETS_RICHTEXTITEMDELEGATE_H_ diff --git a/src/mumble/widgets/SearchDialogItemDelegate.cpp b/src/mumble/widgets/SearchDialogItemDelegate.cpp new file mode 100644 index 000000000..fa1a6fc3a --- /dev/null +++ b/src/mumble/widgets/SearchDialogItemDelegate.cpp @@ -0,0 +1,150 @@ +// Copyright 2021 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 "SearchDialogItemDelegate.h" + +#include "QtWidgetUtils.h" + +#include <QApplication> +#include <QHeaderView> +#include <QPainter> +#include <QScrollBar> +#include <QTextDocument> +#include <QTextOption> +#include <QTreeWidget> + +SearchDialogItemDelegate::SearchDialogItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { +} + +void setupDocuments(QTextDocument &resultDoc, QTextDocument &channelTreeDoc, const QStyleOptionViewItem &option, + const uint8_t channelTreePadding) { + QTextOption resultTextOption; + resultTextOption.setWrapMode(QTextOption::NoWrap); + resultTextOption.setTextDirection(option.direction); + resultTextOption.setAlignment(Qt::AlignLeft); + + QTextOption channelTreeTextOption; + channelTreeTextOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + channelTreeTextOption.setTextDirection(option.direction); + channelTreeTextOption.setAlignment(Qt::AlignLeft); + + // Render the channel tree with a smaller font + QFont channelTreeFont(option.font); + if (channelTreeFont.pointSize() >= 0) { + channelTreeFont.setPointSize(std::max(channelTreeFont.pointSize() * 0.8f, 1.0f)); + } else { + // Font size is specified in pixels + channelTreeFont.setPixelSize(std::max(channelTreeFont.pixelSize() * 0.8f, 1.0f)); + } + + resultDoc.setDefaultTextOption(resultTextOption); + resultDoc.setDefaultFont(option.font); + resultDoc.setDocumentMargin(1); + resultDoc.setTextWidth(option.rect.width()); + + channelTreeDoc.setDefaultTextOption(channelTreeTextOption); + channelTreeDoc.setDefaultFont(channelTreeFont); + channelTreeDoc.setDocumentMargin(1); + channelTreeDoc.setTextWidth(std::max(option.rect.width() - channelTreePadding, 0)); +} + +void SearchDialogItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &inOption, + const QModelIndex &index) const { + QStyleOptionViewItem option = inOption; + initStyleOption(&option, index); + + QStyle *style = option.widget ? option.widget->style() : QApplication::style(); + + // Painting item without text (this takes care of painting e.g. the highlighted for selected + // or hovered over items in an ItemView) + option.text = QString(); + style->drawControl(QStyle::CE_ItemViewItem, &option, painter, inOption.widget); + + + const QString resultText = index.data(Qt::DisplayRole).toString(); + const QString channelTreeText = index.data(CHANNEL_TREE_ROLE).toString(); + + QTextDocument resultDoc; + QTextDocument channelTreeDoc; + + setupDocuments(resultDoc, channelTreeDoc, option, CHANNEL_TREE_LEFT_INDENT); + + resultDoc.setHtml(resultText); + resultDoc.adjustSize(); + + channelTreeDoc.setHtml(channelTreeText); + + const QRect textRect = style->subElementRect(QStyle::SE_ItemViewItemText, &option); + + if (resultDoc.size().width() > textRect.width()) { + Mumble::QtUtils::elideText(resultDoc, textRect.width()); + } + + // Figure out where to render the text in order to follow the requested alignment + QSize contentSize(std::max(resultDoc.size().width(), channelTreeDoc.size().width() + CHANNEL_TREE_LEFT_INDENT), + resultDoc.size().height() + channelTreeDoc.size().height() + BOTTOM_MARGIN); + + QRect contentLayoutRect = + QStyle::alignedRect(Qt::LayoutDirectionAuto, option.displayAlignment, contentSize, textRect); + + painter->save(); + + // Translate the painter to the origin of the layout rectangle in order for the text to be + // rendered at the correct position + QRect clipRect = textRect.translated(-textRect.topLeft()); + + painter->translate(contentLayoutRect.topLeft()); + resultDoc.drawContents(painter, clipRect); + painter->resetTransform(); + painter->translate(contentLayoutRect.topLeft()); + painter->translate(CHANNEL_TREE_LEFT_INDENT, resultDoc.size().height()); + channelTreeDoc.drawContents(painter, clipRect); + + painter->restore(); +} + +QSize SearchDialogItemDelegate::sizeHint(const QStyleOptionViewItem &inOption, const QModelIndex &index) const { + QStyleOptionViewItem option = inOption; + initStyleOption(&option, index); + + const QTreeWidget *widget = qobject_cast< const QTreeWidget * >(option.widget); + + const QSize size = widget->size(); + + // In the SearchDialog, we have 2 sections. The first one for the icon and the second (last!) one for the + // actual text. The latter is the one that we are interested in here. + const int textWidth = widget->header()->sectionSize(widget->header()->count() - 1); + + option.rect.setWidth(textWidth); + option.rect.setHeight(size.height()); + + const QString resultText = index.data(Qt::DisplayRole).toString(); + const QString channelTreeText = index.data(CHANNEL_TREE_ROLE).toString(); + + QTextDocument resultDoc; + QTextDocument channelTreeDoc; + + setupDocuments(resultDoc, channelTreeDoc, option, CHANNEL_TREE_LEFT_INDENT); + + resultDoc.setHtml(resultText); + resultDoc.adjustSize(); + + channelTreeDoc.setHtml(channelTreeText); + + const int hintWidth = + std::max(static_cast< int >( + std::max(resultDoc.size().width(), channelTreeDoc.size().width() + CHANNEL_TREE_LEFT_INDENT)), + size.width()); + const int hintHeight = resultDoc.size().height() + channelTreeDoc.size().height() + BOTTOM_MARGIN; + + return { hintWidth, hintHeight }; +} + +void SearchDialogItemDelegate::initStyleOption(QStyleOptionViewItem *option, const QModelIndex &index) const { + QStyledItemDelegate::initStyleOption(option, index); + + // Make sure icons are aligned top-left + option->decorationAlignment = Qt::AlignLeft | Qt::AlignTop; +} diff --git a/src/mumble/widgets/SearchDialogItemDelegate.h b/src/mumble/widgets/SearchDialogItemDelegate.h new file mode 100644 index 000000000..9a57b579f --- /dev/null +++ b/src/mumble/widgets/SearchDialogItemDelegate.h @@ -0,0 +1,36 @@ +// Copyright 2021 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>. + +#ifndef MUMBLE_MUMBLE_WIDGETS_SEARCHDIALOGITEMDELEGATE_H_ +#define MUMBLE_MUMBLE_WIDGETS_SEARCHDIALOGITEMDELEGATE_H_ + +#include <QSize> +#include <QStyledItemDelegate> + +class QPainter; +class QStyleOptionViewItem; +class QModelIndex; +class QObject; + +class SearchDialogItemDelegate : public QStyledItemDelegate { + Q_OBJECT; + Q_DISABLE_COPY(SearchDialogItemDelegate); + +public: + constexpr static int CHANNEL_TREE_ROLE = Qt::UserRole + 1; + constexpr static uint8_t BOTTOM_MARGIN = 0; + constexpr static uint8_t CHANNEL_TREE_LEFT_INDENT = 5; + + SearchDialogItemDelegate(QObject *parent = nullptr); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +protected: + void initStyleOption(QStyleOptionViewItem *option, const QModelIndex &index) const override; +}; + + +#endif // MUMBLE_MUMBLE_WIDGETS_SEARCHDIALOGITEMDELEGATE_H_ diff --git a/src/mumble/widgets/SearchDialogTree.cpp b/src/mumble/widgets/SearchDialogTree.cpp new file mode 100644 index 000000000..3c2796508 --- /dev/null +++ b/src/mumble/widgets/SearchDialogTree.cpp @@ -0,0 +1,19 @@ +// Copyright 2021 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 "SearchDialogTree.h" + +#include <QResizeEvent> + +void SearchDialogTree::resizeEvent(QResizeEvent *event) { + // We have to update the layout on every resize since we have wrapping text that is displayed in the + // different rows of this tree. That means that by changing its size we potentially change the way + // the text is being wrapped in these rows, causing their vertical space requirements to change. This + // makes it necessary to re-adjust to layout to these (potential) new requirements. + + QTreeWidget::resizeEvent(event); + + scheduleDelayedItemsLayout(); +} diff --git a/src/mumble/widgets/SearchDialogTree.h b/src/mumble/widgets/SearchDialogTree.h new file mode 100644 index 000000000..54d603dda --- /dev/null +++ b/src/mumble/widgets/SearchDialogTree.h @@ -0,0 +1,21 @@ +// Copyright 2021 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>. + +#ifndef MUMBLE_MUMBLE_WIDGETS_SEARCHDIALOGTREE_H_ +#define MUMBLE_MUMBLE_WIDGETS_SEARCHDIALOGTREE_H_ + +#include <QTreeWidget> + +class QResizeEvent; + +class SearchDialogTree : public QTreeWidget { +public: + using QTreeWidget::QTreeWidget; + +protected: + void resizeEvent(QResizeEvent *event) override; +}; + +#endif // MUMBLE_MUMBLE_WIDGETS_SEARCHDIALOGTREE_H_ diff --git a/themes/MumbleTheme.qrc b/themes/MumbleTheme.qrc index d7cc50cc1..d90d89969 100644 --- a/themes/MumbleTheme.qrc +++ b/themes/MumbleTheme.qrc @@ -34,6 +34,7 @@ <file alias="Lite.qss">Mumble/Lite.qss</file> <file alias="lock_locked.svg">Mumble/lock_locked.svg</file> <file alias="lock_unlocked.svg">Mumble/lock_unlocked.svg</file> + <file alias="magnifier.svg">Mumble/magnifier.svg</file> <file alias="mumble.ico">Mumble/mumble.ico</file> <file alias="mumble.osx.png">Mumble/mumble.osx.png</file> <file alias="mumble.png">Mumble/mumble.png</file> |