Marquee/scroll fixes for truncated text across radar and location screens

Bundles several small local follow-ups since v3.3.0:

- Radar screen aircraft detail panel: long lines (model name, distance
  with km/nm/mi) now scroll horizontally instead of being cut off with
  "..." (up to 9 independent scrolling lines, reusing the existing
  InfoMarquee pattern).
- "Setup complete" screen: small centered "Tap to skip" hint below the
  countdown button, translated into all 6 languages.
- Location Presets screen: long preset names/coordinates in the list
  now scroll left-aligned instead of being truncated with "...".
- Address search confirmation screen: the found place name is no
  longer hard-cut at 150 characters - it's now a vertically scrollable
  paragraph (same pattern as the existing Location Presets info
  screen), since it's multi-line wrapped text rather than a single
  overflowing line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-12 16:19:47 +02:00
parent e77238bd90
commit 467d30d8cd
11 changed files with 284 additions and 45 deletions

View File

@@ -38,8 +38,16 @@ namespace {
// Screen haelt seine eigenen kleinen Helfer statt eines gemeinsamen // Screen haelt seine eigenen kleinen Helfer statt eines gemeinsamen
// Moduls). Ohne Scroll-Unterstuetzung - hier immer nur kurze, // Moduls). Ohne Scroll-Unterstuetzung - hier immer nur kurze,
// vorab abgeschnittene Texte (siehe runConfirmScreen/showErrorRetry). // vorab abgeschnittene Texte (siehe runConfirmScreen/showErrorRetry).
// Lokale Kopie (siehe Konvention in location_presets_screen.cpp - jeder
// Screen haelt seine eigenen kleinen Helfer statt eines gemeinsamen
// Moduls). Optionale Scroll-Unterstuetzung (scrollY/viewTop/viewBottom/
// draw) fuer runConfirmScreen() unten - bei den bestehenden Aufrufstellen
// (showErrorRetry, Kopfzeilen) bleiben die Defaults aktiv, es wird also
// wie bisher immer alles auf einmal gezeichnet.
int16_t layoutWrapped(TFT_eSPI& tft, int16_t x, int16_t startY, int16_t maxWidth, int16_t layoutWrapped(TFT_eSPI& tft, int16_t x, int16_t startY, int16_t maxWidth,
int16_t lineHeight, const String& text) { int16_t lineHeight, const String& text,
int16_t scrollY = 0, int16_t viewTop = -32000, int16_t viewBottom = 32000,
bool draw = true) {
int16_t y = startY; int16_t y = startY;
int32_t start = 0; int32_t start = 0;
int32_t len = text.length(); int32_t len = text.length();
@@ -52,8 +60,13 @@ namespace {
if (lastSpace <= 0) break; if (lastSpace <= 0) break;
line = line.substring(0, lastSpace); line = line.substring(0, lastSpace);
} }
tft.setCursor(x, y); if (draw) {
int16_t screenY = (int16_t)(y - scrollY);
if (screenY >= viewTop && screenY <= viewBottom) {
tft.setCursor(x, screenY);
tft.print(line); tft.print(line);
}
}
y += lineHeight; y += lineHeight;
start += line.length(); start += line.length();
} }
@@ -499,26 +512,72 @@ namespace {
// Adress-Tastatur). // Adress-Tastatur).
int runConfirmScreen(TFT_eSPI& tft, const String& displayName) { int runConfirmScreen(TFT_eSPI& tft, const String& displayName) {
MenuStars::reset(); MenuStars::reset();
constexpr int16_t textMaxWidth = Config::SCREEN_WIDTH - 20;
constexpr int16_t LINE_H = 18;
constexpr int16_t VIEW_TOP = 40;
constexpr int16_t TRY_AGAIN_Y = (int16_t)(Config::SCREEN_HEIGHT - 96);
constexpr int16_t USE_Y = (int16_t)(Config::SCREEN_HEIGHT - 50);
constexpr int16_t VIEW_BOTTOM = (int16_t)(TRY_AGAIN_Y - 10);
// Volle Adresse OHNE Kuerzung anzeigen (frueher wurde bei > 150
// Zeichen mit "..." abgeschnitten) - stattdessen wird der Absatz bei
// Bedarf vertikal scrollbar, exakt dasselbe Muster wie beim "Wie
// funktionieren Presets"-Infoscreen (location_presets_screen.cpp
// ::runInfoScreen). Bewusst KEIN Marquee hier: das ist ein
// mehrzeiliger, wortumgebrochener Absatz, keine einzelne Zeile, die
// in der Breite nicht passt - horizontales Scrollen waere hier die
// falsche Loesung.
int16_t totalH = layoutWrapped(tft, 10, VIEW_TOP, textMaxWidth, LINE_H, displayName, 0, 0, 0, false);
int16_t maxScroll = (int16_t)(totalH - VIEW_BOTTOM);
if (maxScroll < 0) maxScroll = 0;
bool scrollable = maxScroll > 0;
int16_t scrollY = 0;
Rect tryAgainBtn = {10, TRY_AGAIN_Y, (int16_t)(Config::SCREEN_WIDTH - 20), 40};
Rect useBtn = scrollable
? Rect{10, USE_Y, 130, 40}
: Rect{10, USE_Y, (int16_t)(Config::SCREEN_WIDTH - 20), 40};
Rect upBtn = {146, USE_Y, 38, 40};
Rect downBtn = {190, USE_Y, 38, 40};
constexpr int16_t SCROLL_STEP = 48;
auto redraw = [&]() {
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14); tft.setCursor(10, 14);
tft.println(I18n::t(StringId::ADDRESS_SEARCH_CONFIRM_TITLE)); tft.println(I18n::t(StringId::ADDRESS_SEARCH_CONFIRM_TITLE));
String shown = displayName;
if (shown.length() > 150) shown = shown.substring(0, 150) + "...";
tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
layoutWrapped(tft, 10, 40, (int16_t)(Config::SCREEN_WIDTH - 20), 18, shown); layoutWrapped(tft, 10, VIEW_TOP, textMaxWidth, LINE_H, displayName, scrollY, VIEW_TOP, VIEW_BOTTOM, true);
Rect tryAgainBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 96), (int16_t)(Config::SCREEN_WIDTH - 20), 40};
Rect useBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50), (int16_t)(Config::SCREEN_WIDTH - 20), 40};
drawButton(tft, tryAgainBtn, I18n::t(StringId::ADDRESS_SEARCH_TRY_AGAIN)); drawButton(tft, tryAgainBtn, I18n::t(StringId::ADDRESS_SEARCH_TRY_AGAIN));
drawButton(tft, useBtn, I18n::t(StringId::ADDRESS_SEARCH_USE_THIS)); drawButton(tft, useBtn, I18n::t(StringId::ADDRESS_SEARCH_USE_THIS));
if (scrollable) {
drawButton(tft, upBtn, "^");
drawButton(tft, downBtn, "v");
}
};
redraw();
while (true) { while (true) {
TouchInput::Point tap; TouchInput::Point tap;
if (!TouchInput::wasTapped(tap)) { MenuStars::update(tft); delay(20); continue; } if (TouchInput::wasTapped(tap)) {
if (tryAgainBtn.contains(tap.x, tap.y)) return 0; if (tryAgainBtn.contains(tap.x, tap.y)) return 0;
if (useBtn.contains(tap.x, tap.y)) return 1; if (useBtn.contains(tap.x, tap.y)) return 1;
if (scrollable && upBtn.contains(tap.x, tap.y) && scrollY > 0) {
scrollY -= SCROLL_STEP;
if (scrollY < 0) scrollY = 0;
redraw();
} else if (scrollable && downBtn.contains(tap.x, tap.y) && scrollY < maxScroll) {
scrollY += SCROLL_STEP;
if (scrollY > maxScroll) scrollY = maxScroll;
redraw();
}
}
MenuStars::update(tft);
delay(20);
} }
} }
} }

