diff --git a/CMakeLists.txt b/CMakeLists.txt index e81dc64c5..ad050458e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1362,7 +1362,7 @@ set_target_properties (wsjtx_udp-static PROPERTIES ) target_compile_definitions (wsjtx_udp-static PUBLIC UDP_STATIC_DEFINE) #qt5_use_modules (wsjtx_udp Network) -qt5_use_modules (wsjtx_udp-static Network) +qt5_use_modules (wsjtx_udp-static Network Gui) generate_export_header (wsjtx_udp-static BASE_NAME udp) add_executable (udp_daemon UDPExamples/UDPDaemon.cpp UDPExamples/udp_daemon.rc ${WSJTX_ICON_FILE}) diff --git a/MessageClient.cpp b/MessageClient.cpp index 2b6d9e308..68d6f0fdc 100644 --- a/MessageClient.cpp +++ b/MessageClient.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include "NetworkMessage.hpp" @@ -218,6 +219,20 @@ void MessageClient::impl::parse_message (QByteArray const& msg) } break; + case NetworkMessage::HighlightCallsign: + { + QByteArray call; + QColor bg; // default invalid color + QColor fg; // default invalid color + bool last_only {false}; + in >> call >> bg >> fg >> last_only; + if (check_status (in) != Fail && call.size ()) + { + Q_EMIT self_->highlight_callsign (QString::fromUtf8 (call), bg, fg, last_only); + } + } + break; + default: // Ignore // diff --git a/MessageClient.hpp b/MessageClient.hpp index f0719ac55..be524fdb8 100644 --- a/MessageClient.hpp +++ b/MessageClient.hpp @@ -11,6 +11,7 @@ class QByteArray; class QHostAddress; +class QColor; // // MessageClient - Manage messages sent and replies received from a @@ -95,6 +96,10 @@ public: // message text Q_SIGNAL void free_text (QString const&, bool send); + // this signal is emitted if the server has sent a highlight + // callsign request for the specified call + Q_SIGNAL void highlight_callsign (QString const& callsign, QColor const& bg, QColor const& fg, bool last_only); + // this signal is emitted when network errors occur or if a host // lookup fails Q_SIGNAL void error (QString const&) const; diff --git a/MessageServer.cpp b/MessageServer.cpp index 724da4afc..b5a719e74 100644 --- a/MessageServer.cpp +++ b/MessageServer.cpp @@ -480,3 +480,16 @@ void MessageServer::location (QString const& id, QString const& loc) m_->send_message (out, message, iter.value ().sender_address_, (*iter).sender_port_); } } + +void MessageServer::highlight_callsign (QString const& id, QString const& callsign + , QColor const& bg, QColor const& fg, bool last_only) +{ + auto iter = m_->clients_.find (id); + if (iter != std::end (m_->clients_)) + { + QByteArray message; + NetworkMessage::Builder out {&message, NetworkMessage::HighlightCallsign, id, (*iter).negotiated_schema_number_}; + out << callsign.toUtf8 () << bg << fg << last_only; + m_->send_message (out, message, iter.value ().sender_address_, (*iter).sender_port_); + } +} diff --git a/MessageServer.hpp b/MessageServer.hpp index 77c976fa4..bb7501529 100644 --- a/MessageServer.hpp +++ b/MessageServer.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "udp_export.h" #include "Radio.hpp" @@ -62,6 +63,12 @@ public: // ask the client with identification 'id' to set the location provided Q_SLOT void location (QString const& id, QString const& location); + // ask the client with identification 'id' to highlight the callsign + // specified with the given colors + Q_SLOT void highlight_callsign (QString const& id, QString const& callsign + , QColor const& bg = QColor {}, QColor const& fg = QColor {} + , bool last_only = false); + // the following signals are emitted when a client broadcasts the // matching message Q_SIGNAL void client_opened (QString const& id, QString const& version, QString const& revision); diff --git a/NetworkMessage.hpp b/NetworkMessage.hpp index 479575a32..acf64fb32 100644 --- a/NetworkMessage.hpp +++ b/NetworkMessage.hpp @@ -350,7 +350,7 @@ * * Logged ADIF Out 12 quint32 * Id (unique key) utf8 - * ADIF text ASCII (serialized like utf8) + * ADIF text utf8 * * The logged ADIF message is sent to the server(s) when the * WSJT-X user accepts the "Log QSO" dialog by clicking the "OK" @@ -368,6 +368,27 @@ * Note that receiving applications can treat the whole message * as a valid ADIF file with one record without special parsing. * + * + * Highlight Callsign In 13 quint32 + * Id (unique key) utf8 + * Callsign utf8 + * Background Color QColor + * Foreground Color QColor + * Highlight last bool + * + * The server may send this message at any time. The message + * specifies the background and foreground color that will be + * used to highlight the specified callsign in the decoded + * messages printed in the Band Activity panel. The WSJT-X + * clients maintain a list of such instructions and apply them to + * all decoded messages in the band activity window. To clear + * highlighting send an invalid QColor value for either or both + * of the background and foreground fields. + * + * The "Highlight last" field allows the sender to request that + * the last instance only instead of all instances of the + * specified call be highlighted or have it's highlighting + * cleared. */ #include @@ -396,6 +417,7 @@ namespace NetworkMessage WSPRDecode, Location, LoggedADIF, + HighlightCallsign, maximum_message_type_ // ONLY add new message types // immediately before here }; diff --git a/UDPExamples/ClientWidget.cpp b/UDPExamples/ClientWidget.cpp index 001041cae..06b09ba4d 100644 --- a/UDPExamples/ClientWidget.cpp +++ b/UDPExamples/ClientWidget.cpp @@ -115,9 +115,10 @@ namespace ClientWidget::ClientWidget (QAbstractItemModel * decodes_model, QAbstractItemModel * beacons_model , QString const& id, QString const& version, QString const& revision - , QWidget * parent) + , QListWidget const * calls_of_interest, QWidget * parent) : QDockWidget {make_title (id, version, revision), parent} , id_ {id} + , calls_of_interest_ {calls_of_interest} , decodes_proxy_model_ {id_} , decodes_table_view_ {new QTableView} , beacons_table_view_ {new QTableView} @@ -216,11 +217,27 @@ ClientWidget::ClientWidget (QAbstractItemModel * decodes_model, QAbstractItemMod // setMinimumSize (QSize {550, 0}); setFeatures (DockWidgetMovable | DockWidgetFloatable); setAllowedAreas (Qt::BottomDockWidgetArea); + setFloating (true); // connect up table view signals connect (decodes_table_view_, &QTableView::doubleClicked, this, [this] (QModelIndex const& index) { Q_EMIT do_reply (decodes_proxy_model_.mapToSource (index), QApplication::keyboardModifiers () >> 24); }); + + // tell new client about calls of interest + for (int row = 0; row < calls_of_interest_->count (); ++row) + { + Q_EMIT highlight_callsign (id_, calls_of_interest_->item (row)->text (), QColor {Qt::blue}, QColor {Qt::yellow}); + } +} + +ClientWidget::~ClientWidget () +{ + for (int row = 0; row < calls_of_interest_->count (); ++row) + { + // tell client to forget calls of interest + Q_EMIT highlight_callsign (id_, calls_of_interest_->item (row)->text ()); + } } void ClientWidget::update_status (QString const& id, Frequency f, QString const& mode, QString const& dx_call diff --git a/UDPExamples/ClientWidget.hpp b/UDPExamples/ClientWidget.hpp index 0cec23e7a..29059bfa5 100644 --- a/UDPExamples/ClientWidget.hpp +++ b/UDPExamples/ClientWidget.hpp @@ -11,6 +11,7 @@ class QAbstractItemModel; class QModelIndex; +class QColor; using Frequency = MessageServer::Frequency; @@ -22,7 +23,8 @@ class ClientWidget public: explicit ClientWidget (QAbstractItemModel * decodes_model, QAbstractItemModel * beacons_model , QString const& id, QString const& version, QString const& revision - , QWidget * parent = nullptr); + , QListWidget const * calls_of_interest, QWidget * parent = nullptr); + ~ClientWidget (); bool fast_mode () const {return fast_mode_;} @@ -43,10 +45,14 @@ public: Q_SIGNAL void do_reply (QModelIndex const&, quint8 modifier); Q_SIGNAL void do_halt_tx (QString const& id, bool auto_only); Q_SIGNAL void do_free_text (QString const& id, QString const& text, bool); - Q_SIGNAL void location (QString const &id, QString const &text); + Q_SIGNAL void location (QString const& id, QString const& text); + Q_SIGNAL void highlight_callsign (QString const& id, QString const& call + , QColor const& bg = QColor {}, QColor const& fg = QColor {} + , bool last_only = false); private: QString id_; + QListWidget const * calls_of_interest_; class IdFilterModel final : public QSortFilterProxyModel { diff --git a/UDPExamples/MessageAggregatorMainWindow.cpp b/UDPExamples/MessageAggregatorMainWindow.cpp index 324c7d0e5..1d1dce5d6 100644 --- a/UDPExamples/MessageAggregatorMainWindow.cpp +++ b/UDPExamples/MessageAggregatorMainWindow.cpp @@ -36,6 +36,11 @@ MessageAggregatorMainWindow::MessageAggregatorMainWindow () , server_ {new MessageServer {this}} , multicast_group_line_edit_ {new QLineEdit} , log_table_view_ {new QTableView} + , add_call_of_interest_action_ {new QAction {tr ("&Add callsign"), this}} + , delete_call_of_interest_action_ {new QAction {tr ("&Delete callsign"), this}} + , last_call_of_interest_action_ {new QAction {tr ("&Highlight last only"), this}} + , call_of_interest_bg_colour_action_ {new QAction {tr ("&Background colour"), this}} + , call_of_interest_fg_colour_action_ {new QAction {tr ("&Foreground colour"), this}} { // logbook int column {0}; @@ -83,6 +88,91 @@ MessageAggregatorMainWindow::MessageAggregatorMainWindow () setDockOptions (AnimatedDocks | AllowNestedDocks | AllowTabbedDocks); setTabPosition (Qt::BottomDockWidgetArea, QTabWidget::North); + QDockWidget * calls_dock {new QDockWidget {tr ("Calls of Interest"), this}}; + calls_dock->setAllowedAreas (Qt::RightDockWidgetArea); + calls_of_interest_ = new QListWidget {calls_dock}; + calls_of_interest_->setContextMenuPolicy (Qt::ActionsContextMenu); + calls_of_interest_->insertAction (nullptr, add_call_of_interest_action_); + connect (add_call_of_interest_action_, &QAction::triggered, [this] () { + auto item = new QListWidgetItem {}; + item->setFlags (item->flags () | Qt::ItemIsEditable); + item->setData (Qt::UserRole, QString {}); + calls_of_interest_->addItem (item); + calls_of_interest_->editItem (item); + }); + calls_of_interest_->insertAction (nullptr, delete_call_of_interest_action_); + connect (delete_call_of_interest_action_, &QAction::triggered, [this] () { + for (auto item : calls_of_interest_->selectedItems ()) + { + auto old_call = item->data (Qt::UserRole); + if (old_call.isValid ()) change_highlighting (old_call.toString ()); + delete item; + } + }); + calls_of_interest_->insertAction (nullptr, last_call_of_interest_action_); + connect (last_call_of_interest_action_, &QAction::triggered, [this] () { + for (auto item : calls_of_interest_->selectedItems ()) + { + auto old_call = item->data (Qt::UserRole); + change_highlighting (old_call.toString ()); + change_highlighting (old_call.toString () + , item->background ().color (), item->foreground ().color (), true); + delete item; + } + }); + calls_of_interest_->insertAction (nullptr, call_of_interest_bg_colour_action_); + connect (call_of_interest_bg_colour_action_, &QAction::triggered, [this] () { + for (auto item : calls_of_interest_->selectedItems ()) + { + auto old_call = item->data (Qt::UserRole); + auto new_colour = QColorDialog::getColor (item->background ().color () + , this, tr ("Select background color")); + if (new_colour.isValid ()) + { + change_highlighting (old_call.toString (), new_colour, item->foreground ().color ()); + item->setBackground (new_colour); + } + } + }); + calls_of_interest_->insertAction (nullptr, call_of_interest_fg_colour_action_); + connect (call_of_interest_fg_colour_action_, &QAction::triggered, [this] () { + for (auto item : calls_of_interest_->selectedItems ()) + { + auto old_call = item->data (Qt::UserRole); + auto new_colour = QColorDialog::getColor (item->foreground ().color () + , this, tr ("Select foreground color")); + if (new_colour.isValid ()) + { + change_highlighting (old_call.toString (), item->background ().color (), new_colour); + item->setForeground (new_colour); + } + } + }); + connect (calls_of_interest_, &QListWidget::itemChanged, [this] (QListWidgetItem * item) { + auto old_call = item->data (Qt::UserRole); + auto new_call = item->text ().toUpper (); + if (new_call != old_call) + { + // tell all clients + if (old_call.isValid ()) + { + change_highlighting (old_call.toString ()); + } + item->setData (Qt::UserRole, new_call); + item->setText (new_call); + auto bg = item->listWidget ()->palette ().text ().color (); + auto fg = item->listWidget ()->palette ().base ().color (); + item->setBackground (bg); + item->setForeground (fg); + change_highlighting (new_call, bg, fg); + } + }); + + calls_dock->setWidget (calls_of_interest_); + addDockWidget (Qt::RightDockWidgetArea, calls_dock); + view_menu_->addAction (calls_dock->toggleViewAction ()); + view_menu_->addSeparator (); + // connect up server connect (server_, &MessageServer::error, [this] (QString const& message) { QMessageBox::warning (this, QApplication::applicationName (), tr ("Network Error"), message); @@ -144,7 +234,7 @@ void MessageAggregatorMainWindow::log_qso (QString const& /*id*/, QDateTime time void MessageAggregatorMainWindow::add_client (QString const& id, QString const& version, QString const& revision) { - auto dock = new ClientWidget {decodes_model_, beacons_model_, id, version, revision, this}; + auto dock = new ClientWidget {decodes_model_, beacons_model_, id, version, revision, calls_of_interest_, this}; dock->setAttribute (Qt::WA_DeleteOnClose); auto view_action = dock->toggleViewAction (); view_action->setEnabled (true); @@ -159,8 +249,9 @@ void MessageAggregatorMainWindow::add_client (QString const& id, QString const& connect (dock, &ClientWidget::do_free_text, server_, &MessageServer::free_text); connect (dock, &ClientWidget::location, server_, &MessageServer::location); connect (view_action, &QAction::toggled, dock, &ClientWidget::setVisible); + connect (dock, &ClientWidget::highlight_callsign, server_, &MessageServer::highlight_callsign); dock_widgets_[id] = dock; - server_->replay (id); + server_->replay (id); // request decodes and status } void MessageAggregatorMainWindow::remove_client (QString const& id) @@ -173,4 +264,21 @@ void MessageAggregatorMainWindow::remove_client (QString const& id) } } +MessageAggregatorMainWindow::~MessageAggregatorMainWindow () +{ + for (auto client : dock_widgets_) + { + delete client; + } +} + +void MessageAggregatorMainWindow::change_highlighting (QString const& call, QColor const& bg, QColor const& fg + , bool last_only) +{ + for (auto id : dock_widgets_.keys ()) + { + server_->highlight_callsign (id, call, bg, fg, last_only); + } +} + #include "moc_MessageAggregatorMainWindow.cpp" diff --git a/UDPExamples/MessageAggregatorMainWindow.hpp b/UDPExamples/MessageAggregatorMainWindow.hpp index 4ee5bface..b699bee93 100644 --- a/UDPExamples/MessageAggregatorMainWindow.hpp +++ b/UDPExamples/MessageAggregatorMainWindow.hpp @@ -15,6 +15,7 @@ class BeaconsModel; class QLineEdit; class QTableView; class ClientWidget; +class QListWidget; using Frequency = MessageServer::Frequency; @@ -25,6 +26,7 @@ class MessageAggregatorMainWindow public: MessageAggregatorMainWindow (); + ~MessageAggregatorMainWindow (); Q_SLOT void log_qso (QString const& /*id*/, QDateTime time_off, QString const& dx_call, QString const& dx_grid , Frequency dial_frequency, QString const& mode, QString const& report_sent @@ -35,6 +37,12 @@ public: private: void add_client (QString const& id, QString const& version, QString const& revision); void remove_client (QString const& id); + void change_highlighting (QString const& call, QColor const& bg = QColor {}, QColor const& fg = QColor {}, + bool last_only = false); + + // maps client id to widgets + using ClientsDictionary = QHash; + ClientsDictionary dock_widgets_; QStandardItemModel * log_; QMenu * view_menu_; @@ -43,10 +51,12 @@ private: MessageServer * server_; QLineEdit * multicast_group_line_edit_; QTableView * log_table_view_; - - // maps client id to widgets - using ClientsDictionary = QHash; - ClientsDictionary dock_widgets_; + QListWidget * calls_of_interest_; + QAction * add_call_of_interest_action_; + QAction * delete_call_of_interest_action_; + QAction * last_call_of_interest_action_; + QAction * call_of_interest_bg_colour_action_; + QAction * call_of_interest_fg_colour_action_; }; #endif diff --git a/displaytext.cpp b/displaytext.cpp index 0fa3682d7..a52d02b91 100644 --- a/displaytext.cpp +++ b/displaytext.cpp @@ -72,7 +72,7 @@ void DisplayText::insertLineSpacer(QString const& line) appendText (line, "#d3d3d3"); } -void DisplayText::appendText(QString const& text, QColor bg) +void DisplayText::appendText(QString const& text, QColor bg, QString const& call1, QString const& call2) { auto cursor = textCursor (); cursor.movePosition (QTextCursor::End); @@ -89,7 +89,59 @@ void DisplayText::appendText(QString const& text, QColor bg) { cursor.insertBlock (block_format); } - cursor.insertText (text); + + QTextCharFormat format = cursor.charFormat(); + format.clearBackground(); + int text_index {0}; + if (call1.size ()) + { + auto call_index = text.indexOf (call1); + if (call_index != -1) // sanity check + { + auto pos = highlighted_calls_.find (call1); + if (pos != highlighted_calls_.end ()) + { + cursor.insertText(text.left (call_index), format); + if (pos.value ().first.isValid ()) + { + format.setBackground (pos.value ().first); + } + if (pos.value ().second.isValid ()) + { + format.setForeground (pos.value ().second); + } + cursor.insertText(text.mid (call_index, call1.size ()), format); + text_index = call_index + call1.size (); + } + } + } + if (call2.size ()) + { + auto call_index = text.indexOf (call2, text_index); + if (call_index != -1) // sanity check + { + auto pos = highlighted_calls_.find (call2); + if (pos != highlighted_calls_.end ()) + { + format.setBackground (bg); + format.clearForeground (); + cursor.insertText(text.mid (text_index, call_index - text_index), format); + if (pos.value ().second.isValid ()) + { + format.setBackground (pos.value ().first); + } + if (pos.value ().second.isValid ()) + { + format.setForeground (pos.value ().second); + } + cursor.insertText(text.mid (call_index, call2.size ()), format); + text_index = call_index + call2.size (); + } + } + } + format.setBackground (bg); + format.clearForeground (); + cursor.insertText(text.mid (text_index), format); // position so viewport scrolled to left cursor.movePosition (QTextCursor::StartOfLine); @@ -210,13 +262,16 @@ void DisplayText::displayDecodedText(DecodedText const& decodedText, QString con bg = color_MyCall; } auto message = decodedText.string (); + QString dxCall; + QString dxGrid; + decodedText.deCallAndGrid (dxCall, dxGrid); message = message.left (message.indexOf (QChar::Nbsp)); // strip appended info if (displayDXCCEntity && CQcall) // if enabled add the DXCC entity and B4 status to the end of the // preformated text line t1 message = appendDXCCWorkedB4 (message, decodedText.CQersCall (), &bg, logBook, color_CQ, color_DXCC, color_NewCall); - appendText (message.trimmed (), bg); + appendText (message.trimmed (), bg, decodedText.call (), dxCall); } @@ -254,3 +309,96 @@ void DisplayText::displayFoxToBeCalled(QString t, QColor bg) { appendText(t,bg); } + +namespace +{ + void update_selection (QTextCursor& cursor, QColor const& bg, QColor const& fg) + { + if (!cursor.isNull ()) + { + QTextCharFormat format {cursor.charFormat ()}; + if (bg.isValid ()) + { + format.setBackground (bg); + } + else + { + format.clearBackground (); + } + if (fg.isValid ()) + { + format.setForeground (fg); + } + else + { + format.clearForeground (); + } + cursor.mergeCharFormat (format); + } + } + + void reset_selection (QTextCursor& cursor) + { + if (!cursor.isNull ()) + { + // restore previous text format, we rely on the text + // char format at he start of the selection being the + // old one which should be the case + auto c2 = cursor; + c2.setPosition (c2.selectionStart ()); + cursor.setCharFormat (c2.charFormat ()); + } + } +} + +void DisplayText::highlight_callsign (QString const& callsign, QColor const& bg, QColor const& fg, bool last_only) +{ + QTextCharFormat old_format {currentCharFormat ()}; + QTextCursor cursor {document ()}; + if (last_only) + { + cursor.movePosition (QTextCursor::End); + cursor = document ()->find (callsign, cursor + , QTextDocument::FindBackward | QTextDocument::FindWholeWords); + if (bg.isValid () || fg.isValid ()) + { + update_selection (cursor, bg, fg); + } + else + { + reset_selection (cursor); + } + } + else + { + auto pos = highlighted_calls_.find (callsign); + if (bg.isValid () || fg.isValid ()) + { + auto colours = qMakePair (bg, fg); + if (pos == highlighted_calls_.end ()) + { + pos = highlighted_calls_.insert (callsign.toUpper (), colours); + } + else + { + pos.value () = colours; // update colours + } + while (!cursor.isNull ()) + { + cursor = document ()->find (callsign, cursor, QTextDocument::FindWholeWords); + update_selection (cursor, bg, fg); + } + } + else if (pos != highlighted_calls_.end ()) + { + highlighted_calls_.erase (pos); + QTextCursor cursor {document ()}; + while (!cursor.isNull ()) + { + cursor = document ()->find (callsign, cursor, QTextDocument::FindWholeWords); + reset_selection (cursor); + } + } + } + setCurrentCharFormat (old_format); +} diff --git a/displaytext.h b/displaytext.h index 0e027d9eb..0f1ef01b3 100644 --- a/displaytext.h +++ b/displaytext.h @@ -4,6 +4,9 @@ #include #include +#include +#include +#include #include "logbook/logbook.h" #include "decodedtext.h" @@ -30,8 +33,10 @@ public: Q_SIGNAL void selectCallsign (Qt::KeyboardModifiers); Q_SIGNAL void erased (); - Q_SLOT void appendText (QString const& text, QColor bg = Qt::white); + Q_SLOT void appendText (QString const& text, QColor bg = Qt::white + , QString const& call1 = QString {}, QString const& call2 = QString {}); Q_SLOT void erase (); + Q_SLOT void highlight_callsign (QString const& callsign, QColor const& bg, QColor const& fg, bool last_only); protected: void mouseDoubleClickEvent(QMouseEvent *e); @@ -43,6 +48,7 @@ private: QFont char_font_; QAction * erase_action_; + QHash> highlighted_calls_; }; #endif // DISPLAYTEXT_H diff --git a/mainwindow.cpp b/mainwindow.cpp index 57de7266b..2469f4c7c 100644 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -497,6 +497,8 @@ MainWindow::MainWindow(QDir const& temp_directory, bool multiple, } }); + connect (m_messageClient, &MessageClient::highlight_callsign, ui->decodedTextBrowser, &DisplayText::highlight_callsign); + // Hook up WSPR band hopping connect (ui->band_hopping_schedule_push_button, &QPushButton::clicked , &m_WSPR_band_hopping, &WSPRBandHopping::show_dialog);