diff --git a/src/radar_screen.cpp b/src/radar_screen.cpp index 5b95c71..4b92838 100644 --- a/src/radar_screen.cpp +++ b/src/radar_screen.cpp @@ -35,6 +35,7 @@ struct Layout { Rect rangeBtn; Rect listBtn; Rect searchBtn; + Rect helpBtn; int16_t infoTop; }; @@ -62,6 +63,7 @@ Layout computeLayout(int16_t top) { L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 70), (int16_t)(L.infoTop + 4), 62, 22}; L.listBtn = {(int16_t)(Config::SCREEN_WIDTH - 110), (int16_t)(L.infoTop + 4), 36, 22}; L.searchBtn = {(int16_t)(Config::SCREEN_WIDTH - 150), (int16_t)(L.infoTop + 4), 36, 22}; + L.helpBtn = {8, (int16_t)(L.infoTop + 4), 36, 22}; return L; } @@ -99,68 +101,17 @@ Layout computeLayout(int16_t top) { bool ledBlinkOn = true; - // "Leerer Himmel"-Timer: merkt sich, wann zuletzt mindestens ein +// "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; + // Wird von der Detail-Panel-Laufschrift (LineMarquee in drawDetailPanel) + // fuer das Scrollen von Routentext verwendet. + constexpr uint32_t INFO_MARQUEE_STEP_MS = 200; - 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. needsScroll wird trotzdem bei JEDEM - // Aufruf neu bestimmt (nicht nur einmalig in setupInfoMarquee) - sonst - // blieb der "Leerer Himmel"-Timer dauerhaft ohne Scrollen stehen, weil - // der allererste Text ("...seit 0s") noch kurz genug war und die - // Laufschrift-Funktion spaeter (z.B. bei "...seit 5min") nie erneut - // geprueft hat, ob der inzwischen laengere Text nun doch scrollen muss. - void updateInfoMarqueeText(TFT_eSPI& tft, const String& text, int16_t viewportW) { - tft.setTextSize(1); - infoMarquee.text = text; - infoMarquee.needsScroll = tft.textWidth(text) > viewportW; - 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) { @@ -169,39 +120,7 @@ Layout computeLayout(int16_t top) { 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++) { if (strcmp(squawk, Config::EMERGENCY_SQUAWKS[i]) == 0) return true; } @@ -1019,27 +938,10 @@ void render(TFT_eSPI& tft, int16_t top) { drawDetailPanel(tft, selected); tft.startWrite(); } else { - lastPanel.valid = false; +lastPanel.valid = false; int16_t infoTop = L.infoTop; tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY); - // Der eigentliche Text/Scroll-Fortschritt der Info-Zeile (Tap- - // Hinweis bzw. "Leerer Himmel"-Timer) wird weiterhin laufend in - // tick() aktualisiert (siehe dort) - HIER aber trotzdem sofort mit - // dem aktuellen Stand neu gezeichnet (statt bis zum naechsten - // tick() zu warten). Der obige fillRect() ganz oben in render() - // hat die Zeile naemlich gerade erst geloescht, und render() laeuft - // nur bei jedem Fetch-Zyklus (ca. alle 8s, zeitgleich mit dem - // Heartbeat-Blinken) - ohne dieses sofortige Nachzeichnen blieb die - // Zeile bis zu 80ms lang leer, sichtbar als kurzes Blinken im - // Heartbeat-Takt. -constexpr int16_t INFO_TEXT_X = 8; - constexpr int16_t INFO_TEXT_GAP = 6; - int16_t infoTextY = infoTop + 20; - int16_t infoTextW = L.listBtn.x - INFO_TEXT_X - INFO_TEXT_GAP; - drawInfoMarquee(tft, INFO_TEXT_X, infoTextY, infoTextW); - - // Respektiert jetzt die Einheiten-Einstellung (Menue > Einheiten) - - // vorher immer "XXkm", auch bei Imperial (dort jetzt "XXnm"). + // Info-Bar Button-Leiste: [?] [S] [L] [50km] char rangeLabel[8]; bool rangeMetric = LocationManager::useMetricUnits(); if (rangeMetric) { @@ -1047,9 +949,10 @@ constexpr int16_t INFO_TEXT_X = 8; } else { snprintf(rangeLabel, sizeof(rangeLabel), "%.0fnm", Units::kmToNm(rangeKm)); } - drawButton(tft, L.rangeBtn, rangeLabel); - drawButton(tft, L.listBtn, "L"); + drawButton(tft, L.helpBtn, "?"); drawButton(tft, L.searchBtn, "S"); + drawButton(tft, L.listBtn, "L"); + drawButton(tft, L.rangeBtn, rangeLabel); drawLegend(tft, infoTop + 44); } @@ -1157,60 +1060,14 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) { } // 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. +// Laufende Aktualisierung der letzten Sichtung fuer den "Leerer + // Himmel"-Zaehler. 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.listBtn.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(tft, infoText, infoTextW); - } - drawInfoMarquee(tft, INFO_TEXT_X, infoTextY, infoTextW); - tft.endWrite(); } @@ -1233,6 +1090,11 @@ bool handleTap(TFT_eSPI& tft, int16_t x, int16_t y, int16_t top) { return true; } + if (L.helpBtn.contains(x, y)) { + runHelp(tft, top); + return true; + } + if (L.searchBtn.contains(x, y)) { runSearch(tft, top); return true; @@ -1468,7 +1330,7 @@ void runSearch(TFT_eSPI& tft, int16_t top) { tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setCursor(10, 14); - tft.print("Search"); + //tft.print("Search"); tft.fillRect(8, 30, Config::SCREEN_WIDTH - 16, 28, TFT_BLACK); tft.drawRect(8, 30, Config::SCREEN_WIDTH - 16, 28, TFT_GREEN); @@ -1585,6 +1447,70 @@ void runSearch(TFT_eSPI& tft, int16_t top) { } } +void runHelp(TFT_eSPI& tft, int16_t top) { + tft.fillScreen(TFT_BLACK); + + constexpr int16_t X = 10; + constexpr int16_t W = Config::SCREEN_WIDTH - 20; + constexpr int16_t LBL_H = 14; + int16_t y = 14; + + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.setTextSize(1); + tft.setCursor(X, y); + tft.print("Usage Guide"); + y += 24; + + auto line = [&](const char* text) { + tft.setCursor(X, y); + tft.print(text); + y += LBL_H; + }; + + tft.setTextColor(TFT_WHITE, TFT_BLACK); + line("Tap a plane on the radar to see"); + line("its details (model, altitude,"); + line("speed, route, squawk)."); + y += 4; + + tft.setTextColor(TFT_GREEN, TFT_BLACK); + line("[?] This help screen."); + line("[S] Search by flight number or"); + line(" hex code."); + line("[L] List all planes by distance."); + line("[km] Cycle radar range: 10, 25,"); + line(" 50, 100 km."); + y += 4; + + tft.setTextColor(TFT_GREEN, TFT_BLACK); + line("Altitude colors:"); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + line(" <10k ft "); + tft.setTextColor(TFT_YELLOW, TFT_BLACK); + line(" 10k - 30k ft "); + tft.setTextColor(TFT_RED, TFT_BLACK); + line(" >30k ft "); + y += 4; + + tft.setTextColor(TFT_WHITE, TFT_BLACK); + line("Menu > settings, WLAN, flight"); + line("options, watchlist & more."); + + Rect okBtn = {(int16_t)((Config::SCREEN_WIDTH - 120) / 2), (int16_t)(Config::SCREEN_HEIGHT - 50), 120, 36}; + drawButton(tft, okBtn, I18n::t(StringId::OK)); + + MenuStars::reset(); + while (true) { + TouchInput::Point tap; + if (TouchInput::wasTapped(tap)) { + if (okBtn.contains(tap.x, tap.y)) break; + break; // any tap dismisses + } + MenuStars::update(tft); + delay(20); + } +} + void selectAircraft(const char* hex, const char* callsign) { strncpy(selectedHex, hex, sizeof(selectedHex) - 1); AircraftDetails::request(hex, callsign); diff --git a/src/radar_screen.h b/src/radar_screen.h index c5a52e2..e5a49e7 100644 --- a/src/radar_screen.h +++ b/src/radar_screen.h @@ -18,6 +18,9 @@ void render(TFT_eSPI& tft, int16_t top); // "not found" message and returns to the keypad. void runSearch(TFT_eSPI& tft, int16_t top); + // Shows a full-screen usage guide popup triggered by the "?" button. + void runHelp(TFT_eSPI& tft, int16_t top); + // Waehlt ein Flugzeug programmgesteuert aus (z.B. von der Flugzeugliste // aus, nicht per Antippen auf dem Radar) - damit beim naechsten render() // sofort das Detail-Panel fuer dieses Flugzeug erscheint, so als haette