View File

@@ -84,6 +84,17 @@ void run(TFT_eSPI& tft) {
Rect startBtn = {10, btnY, (int16_t)(Config::SCREEN_WIDTH - 20), BTN_H}; Rect startBtn = {10, btnY, (int16_t)(Config::SCREEN_WIDTH - 20), BTN_H};
drawButtonFrame(tft, startBtn); drawButtonFrame(tft, startBtn);
// Small, centered hint below the button that tapping it skips the
// countdown - stays unchanged for the whole countdown
// (drawCountdownText() only redraws inside the button, so it never
// overwrites this text).
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setTextSize(1);
tft.drawString(I18n::t(StringId::FIRST_RUN_COMPLETE_TAP_TO_SKIP),
(int16_t)(Config::SCREEN_WIDTH / 2), (int16_t)(startBtn.y + startBtn.h + 12));
tft.setTextDatum(TL_DATUM);
// Automatic countdown in the button instead of a tappable // Automatic countdown in the button instead of a tappable
// "Flightradar" text - continues automatically into the splash // "Flightradar" text - continues automatically into the splash
// screen/app afterward. Tapping the button during the countdown // screen/app afterward. Tapping the button during the countdown

View File

@@ -297,6 +297,12 @@ enum class StringId : uint8_t {
MENU_FACTORY_RESET_DELETING, MENU_FACTORY_RESET_DELETING,
MENU_FACTORY_RESET_FAILED, MENU_FACTORY_RESET_FAILED,
// Small, centered hint below the countdown button on the "Setup
// complete" screen (first_run_complete_screen.cpp) - makes it visible
// that tapping the button skips the 7-second countdown immediately
// instead of waiting it out.
FIRST_RUN_COMPLETE_TAP_TO_SKIP,
COUNT COUNT
}; };

