Skip to content
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ set(CONTESTPROGRAMM_UI_SOURCES
src/ui/BandmapWidget.h
src/ui/BandmapWidget.cpp
src/ui/ChatPanelWidget.cpp
src/ui/SideAreaIcons.cpp
src/ui/SideAreaWidget.cpp
src/ui/SkedPanel.h
src/ui/SkedPanel.cpp
Expand Down
77 changes: 72 additions & 5 deletions src/ui/ChatPanelWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ class MergedChatModel : public QAbstractTableModel {
public:
explicit MergedChatModel(QObject* parent = nullptr) : QAbstractTableModel(parent) {}

// Eine zusammengeführte Zeile: woher sie kam und was drinsteht.
struct Zeile {
bool ausKst = true;
ChatFeedModel::FeedLine linie;
};

void setFeeds(ChatFeedModel* onKst, ChatFeedModel* cluster)
{
for (ChatFeedModel* alt : {m_onKst, m_cluster}) {
Expand Down Expand Up @@ -100,6 +106,12 @@ class MergedChatModel : public QAbstractTableModel {
}
}
if (role == Qt::ForegroundRole) {
// Was mich betrifft, zuerst -- auch wenn die Station schon
// gearbeitet ist: wer mich anspricht, ist wichtiger als die
// Frage, ob ich ihn schon im Log habe.
if (betrifftMich(zeile)) {
return QVariant::fromValue(QColor(Style::kMentionMagenta()));
}
// Schon gearbeitet: gedämpft. Das Auge soll an den offenen
// Stationen hängenbleiben.
if (zeile.linie.worked) {
Expand Down Expand Up @@ -150,6 +162,53 @@ class MergedChatModel : public QAbstractTableModel {

public slots:
// Ob ALLE Zeilen gezeigt werden oder nur die gefilterten.
void setOwnCallsign(const QString& callsign)
{
const QString neu = callsign.trimmed().toUpper();
if (m_ownCallsign == neu) {
return;
}
m_ownCallsign = neu;
if (!m_rows.isEmpty()) {
emit dataChanged(index(0, 0), index(m_rows.size() - 1, ChatPanelWidget::ColumnCount - 1),
{Qt::ForegroundRole});
}
}

QString ownCallsign() const { return m_ownCallsign; }

// Betrifft mich diese Zeile? Wenn mein Rufzeichen darin steht --
// als eigenes Wort, nicht als Teil eines anderen Rufzeichens:
// "OE5SOS" darf nicht in "OE5SOSX" anschlagen. ON4KST schreibt die
// Anrede mitten in den Text ("OE5SOS de DL1ABC ..."), deshalb wird
// der ganze Text durchsucht und nicht nur ein Empfängerfeld.
bool betrifftMich(const Zeile& zeile) const
{
if (m_ownCallsign.isEmpty()) {
return false;
}
if (zeile.linie.candidate.callsign.trimmed().compare(m_ownCallsign, Qt::CaseInsensitive) == 0) {
return true;
}
const QString text = zeile.linie.candidate.message.isEmpty() ? zeile.linie.candidate.rawLine
: zeile.linie.candidate.message;
int ab = 0;
while (true) {
const int pos = text.indexOf(m_ownCallsign, ab, Qt::CaseInsensitive);
if (pos < 0) {
return false;
}
const bool linksFrei = pos == 0 || !(text.at(pos - 1).isLetterOrNumber() || text.at(pos - 1) == QLatin1Char('/'));
const int nach = pos + m_ownCallsign.size();
const bool rechtsFrei = nach >= text.size()
|| !(text.at(nach).isLetterOrNumber() || text.at(nach) == QLatin1Char('/'));
if (linksFrei && rechtsFrei) {
return true;
}
ab = pos + 1;
}
}

void setShowAll(bool alle)
{
if (m_alleZeigen == alle) {
Expand Down Expand Up @@ -223,11 +282,7 @@ public slots:
}

private:
struct Zeile {
bool ausKst = true;
ChatFeedModel::FeedLine linie;
};

QString m_ownCallsign;
ChatFeedModel* m_onKst = nullptr;
ChatFeedModel* m_cluster = nullptr;
QVector<Zeile> m_rows;
Expand Down Expand Up @@ -301,6 +356,18 @@ void ChatPanelWidget::setFeedModels(ChatFeedModel* onKst, ChatFeedModel* cluster
m_model->setFeeds(onKst, cluster);
}

void ChatPanelWidget::setOwnCallsign(const QString& callsign)
{
if (m_model) {
m_model->setOwnCallsign(callsign);
}
}

QString ChatPanelWidget::ownCallsign() const
{
return m_model ? m_model->ownCallsign() : QString();
}

void ChatPanelWidget::setConnectionStatus(const QString& text)
{
m_connectionText = text;
Expand Down
6 changes: 6 additions & 0 deletions src/ui/ChatPanelWidget.h
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ class ChatPanelWidget : public QWidget {
void setShowAll(bool showAll);
bool showsAll() const;

// Das eigene Rufzeichen. Zeilen, in denen es vorkommt, stehen in
// Magenta -- Martin, 2026-09-28: "was im chat mich betrifft soll in
// magenta gekennzeichnet werden." Leer heißt: nichts hervorheben.
void setOwnCallsign(const QString& callsign);
QString ownCallsign() const;

signals:
// Der Bediener will diese Station anrufen -- Doppelklick auf eine
// Zeile. Dieselbe Form, die MainWindow::handleCandidateActivated
Expand Down
109 changes: 105 additions & 4 deletions src/ui/MainWindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,13 @@ MainWindow::MainWindow(AppController& appController, QWidget* parent)
/*contentHasOwnChrome=*/false, QRect(630, 720, 742, 262));
// Optionen rechts oben im Panelkopf -- Martins Regel vom 2026-09-20.
if (chatContainer && chatContainer->headerBar()) {
// Ohne diese Zeile gibt es das Menü zwar, aber keinen Knopf, der
// es öffnet. Genau so war es: Martin, 2026-09-28, "im chat gibt
// es keine optionen - diese sollten dafür dienen, dass ich zb
// die gruppe wechseln kann". Der Kopf zeigt den ⚙ erst, wenn er
// ausdrücklich eingeschaltet wird (setOptionsAffordanceEnabled),
// das Verbinden des Signals allein genügt nicht.
chatContainer->headerBar()->setOptionsAffordanceEnabled(true);
connect(chatContainer->headerBar(), &PanelHeaderBar::optionsRequested, this,
&MainWindow::showChatOptionsPopup);
}
Expand All @@ -988,6 +995,7 @@ MainWindow::MainWindow(AppController& appController, QWidget* parent)
connect(m_sideArea, &SideAreaWidget::removeRequested, this, [this](const QString& id) {
takePanelOutOfSideArea(id);
});
connect(m_sideArea, &SideAreaWidget::pageDraggedOut, this, &MainWindow::dragPanelOutOfSideArea);
// Welche Seite oben liegt und ob der Bereich zugeklappt ist, gehört
// zur Lage dazu -- sonst steht nach dem Neustart eine andere Seite
// vorne als beim Beenden.
Expand All @@ -1006,8 +1014,19 @@ MainWindow::MainWindow(AppController& appController, QWidget* parent)
QStringLiteral("checkpartial"), QStringLiteral("bandmap"),
QStringLiteral("skeds"), QStringLiteral("chat")}) {
if (PanelContainerWidget* panel = m_panelLayoutManager->panel(id)) {
connect(panel, &PanelContainerWidget::dragFinished, this,
[this, id](const QPoint& globalPos) { dropPanelIfOverSideArea(id, globalPos); });
connect(panel, &PanelContainerWidget::dragFinished, this, [this, id](const QPoint& globalPos) {
// Beide Richtungen am selben Griff: liegt das Panel
// schon im Seitenbereich, holt der Zug es heraus, sonst
// legt er es hinein. Martin, 2026-09-28: "ich kann
// nichts herausziehen" -- der Leistenknopf ging, aber
// gezogen wird am Panelkopf, spiegelbildlich zum
// Hineinziehen.
if (m_sideArea && m_sideArea->hasPage(id)) {
dragPanelOutOfSideArea(id, globalPos);
} else {
dropPanelIfOverSideArea(id, globalPos);
}
});
}
}

Expand Down Expand Up @@ -2175,8 +2194,21 @@ void MainWindow::applyRotorSlot(bool enabled, const QString& label, RotctldClien
connect(&client, &RotctldClient::azimuthChanged, widget, [this, &client, label](double az) {
pushRotorHeadingToMap(client, true, az, label);
});
connect(&client, &RotctldClient::stateChanged, widget, [this, &client, label]() {
pushRotorHeadingToMap(client, true, client.azimuthDeg(), label);
// Die Richtung kommt vom BEDIENFELD, nicht vom Client.
// Martin, 2026-09-28: "ich habe den rotor-zeiger geändert,
// aber in der karte war dieser auf null grad." Genau hier
// ging sie verloren: ohne Draht zum Rotor meldet
// RotctldClient::azimuthDeg() 0, und jeder Takt der
// Verbindungsprüfung (stateChanged feuert auch bei jedem
// erfolglosen Anlauf) schob diese 0 in die Karte -- über
// die Richtung hinweg, die man gerade von Hand gestellt
// hatte. Der Zeiger blieb auf 31, der Lichtkegel sprang auf
// 0. Das Bedienfeld ist die Quelle: es zeigt, was der
// Steckplatz verfolgt, ob echt gemessen oder von Hand
// gestellt -- dieselbe Regel, nach der die Nadel gezeichnet
// wird.
connect(&client, &RotctldClient::stateChanged, widget, [this, &client, widget, label]() {
pushRotorHeadingToMap(client, true, widget->azimuthDeg(), label);
});
// Mirrors the dial's OWN heading onto the map -- not just a
// duplicate of the two connections above. This one also
Expand Down Expand Up @@ -2399,6 +2431,14 @@ void MainWindow::refreshMapWidget()
const ContestSettings settings = m_appController.settings();
m_mapWidget->setOwnGrid(settings.ownGrid);
m_mapWidget->setOwnLabel(settings.ownCallsign);
// Dasselbe Rufzeichen färbt im Chat die Zeilen, die mich angehen
// (Martin, 2026-09-28: "was im chat mich betrifft soll in magenta
// gekennzeichnet werden"). Hier mitgezogen, weil diese Stelle nach
// jeder Einstellungsänderung läuft -- wer sein Rufzeichen ändert,
// hat sofort die richtige Hervorhebung.
if (m_chatPanel) {
m_chatPanel->setOwnCallsign(settings.ownCallsign);
}

QVector<MapWidget::Station> stations;
QSet<QString> workedCallsigns;
Expand Down Expand Up @@ -2764,6 +2804,67 @@ void MainWindow::putPanelIntoSideArea(const QString& id, const QString& title)
statusBar()->showMessage(QStringLiteral("%1 liegt jetzt im Seitenbereich").arg(title), 4000);
}

// Herausziehen: das Panel verlässt den Bereich und legt sich dorthin,
// wo der Zeiger losgelassen wurde. Martin, 2026-09-28: "die widgets
// sollte man aber auch wieder per drag and drop rausziehen können, in
// dem fall nach rechts." Innerhalb des Bereichs losgelassen heißt
// "doch nicht" -- sonst risse ein Rutscher beim Umschalten das Panel
// heraus.
void MainWindow::dragPanelOutOfSideArea(const QString& id, const QPoint& globalPos)
{
if (!m_sideArea || !m_sideArea->hasPage(id)) {
return;
}
PanelContainerWidget* bereichPanel = m_panelLayoutManager->panel(QStringLiteral("sidearea"));
if (bereichPanel) {
const QRect bereichAufDemSchirm(bereichPanel->mapToGlobal(QPoint(0, 0)), bereichPanel->size());
if (bereichAufDemSchirm.contains(globalPos)) {
return;
}
}
const QSize vorherigeGroesse = m_sideAreaHomeGeometry.value(id).size();
takePanelOutOfSideArea(id);
PanelContainerWidget* panel = m_panelLayoutManager->panel(id);
if (!panel) {
return;
}
// Der Griff sitzt links oben am Panelkopf, dort wo man es auch
// wieder anfassen würde -- ein paar Pixel neben dem Zeiger, nicht
// mittig darunter, sonst verdeckt der Mauszeiger die Kopfzeile.
QWidget* flaeche = m_panelLayoutManager->canvas();
if (!flaeche) {
return;
}
const QPoint aufDerFlaeche = flaeche->mapFromGlobal(globalPos) - QPoint(20, 10);
const QSize groesse = vorherigeGroesse.isValid() && !vorherigeGroesse.isEmpty() ? vorherigeGroesse
: panel->size();
// Ein GESPERRTES Panel weist trySetGeometry() ab -- und Martins
// Panels sind gesperrt. Live gefunden: Skeds kam aus der Leiste,
// legte sich aber an seinen alten Platz links oben statt dorthin,
// wo losgelassen wurde. Wer ein Panel eigenhändig herauszieht,
// verschiebt es absichtlich; das Schloss schützt vor Versehen, und
// ein Zug quer über den Schirm ist keines. Es bleibt gesperrt --
// nur dieser eine Handgriff geht durch.
const bool warGesperrt = panel->isLocked();
if (warGesperrt) {
panel->setLocked(false);
}
// Auf der Fläche halten. Sonst hinge ein breites Panel, ganz rechts
// abgelegt, zur Hälfte draußen -- die Klemme, die der
// Layout-Manager sonst besorgt, greift bei einem gesperrten Panel
// nicht.
const QPoint gehalten(std::clamp(aufDerFlaeche.x(), 0, std::max(0, flaeche->width() - groesse.width())),
std::clamp(aufDerFlaeche.y(), 0, std::max(0, flaeche->height() - groesse.height())));
panel->trySetGeometry(QRect(gehalten, groesse));
if (warGesperrt) {
panel->setLocked(true);
}
m_panelLayoutManager->revealPanel(id);
statusBar()->showMessage(QStringLiteral("%1 liegt wieder auf der Fläche")
.arg(panel->title().isEmpty() ? id : panel->title()),
4000);
}

// Und zurück auf die Fläche, an die Stelle, von der es kam.
void MainWindow::takePanelOutOfSideArea(const QString& id)
{
Expand Down
3 changes: 3 additions & 0 deletions src/ui/MainWindow.h
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,9 @@ private slots:
Q_INVOKABLE void takePanelOutOfSideArea(const QString& id);
// Ein losgelassenes Panel über dem Seitenbereich -- siehe dort.
Q_INVOKABLE void dropPanelIfOverSideArea(const QString& id, const QPoint& globalPos);
// Der Rückweg: aus dem Seitenbereich heraus an die Stelle, wo man
// losgelassen hat.
Q_INVOKABLE void dragPanelOutOfSideArea(const QString& id, const QPoint& globalPos);
// Wer im Seitenbereich liegt, welche Seite oben ist und ob er
// zugeklappt war -- das überlebt den Neustart, wie jede andere
// Panel-Lage auch.
Expand Down
3 changes: 3 additions & 0 deletions src/ui/MapWidget.h
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ class MapWidget : public QWidget {
// The scope rectangle -- its centre is the own station. Tests click
// there.
QRectF canvasRectForTest() const { return scopeRect(); }
// Was die Karte gerade als Peilung führt -- für Prüfstände, die
// nachsehen, ob eine Drehung am Rotor hier ankommt.
double rotorHeadingForTest(int rotor) const { return rotor == 2 ? m_rotor2AzimuthDeg : m_rotor1AzimuthDeg; }
QRectF openInBeamRectForTest() const { return m_openInBeamRect; }

QSize minimumSizeHint() const override;
Expand Down
8 changes: 8 additions & 0 deletions src/ui/PanelContainerWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <QApplication>
#include <QMouseEvent>
#include <QPushButton>
#include <QStackedWidget>
#include <QVBoxLayout>

#include <algorithm>
Expand Down Expand Up @@ -217,6 +218,13 @@ void PanelContainerWidget::updateDrag(const QPoint& globalPos)
return;
}
m_lastDragGlobal = globalPos;
// Liegt das Panel im Seitenbereich, sitzt es in einem
// QStackedWidget und füllt es ganz aus. Dort etwas zu verschieben
// ergibt nur Gezitter -- der Zug zählt trotzdem, sein Ende
// entscheidet über das Herausziehen (siehe dragFinished).
if (qobject_cast<QStackedWidget*>(parentWidget()) != nullptr) {
return;
}
QPoint newPos = globalPos - m_dragStartOffset;
// Clamped to the parent canvas -- same overlay-docked behavior as
// ContainerWidget::updateDrag()'s non-floating branch.
Expand Down
Loading
Loading