remove rolling banner add help screen

This commit is contained in:
2026-08-15 21:46:40 +02:00
parent 760b1a9fc1
commit 3778f3a506
2 changed files with 86 additions and 157 deletions

View File

@@ -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);

View File

@@ -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