View File

@@ -237,6 +237,7 @@ static const char* const I18N_DE[] = {
"Löscht ALLE Daten von der SD-Karte (Einstellungen, Flugbuch, Standort-Presets, WLAN, Kalibrierung) und startet das Gerät neu. Danach läuft die komplette Ersteinrichtung erneut - wie beim allerersten Start. Das kann NICHT rückgängig gemacht werden!", "Löscht ALLE Daten von der SD-Karte (Einstellungen, Flugbuch, Standort-Presets, WLAN, Kalibrierung) und startet das Gerät neu. Danach läuft die komplette Ersteinrichtung erneut - wie beim allerersten Start. Das kann NICHT rückgängig gemacht werden!",
"Lösche alle Daten...", "Lösche alle Daten...",
"Zurücksetzen fehlgeschlagen.", "Zurücksetzen fehlgeschlagen.",
"Tippen zum Überspringen",
}; };
static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT,

View File

@@ -238,6 +238,7 @@ static const char* const I18N_EN[] = {
"Deletes ALL data from the SD card (settings, logbook, location presets, WiFi, calibration) and restarts the device. The full first-time setup will then run again, just like on the very first boot. This CANNOT be undone!", "Deletes ALL data from the SD card (settings, logbook, location presets, WiFi, calibration) and restarts the device. The full first-time setup will then run again, just like on the very first boot. This CANNOT be undone!",
"Deleting all data...", "Deleting all data...",
"Reset failed.", "Reset failed.",
"Tap to skip",
}; };
static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT,

View File

@@ -237,6 +237,7 @@ static const char* const I18N_ES[] = {
"Elimina TODOS los datos de la tarjeta SD (ajustes, registro de vuelos, ubicaciones guardadas, WiFi, calibración) y reinicia el dispositivo. Después se ejecutará de nuevo la configuración inicial completa, como en el primer arranque. ¡Esto NO se puede deshacer!", "Elimina TODOS los datos de la tarjeta SD (ajustes, registro de vuelos, ubicaciones guardadas, WiFi, calibración) y reinicia el dispositivo. Después se ejecutará de nuevo la configuración inicial completa, como en el primer arranque. ¡Esto NO se puede deshacer!",
"Eliminando todos los datos...", "Eliminando todos los datos...",
"Error al reiniciar.", "Error al reiniciar.",
"Toca para saltar",
}; };
static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT,

View File

@@ -237,6 +237,7 @@ static const char* const I18N_FR[] = {
"Supprime TOUTES les données de la carte SD (réglages, journal, préréglages de position, WiFi, calibrage) et redémarre l'appareil. La configuration initiale complète s'exécutera alors à nouveau, comme au tout premier démarrage. Cette action est IRRÉVERSIBLE !", "Supprime TOUTES les données de la carte SD (réglages, journal, préréglages de position, WiFi, calibrage) et redémarre l'appareil. La configuration initiale complète s'exécutera alors à nouveau, comme au tout premier démarrage. Cette action est IRRÉVERSIBLE !",
"Suppression de toutes les données...", "Suppression de toutes les données...",
"Échec de la réinitialisation.", "Échec de la réinitialisation.",
"Toucher pour passer",
}; };
static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT,

