From 1bea16c6541f19412f0f6d5fc87337f2174ce1a7 Mon Sep 17 00:00:00 2001 From: Eiswolf-BG <37225519+Eiswolf-BG@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:21:49 +0200 Subject: [PATCH] Scrolling marquee for radar info line, clearer tap hint text The bottom info line on the radar screen (tap hint / empty-sky timer) is now a continuously scrolling marquee instead of static text, consistent with the nearest-airport ticker on the Location Presets screen. The text is redrawn every tick() call (80ms) rather than only on render(), so it scrolls smoothly and the empty-sky second counter keeps advancing fluidly. Also clarifies the tap hint in all 6 languages to make it explicit that you need to tap an aircraft (e.g. "Tap an aircraft for more details" instead of the more ambiguous "Tap for details"). Co-Authored-By: Claude Sonnet 5 --- src/i18n_de.h | 2 +- src/i18n_en.h | 2 +- src/i18n_es.h | 2 +- src/i18n_fr.h | 2 +- src/i18n_it.h | 2 +- src/i18n_tr.h | 2 +- src/radar_screen.cpp | 181 ++++++++++++++++++++++++++++++++++++------- 7 files changed, 160 insertions(+), 33 deletions(-) diff --git a/src/i18n_de.h b/src/i18n_de.h index c65fda4..110be9c 100644 --- a/src/i18n_de.h +++ b/src/i18n_de.h @@ -133,7 +133,7 @@ static const char* const I18N_DE[] = { "Das Gerät bietet auch eine kleine Webseite zum Exportieren des kompletten Flugbuchs an. Im selben WLAN einfach im Browser aufrufen:", "Keine WLAN-Verbindung - die Export-Seite ist nur erreichbar, solange eine Netzwerkverbindung besteht.", - "Für Details antippen", + "Für mehr Details ein Flugzeug antippen", "Leerer Himmel seit ", "Modell: ", diff --git a/src/i18n_en.h b/src/i18n_en.h index f4b71f9..c7cdd7a 100644 --- a/src/i18n_en.h +++ b/src/i18n_en.h @@ -134,7 +134,7 @@ static const char* const I18N_EN[] = { "The device also runs a small web page for exporting the full logbook. While connected to the same WiFi network, open a browser and go to:", "No WiFi connection - the export page is only reachable while connected to a network.", - "Tap for details", + "Tap an aircraft for more details", "Empty sky for ", "Model: ", diff --git a/src/i18n_es.h b/src/i18n_es.h index 12b4070..263d6d8 100644 --- a/src/i18n_es.h +++ b/src/i18n_es.h @@ -133,7 +133,7 @@ static const char* const I18N_ES[] = { "El dispositivo también ofrece una pequeña página web para exportar el registro completo. Conectado a la misma red WiFi, simplemente abra un navegador y vaya a:", "Sin conexión WiFi - la página de exportación solo es accesible mientras haya una conexión de red.", - "Toque para ver detalles", + "Toque un avión para ver más detalles", "Cielo vacío desde hace ", "Modelo: ", diff --git a/src/i18n_fr.h b/src/i18n_fr.h index 7a1189d..212d754 100644 --- a/src/i18n_fr.h +++ b/src/i18n_fr.h @@ -133,7 +133,7 @@ static const char* const I18N_FR[] = { "L'appareil propose aussi une petite page web pour exporter le journal complet. Connecté au même réseau WiFi, ouvrez simplement un navigateur et allez sur :", "Pas de connexion WiFi - la page d'export n'est accessible que lorsqu'une connexion réseau est active.", - "Touchez pour les détails", + "Touchez un avion pour plus de détails", "Ciel vide depuis ", "Modèle : ", diff --git a/src/i18n_it.h b/src/i18n_it.h index f0fd2a2..f3e5517 100644 --- a/src/i18n_it.h +++ b/src/i18n_it.h @@ -133,7 +133,7 @@ static const char* const I18N_IT[] = { "Il dispositivo offre anche una piccola pagina web per esportare l'intero registro. Connesso alla stessa rete WiFi, apri semplicemente un browser e vai su:", "Nessuna connessione WiFi - la pagina di esportazione è raggiungibile solo quando è attiva una connessione di rete.", - "Tocca per i dettagli", + "Tocca un aereo per maggiori dettagli", "Cielo vuoto da ", "Modello: ", diff --git a/src/i18n_tr.h b/src/i18n_tr.h index abf6a40..dc610d8 100644 --- a/src/i18n_tr.h +++ b/src/i18n_tr.h @@ -133,7 +133,7 @@ static const char* const I18N_TR[] = { "Cihaz ayrıca tam kayıt defterini dışa aktarmak için küçük bir web sayfası da sunar. Aynı WiFi ağındayken tarayıcıda şu adresi açmanız yeterli:", "WiFi bağlantısı yok - dışa aktarma sayfasına yalnızca bir ağ bağlantısı varken ulaşılabilir.", - "Detaylar için dokunun", + "Daha fazla ayrıntı için bir uçağa dokunun", "Boş gökyüzü: ", "Model: ", diff --git a/src/radar_screen.cpp b/src/radar_screen.cpp index 363693a..ed30730 100644 --- a/src/radar_screen.cpp +++ b/src/radar_screen.cpp @@ -73,6 +73,100 @@ namespace { bool ledBlinkOn = true; + // "Leerer Himmel"-Timer: merkt sich, wann zuletzt mindestens ein + // Flugzeug sichtbar war (nach allen Filtern) - namespace-weit statt + // lokal in render(), da tick() (siehe dort) den Wert bei jedem Tick + // (alle 80ms) braucht, um den Sekundenzaehler fluessig hochzuzaehlen, + // statt nur alle paar Sekunden bei einem render()-Aufruf. + uint32_t lastAircraftSeenMs = millis(); + + // Laufschrift fuer die Info-Zeile unten (Tap-Hinweis bzw. "Leerer + // Himmel"-Timer) - gleiches Grundprinzip wie der Naechster-Flughafen- + // Text in location_presets_screen.cpp (bewusst dupliziert, kein + // gemeinsames Modul, siehe CLAUDE.md-Konvention "jeder Screen + // unabhaengig lauffaehig"). Wird bei jedem tick()-Aufruf (alle 80ms) + // statt nur bei Aenderung neu gezeichnet, damit der Text fluessig und + // endlos durchlaeuft. + struct InfoMarquee { + String text; + String ring; // text + Luecke, doppelt aneinandergehaengt + bool needsScroll = false; + int32_t charOffset = 0; + uint32_t lastStepMs = 0; + }; + InfoMarquee infoMarquee; + + enum class InfoMsgKind { None, TapForDetails, EmptySky }; + InfoMsgKind infoMarqueeKind = InfoMsgKind::None; + + constexpr uint32_t INFO_MARQUEE_STEP_MS = 200; // alle 200ms ein Zeichen weiter + + // Baut Text + Ring-Puffer neu auf und setzt den Scroll-Fortschritt + // zurueck - nur aufrufen, wenn sich die ART der Nachricht aendert + // (Tap-Hinweis <-> Leerer-Himmel-Timer), siehe updateInfoMarqueeText() + // fuer den Fall, dass sich nur der Sekundenwert aendert. + void setupInfoMarquee(TFT_eSPI& tft, const String& text, int16_t viewportW) { + tft.setTextSize(1); + infoMarquee.text = text; + infoMarquee.needsScroll = tft.textWidth(text) > viewportW; + String withGap = text + " "; // 3 Leerzeichen Luecke vor der Wiederholung + infoMarquee.ring = withGap + withGap; + infoMarquee.charOffset = 0; + infoMarquee.lastStepMs = millis(); + } + + // Aktualisiert nur den angezeigten Text (z.B. weil die Sekundenzahl des + // "Leerer Himmel"-Timers weitergezaehlt hat), OHNE den Scroll- + // Fortschritt zurueckzusetzen - so laeuft die Laufschrift trotz des + // sich staendig aendernden Zaehlers fluessig weiter, statt bei jedem + // Tick neu am Anfang zu beginnen. + void updateInfoMarqueeText(const String& text) { + infoMarquee.text = text; + String withGap = text + " "; + infoMarquee.ring = withGap + withGap; + } + + // Liefert den laengsten Ausschnitt ab startIdx, der noch in maxWidth + // passt - OHNE "..." anzuhaengen. + String infoMarqueeWindow(TFT_eSPI& tft, const String& src, int32_t startIdx, int16_t maxWidth) { + String s = src.substring(startIdx); + while (s.length() > 1 && tft.textWidth(s) > maxWidth) { + s.remove(s.length() - 1); + } + return s; + } + + // Zeichnet die Info-Zeile neu - wenn der Text nicht scrollen muss, wird + // er einfach normal (fest) angezeigt. + void drawInfoMarquee(TFT_eSPI& tft, int16_t x, int16_t y, int16_t w) { + if (infoMarquee.text.length() == 0) return; + + constexpr int16_t CLEAR_TOP = 16; + constexpr int16_t CLEAR_H = 20; + tft.fillRect(x, y - CLEAR_TOP, w, CLEAR_H, TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setTextSize(1); + tft.setCursor(x, y); + + if (!infoMarquee.needsScroll) { + tft.print(infoMarquee.text); + return; + } + + uint32_t now = millis(); + if (now - infoMarquee.lastStepMs >= INFO_MARQUEE_STEP_MS) { + infoMarquee.lastStepMs = now; + infoMarquee.charOffset++; + // Zurueck an den Anfang, sobald der erste (nicht doppelte) + // Text+Luecke-Block durchgelaufen ist - so entsteht die + // Endlosschleife. + int32_t singleLen = (int32_t)infoMarquee.text.length() + 3; + if (infoMarquee.charOffset >= singleLen) infoMarquee.charOffset = 0; + } + + tft.print(infoMarqueeWindow(tft, infoMarquee.ring, infoMarquee.charOffset, w)); + } + bool isEmergencySquawk(const char* squawk) { if (!squawk[0]) return false; for (uint8_t i = 0; i < Config::EMERGENCY_SQUAWK_COUNT; i++) { @@ -653,10 +747,6 @@ void render(TFT_eSPI& tft, int16_t top) { strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1); } - // "Leerer Himmel"-Timer: merkt sich, wann zuletzt mindestens ein - // Flugzeug sichtbar war (nach allen Filtern). Statisch, damit der Wert - // über mehrere render()-Aufrufe hinweg erhalten bleibt. - static uint32_t lastAircraftSeenMs = millis(); if (visibleCount > 0) lastAircraftSeenMs = millis(); if (selectedHex[0] && !selectionStillPresent) { @@ -671,29 +761,11 @@ void render(TFT_eSPI& tft, int16_t top) { lastPanel.valid = false; int16_t infoTop = L.infoTop; tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY); - tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.setCursor(8, infoTop + 20); - if (visibleCount == 0) { - // Kein Flugzeug in Reichweite - statt des "Fuer Details - // antippen"-Hinweises (der hier ohnehin ins Leere liefe) zeigen - // wir an, wie lange der Himmel schon leer ist. - uint32_t emptySec = (millis() - lastAircraftSeenMs) / 1000; - char buf[16]; - if (emptySec < 60) { - snprintf(buf, sizeof(buf), "%lus", (unsigned long)emptySec); - } else { - // Ab der ersten vollen Minute NUR noch Minuten anzeigen (ohne - // Sekunden) - "1min 05s" war zu lang und stiess an den - // Reichweiten-Button rechts daneben. Abgerundet (60-119s = - // "1min", 120-179s = "2min", usw.) - intuitiv wie eine - // normale Stoppuhr-Minutenanzeige. - unsigned long minutes = emptySec / 60; - snprintf(buf, sizeof(buf), "%lumin", minutes); - } - tft.print(String(I18n::t(StringId::RADAR_EMPTY_SKY_PREFIX)) + buf); - } else { - tft.print(I18n::t(StringId::RADAR_TAP_FOR_DETAILS)); - } + // Der Info-Text (Tap-Hinweis bzw. "Leerer Himmel"-Timer) wird NICHT + // mehr hier gezeichnet, sondern laufend in tick() als Laufschrift + // (siehe dort) - render() laeuft nur bei geaenderten Flugdaten + // (alle paar Sekunden), tick() dagegen alle 80ms, was fuer eine + // fluessige Laufschrift-Animation noetig ist. char rangeLabel[8]; snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm); @@ -770,6 +842,61 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) { tft.setTextDatum(TL_DATUM); } + // Info-Zeile unten (Tap-Hinweis bzw. "Leerer Himmel"-Timer) als + // durchlaufende Laufschrift - siehe InfoMarquee weiter oben. + // visibleCount wird hier bewusst erneut aus hitPoints[] gezaehlt + // (statt aus render() uebernommen), da tick() unabhaengig von render() + // laeuft und so auch den "Leerer Himmel"-Sekundenzaehler fluessig + // hochzaehlen kann, ohne auf den naechsten render()-Aufruf warten zu + // muessen. + uint8_t visibleCountNow = 0; + for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) { + if (hitPoints[i].valid) visibleCountNow++; + } + if (visibleCountNow > 0) lastAircraftSeenMs = millis(); + + String infoText; + InfoMsgKind kind; + if (visibleCountNow == 0) { + kind = InfoMsgKind::EmptySky; + uint32_t emptySec = (millis() - lastAircraftSeenMs) / 1000; + char buf[16]; + if (emptySec < 60) { + snprintf(buf, sizeof(buf), "%lus", (unsigned long)emptySec); + } else { + // Ab der ersten vollen Minute NUR noch Minuten anzeigen (ohne + // Sekunden) - abgerundet (60-119s = "1min", 120-179s = "2min", + // usw.) - intuitiv wie eine normale Stoppuhr-Minutenanzeige. + unsigned long minutes = emptySec / 60; + snprintf(buf, sizeof(buf), "%lumin", minutes); + } + infoText = String(I18n::t(StringId::RADAR_EMPTY_SKY_PREFIX)) + buf; + } else { + kind = InfoMsgKind::TapForDetails; + infoText = I18n::t(StringId::RADAR_TAP_FOR_DETAILS); + } + + constexpr int16_t INFO_TEXT_X = 8; + constexpr int16_t INFO_TEXT_GAP = 6; + int16_t infoTextY = L.infoTop + 20; + int16_t infoTextW = L.rangeBtn.x - INFO_TEXT_X - INFO_TEXT_GAP; + + if (kind != infoMarqueeKind) { + // Die ART der Nachricht hat sich geaendert (z.B. letztes Flugzeug + // verschwunden) - Laufschrift komplett neu aufsetzen und von vorne + // beginnen. + infoMarqueeKind = kind; + setupInfoMarquee(tft, infoText, infoTextW); + } else { + // Gleiche Art wie zuvor (z.B. weiterhin "Leerer Himmel", nur die + // Sekundenzahl hat sich geaendert) - Text aktualisieren, aber NICHT + // den Scroll-Fortschritt zuruecksetzen, sonst wuerde die + // Laufschrift bei jedem Sekundenwechsel neu von vorne beginnen statt + // fluessig durchzulaufen. + updateInfoMarqueeText(infoText); + } + drawInfoMarquee(tft, INFO_TEXT_X, infoTextY, infoTextW); + tft.endWrite(); }