View File

@@ -237,6 +237,7 @@ static const char* const I18N_IT[] = {
"Elimina TUTTI i dati dalla scheda SD (impostazioni, registro voli, posizioni preimpostate, WiFi, calibrazione) e riavvia il dispositivo. Verrà quindi eseguita di nuovo la configurazione iniziale completa, come al primo avvio. Questa azione NON può essere annullata!", "Elimina TUTTI i dati dalla scheda SD (impostazioni, registro voli, posizioni preimpostate, WiFi, calibrazione) e riavvia il dispositivo. Verrà quindi eseguita di nuovo la configurazione iniziale completa, come al primo avvio. Questa azione NON può essere annullata!",
"Eliminazione di tutti i dati...", "Eliminazione di tutti i dati...",
"Reimpostazione fallita.", "Reimpostazione fallita.",
"Tocca per saltare",
}; };
static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT,

View File

@@ -237,6 +237,7 @@ static const char* const I18N_TR[] = {
"SD karttaki TÜM verileri (ayarlar, uçuş kayıt defteri, konum ön ayarları, WiFi, kalibrasyon) siler ve cihazı yeniden başlatır. Ardından ilk çalıştırmadaki gibi kurulum işlemi tekrar başlar. Bu işlem GERİ ALINAMAZ!", "SD karttaki TÜM verileri (ayarlar, uçuş kayıt defteri, konum ön ayarları, WiFi, kalibrasyon) siler ve cihazı yeniden başlatır. Ardından ilk çalıştırmadaki gibi kurulum işlemi tekrar başlar. Bu işlem GERİ ALINAMAZ!",
"Tüm veriler siliniyor...", "Tüm veriler siliniyor...",
"Sıfırlama başarısız.", "Sıfırlama başarısız.",
"Atlamak için dokun",
}; };
static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT,

View File

@@ -328,15 +328,6 @@ namespace {
return y; return y;
} }
String truncateForWidth(TFT_eSPI& tft, const String& text, int16_t maxWidth) {
String s = text;
if (tft.textWidth(s) <= maxWidth) return s;
while (s.length() > 1 && tft.textWidth(s + "...") > maxWidth) {
s.remove(s.length() - 1);
}
return s + "...";
}
// Laufschrift fuer den "Naechster Flughafen"-Text: statt ihn mit "..." // Laufschrift fuer den "Naechster Flughafen"-Text: statt ihn mit "..."
// abzuschneiden, scrollt er horizontal durch, falls er nicht in die // abzuschneiden, scrollt er horizontal durch, falls er nicht in die
// verfuegbare Breite passt. BEWUSST OHNE tft.setViewport() (das hatte // verfuegbare Breite passt. BEWUSST OHNE tft.setViewport() (das hatte
@@ -431,6 +422,66 @@ namespace {
tft.setTextSize(1); tft.setTextSize(1);
} }
// Laufschrift-Zustand pro Preset-Zeile, analog zu airportMarquee oben,
// aber als Array (eine Instanz je Zeile) und LINKSBUENDIG gezeichnet
// statt zentriert - zentriertes Scrollen wuerde bei jedem Schritt
// sichtbar hin- und herspringen, da sich die Textbreite laufend
// aendert. Ersetzt die bisherige truncateForWidth()-Kuerzung mit "...".
struct RowMarquee {
String text;
String ring;
bool needsScroll = false;
int32_t charOffset = 0;
uint32_t lastStepMs = 0;
};
RowMarquee rowMarquees[LocationPresets::MAX_PRESETS];
// Wie setupMarquee() oben, aber ohne Textgroessen-Umschaltung (die
// Preset-Zeilen nutzen durchgehend Size 1).
void setupRowMarquee(TFT_eSPI& tft, RowMarquee& m, const String& text, int16_t maxWidth) {
m.text = text;
m.needsScroll = tft.textWidth(text) > maxWidth;
String withGap = text + " ";
m.ring = withGap + withGap;
m.charOffset = 0;
m.lastStepMs = millis();
}
// Zeichnet eine Preset-Zeile im selben Rahmen-/Fuellstil wie
// drawButton(), aber mit linksbuendiger Laufschrift statt zentriertem,
// ggf. abgeschnittenem Text. Wird sowohl beim ersten Bildschirmaufbau
// als auch (nur fuer Zeilen mit needsScroll) bei jedem Tick in der
// Warteschleife erneut aufgerufen, um den naechsten Scroll-Schritt zu
// zeichnen.
void drawRowMarquee(TFT_eSPI& tft, const Rect& r, RowMarquee& m, bool active) {
uint16_t bg = active ? TFT_GREEN : TFT_BLACK;
uint16_t fg = active ? TFT_BLACK : TFT_GREEN;
tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, bg);
tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_GREEN);
constexpr int16_t PAD = 8;
int16_t maxWidth = (int16_t)(r.w - 2 * PAD);
tft.setTextColor(fg, bg);
tft.setTextDatum(ML_DATUM);
int16_t textY = (int16_t)(r.y + r.h / 2);
if (!m.needsScroll) {
tft.drawString(m.text, (int16_t)(r.x + PAD), textY);
tft.setTextDatum(TL_DATUM);
return;
}
uint32_t now = millis();
if (now - m.lastStepMs >= MARQUEE_STEP_MS) {
m.lastStepMs = now;
m.charOffset++;
int32_t singleLen = (int32_t)m.text.length() + 3;
if (m.charOffset >= singleLen) m.charOffset = 0;
}
tft.drawString(marqueeWindow(tft, m.ring, m.charOffset, maxWidth), (int16_t)(r.x + PAD), textY);
tft.setTextDatum(TL_DATUM);
}
void runInfoScreen(TFT_eSPI& tft) { void runInfoScreen(TFT_eSPI& tft) {
MenuStars::reset(); MenuStars::reset();
@@ -563,8 +614,17 @@ void run(TFT_eSPI& tft) {
snprintf(coords, sizeof(coords), "%d: %.2f, %.2f", i + 1, lat, lon); snprintf(coords, sizeof(coords), "%d: %.2f, %.2f", i + 1, lat, lon);
label = (active == (int8_t)i ? "> " : "") + presetWord + " " + coords; label = (active == (int8_t)i ? "> " : "") + presetWord + " " + coords;
} }
label = truncateForWidth(tft, label, (int16_t)(rowRect.w - 10)); // Laufschrift statt "..."-Kuerzung fuer lange Preset-Namen
// bzw. Koordinaten, die nicht in die Zeilenbreite passen
// (siehe RowMarquee oben) - kurze Labels bleiben unveraendert
// zentriert wie zuvor.
if (tft.textWidth(label) <= rowRect.w - 10) {
rowMarquees[i].needsScroll = false;
drawButton(tft, rowRect, label, active == (int8_t)i); drawButton(tft, rowRect, label, active == (int8_t)i);
} else {
setupRowMarquee(tft, rowMarquees[i], label, (int16_t)(rowRect.w - 10));
drawRowMarquee(tft, rowRect, rowMarquees[i], active == (int8_t)i);
}
drawButton(tft, removeRect, "X", false, true); drawButton(tft, removeRect, "X", false, true);
} else { } else {
tft.fillRoundRect(rowRect.x, rowRect.y, rowRect.w, rowRect.h, 4, TFT_BLACK); tft.fillRoundRect(rowRect.x, rowRect.y, rowRect.w, rowRect.h, 4, TFT_BLACK);
@@ -628,6 +688,11 @@ void run(TFT_eSPI& tft) {
if (TouchInput::wasTapped(tap)) break; if (TouchInput::wasTapped(tap)) break;
MenuStars::update(tft); MenuStars::update(tft);
drawMarquee(tft, AIRPORT_LINE_X, airportLineY, AIRPORT_LINE_W, 20); drawMarquee(tft, AIRPORT_LINE_X, airportLineY, AIRPORT_LINE_W, 20);
for (uint8_t i = 0; i < count; i++) {
if (rowMarquees[i].needsScroll) {
drawRowMarquee(tft, rowRects[i], rowMarquees[i], active == (int8_t)i);
}
}
delay(20); delay(20);
} }

View File

@@ -485,21 +485,107 @@ namespace {
gfx.setTextDatum(TL_DATUM); gfx.setTextDatum(TL_DATUM);
} }
// Laufschrift-Zustand fuer EINE Detail-Panel-Zeile - gleiches
// Grundprinzip wie InfoMarquee oben, aber das Detail-Panel hat bis zu
// neun solcher Zeilen gleichzeitig (Airline, Modell, Typ, Hoehe,
// Geschw., Steig-/Sinkrate, Distanz/Peilung, Squawk, Sitzplaetze),
// deshalb ein eigener Zustand pro Zeile statt einer einzigen globalen
// Instanz. y/h/maxWidth/fg merken sich die Zeilen-Geometrie, damit
// tickDetailPanelMarquees() (siehe unten) ohne Zusatzparameter weiss,
// wo/wie jede Zeile neu zu zeichnen ist.
struct LineMarquee {
String text;
String ring;
bool needsScroll = false;
int32_t charOffset = 0;
uint32_t lastStepMs = 0;
int16_t y = 0, h = 0, maxWidth = 0;
uint16_t fg = TFT_GREEN;
};
struct PanelState { struct PanelState {
bool valid = false; bool valid = false;
char hex[7] = {0}; char hex[7] = {0};
String callsignText, airlineText, modelText, typeText, String callsignText;
altText, speedText, climbText, distHeadingText, squawkText, seatsText; LineMarquee airline, model, type, alt, speed, climb, distHeading, squawk, seats;
}; };
PanelState lastPanel; PanelState lastPanel;
void updateLine(TFT_eSPI& gfx, int16_t y, int16_t h, int16_t maxWidth, // Zeichnet eine Detail-Panel-Zeile NEU, wenn sich ihr Text geaendert
uint16_t fg, String& cached, const String& newText, bool forceFull) { // hat (oder das Panel komplett neu aufgebaut wird) - merkt sich dabei
if (!forceFull && cached == newText) return; // auch, ob der Text zu breit fuer die verfuegbare Breite ist
// (needsScroll) und baut bei Bedarf den Ring-Puffer fuer die
// Laufschrift auf (gleiches Muster wie infoMarqueeWindow() oben).
// Passt zu breite Zeilen ("Modell: ...", "Distanz: ...") NICHT mehr
// mit "..." ab, sondern laesst sie horizontal durchscrollen - siehe
// tickDetailPanelMarquees() weiter unten fuer den Teil, der das
// tatsaechliche Weiterscrollen zwischen zwei Datenaktualisierungen
// uebernimmt (render() liefert i.d.R. nur alle ~300ms neue Werte,
// das waere fuer eine fluessige Laufschrift viel zu selten).
void updateMarqueeLine(TFT_eSPI& gfx, int16_t y, int16_t h, int16_t maxWidth,
uint16_t fg, LineMarquee& m, const String& newText, bool forceFull) {
// Geometrie/Farbe IMMER aktualisieren (auch ohne Textaenderung) -
// tickDetailPanelMarquees() braucht diese Werte, um beim naechsten
// Scroll-Schritt an der richtigen Stelle neu zu zeichnen.
m.y = y;
m.h = h;
m.maxWidth = maxWidth;
m.fg = fg;
if (!forceFull && m.text == newText) return;
m.text = newText;
m.needsScroll = gfx.textWidth(newText) > maxWidth;
String withGap = newText + " "; // 3 Leerzeichen Luecke vor der Wiederholung
m.ring = withGap + withGap;
m.charOffset = 0;
m.lastStepMs = millis();
gfx.fillRect(0, y - 14, Config::SCREEN_WIDTH, h, TFT_BLACK); gfx.fillRect(0, y - 14, Config::SCREEN_WIDTH, h, TFT_BLACK);
gfx.setTextColor(fg, TFT_BLACK); gfx.setTextColor(fg, TFT_BLACK);
printLineTruncated(gfx, 8, y, maxWidth, newText); gfx.setCursor(8, y);
cached = newText; gfx.print(m.needsScroll ? infoMarqueeWindow(gfx, m.ring, 0, maxWidth) : newText);
}
// Laesst eine einzelne Detail-Panel-Zeile weiterscrollen, falls sie zu
// breit ist (needsScroll) und genug Zeit seit dem letzten Schritt
// vergangen ist - sonst passiert nichts (kein unnoetiges Neuzeichnen
// fuer Zeilen, die ohnehin komplett passen). Wird von
// tickDetailPanelMarquees() fuer alle neun Zeilen aufgerufen.
void advanceAndDrawMarqueeLine(TFT_eSPI& gfx, LineMarquee& m) {
if (!m.needsScroll) return;
uint32_t now = millis();
if (now - m.lastStepMs < INFO_MARQUEE_STEP_MS) return;
m.lastStepMs = now;
m.charOffset++;
int32_t singleLen = (int32_t)m.text.length() + 3;
if (m.charOffset >= singleLen) m.charOffset = 0;
gfx.fillRect(0, m.y - 14, Config::SCREEN_WIDTH, m.h, TFT_BLACK);
gfx.setTextColor(m.fg, TFT_BLACK);
gfx.setCursor(8, m.y);
gfx.print(infoMarqueeWindow(gfx, m.ring, m.charOffset, m.maxWidth));
}
// Laesst alle Detail-Panel-Zeilen weiterscrollen - waehrend das Panel
// offen ist, ruft tick() (alle ~80ms) das hier auf, DAZWISCHEN also
// viel oefter als render() (das nur bei neuen Flugzeug-Daten,
// ~alle 300ms, feuert) neue Werte liefert. Ohne diesen zusaetzlichen
// Aufruf wuerde eine lange Zeile bei jedem render()-Aufruf zwar einen
// Schritt weiterspringen, aber nicht fluessig durchlaufen.
void tickDetailPanelMarquees(TFT_eSPI& gfx) {
if (!lastPanel.valid) return;
advanceAndDrawMarqueeLine(gfx, lastPanel.airline);
advanceAndDrawMarqueeLine(gfx, lastPanel.model);
advanceAndDrawMarqueeLine(gfx, lastPanel.type);
advanceAndDrawMarqueeLine(gfx, lastPanel.alt);
advanceAndDrawMarqueeLine(gfx, lastPanel.speed);
advanceAndDrawMarqueeLine(gfx, lastPanel.climb);
advanceAndDrawMarqueeLine(gfx, lastPanel.distHeading);
advanceAndDrawMarqueeLine(gfx, lastPanel.squawk);
advanceAndDrawMarqueeLine(gfx, lastPanel.seats);
} }
void drawDetailPanel(TFT_eSPI& gfx, Aircraft& a) { void drawDetailPanel(TFT_eSPI& gfx, Aircraft& a) {
@@ -533,29 +619,29 @@ namespace {
} }
y += 30; y += 30;
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.airlineText, updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.airline,
String(a.airlineName), forceFull); String(a.airlineName), forceFull);
y += LINE_H; y += LINE_H;
String modelLine = details.loading String modelLine = details.loading
? String(I18n::t(StringId::DETAIL_MODEL)) + I18n::t(StringId::DETAIL_LOADING_DOTS) ? String(I18n::t(StringId::DETAIL_MODEL)) + I18n::t(StringId::DETAIL_LOADING_DOTS)
: String(I18n::t(StringId::DETAIL_MODEL)) + (details.model[0] ? details.model : I18n::t(StringId::DETAIL_UNKNOWN)); : String(I18n::t(StringId::DETAIL_MODEL)) + (details.model[0] ? details.model : I18n::t(StringId::DETAIL_UNKNOWN));
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.modelText, modelLine, forceFull); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.model, modelLine, forceFull);
y += LINE_H; y += LINE_H;
String typeLine = String(I18n::t(StringId::DETAIL_TYPE)) + (a.typeCode[0] ? a.typeCode : I18n::t(StringId::DETAIL_UNKNOWN)); String typeLine = String(I18n::t(StringId::DETAIL_TYPE)) + (a.typeCode[0] ? a.typeCode : I18n::t(StringId::DETAIL_UNKNOWN));
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.typeText, typeLine, forceFull); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.type, typeLine, forceFull);
y += LINE_H; y += LINE_H;
char buf[48]; char buf[48];
snprintf(buf, sizeof(buf), "%s%.0fm / %.0fft", I18n::t(StringId::DETAIL_ALT), snprintf(buf, sizeof(buf), "%s%.0fm / %.0fft", I18n::t(StringId::DETAIL_ALT),
Units::feetToMeters((float)a.altBaroFt), (float)a.altBaroFt); Units::feetToMeters((float)a.altBaroFt), (float)a.altBaroFt);
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.altText, String(buf), forceFull); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.alt, String(buf), forceFull);
y += LINE_H; y += LINE_H;
snprintf(buf, sizeof(buf), "%s%.0fkm/h / %.0fkt", I18n::t(StringId::DETAIL_SPEED), snprintf(buf, sizeof(buf), "%s%.0fkm/h / %.0fkt", I18n::t(StringId::DETAIL_SPEED),
Units::ktToKmh(a.groundSpeedKt), a.groundSpeedKt); Units::ktToKmh(a.groundSpeedKt), a.groundSpeedKt);
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.speedText, String(buf), forceFull); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.speed, String(buf), forceFull);
y += LINE_H; y += LINE_H;
String climbLine; String climbLine;
@@ -568,7 +654,7 @@ namespace {
} else { } else {
climbLine = I18n::t(StringId::DETAIL_LEVEL); climbLine = I18n::t(StringId::DETAIL_LEVEL);
} }
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.climbText, climbLine, forceFull); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.climb, climbLine, forceFull);
y += LINE_H; y += LINE_H;
// Zusaetzlich zu km/nm auch Meilen (mi) - nm allein war fuer // Zusaetzlich zu km/nm auch Meilen (mi) - nm allein war fuer
@@ -579,17 +665,17 @@ namespace {
snprintf(buf, sizeof(buf), "%s%.0fkm / %.0fnm / %.0fmi %s%.0f", I18n::t(StringId::DETAIL_DIST), snprintf(buf, sizeof(buf), "%s%.0fkm / %.0fnm / %.0fmi %s%.0f", I18n::t(StringId::DETAIL_DIST),
a.distanceKm, Units::kmToNm(a.distanceKm), Units::kmToMi(a.distanceKm), a.distanceKm, Units::kmToNm(a.distanceKm), Units::kmToMi(a.distanceKm),
I18n::t(StringId::DETAIL_HDG), a.headingDeg); I18n::t(StringId::DETAIL_HDG), a.headingDeg);
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.distHeadingText, String(buf), forceFull); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.distHeading, String(buf), forceFull);
y += LINE_H; y += LINE_H;
String squawkLine = String(I18n::t(StringId::DETAIL_SQUAWK)) + (a.squawk[0] ? a.squawk : I18n::t(StringId::DETAIL_UNKNOWN)); String squawkLine = String(I18n::t(StringId::DETAIL_SQUAWK)) + (a.squawk[0] ? a.squawk : I18n::t(StringId::DETAIL_UNKNOWN));
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.squawkText, squawkLine, forceFull); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.squawk, squawkLine, forceFull);
y += LINE_H; y += LINE_H;
String seatsLine = a.estSeats > 0 String seatsLine = a.estSeats > 0
? String(I18n::t(StringId::DETAIL_SEATS_EST)) + a.estSeats ? String(I18n::t(StringId::DETAIL_SEATS_EST)) + a.estSeats
: String(I18n::t(StringId::DETAIL_SEATS_UNKNOWN)); : String(I18n::t(StringId::DETAIL_SEATS_UNKNOWN));
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.seatsText, seatsLine, forceFull); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.seats, seatsLine, forceFull);
y += 22; y += 22;
if (forceFull) { if (forceFull) {
@@ -867,6 +953,12 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
if (selectedHex[0]) { if (selectedHex[0]) {
tft.startWrite(); tft.startWrite();
updateDetailPanelStars(tft); updateDetailPanelStars(tft);
// Laesst zu breite Detail-Panel-Zeilen (z.B. "Modell: ..." oder
// "Distanz: ...") weiterscrollen, statt wie zuvor mit "..." starr
// abgeschnitten zu bleiben - render() liefert neue Werte nur alle
// ~300ms, das reicht fuer eine fluessige Laufschrift nicht aus,
// deshalb hier zusaetzlich im 80ms-Tick weiterschieben.
tickDetailPanelMarquees(tft);
tft.endWrite(); tft.endWrite();
return; return;
} }