From 467d30d8cdf6c16be46532f1c48b144f285e8866 Mon Sep 17 00:00:00 2001 From: Eiswolf-BG <37225519+Eiswolf-BG@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:19:47 +0200 Subject: [PATCH] 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 --- src/address_search_screen.cpp | 95 ++++++++++++++++++----- src/first_run_complete_screen.cpp | 11 +++ src/i18n.h | 6 ++ src/i18n_de.h | 1 + src/i18n_en.h | 1 + src/i18n_es.h | 1 + src/i18n_fr.h | 1 + src/i18n_it.h | 1 + src/i18n_tr.h | 1 + src/location_presets_screen.cpp | 87 ++++++++++++++++++--- src/radar_screen.cpp | 124 ++++++++++++++++++++++++++---- 11 files changed, 284 insertions(+), 45 deletions(-) diff --git a/src/address_search_screen.cpp b/src/address_search_screen.cpp index 3e01808..2c08e32 100644 --- a/src/address_search_screen.cpp +++ b/src/address_search_screen.cpp @@ -38,8 +38,16 @@ namespace { // Screen haelt seine eigenen kleinen Helfer statt eines gemeinsamen // Moduls). Ohne Scroll-Unterstuetzung - hier immer nur kurze, // 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 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; int32_t start = 0; int32_t len = text.length(); @@ -52,8 +60,13 @@ namespace { if (lastSpace <= 0) break; line = line.substring(0, lastSpace); } - tft.setCursor(x, y); - tft.print(line); + if (draw) { + int16_t screenY = (int16_t)(y - scrollY); + if (screenY >= viewTop && screenY <= viewBottom) { + tft.setCursor(x, screenY); + tft.print(line); + } + } y += lineHeight; start += line.length(); } @@ -499,26 +512,72 @@ namespace { // Adress-Tastatur). int runConfirmScreen(TFT_eSPI& tft, const String& displayName) { MenuStars::reset(); - tft.fillScreen(TFT_BLACK); - tft.setTextColor(TFT_GREEN, TFT_BLACK); - tft.setCursor(10, 14); - 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); - layoutWrapped(tft, 10, 40, (int16_t)(Config::SCREEN_WIDTH - 20), 18, shown); + 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); - 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, useBtn, I18n::t(StringId::ADDRESS_SEARCH_USE_THIS)); + // 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.setTextColor(TFT_GREEN, TFT_BLACK); + tft.setCursor(10, 14); + tft.println(I18n::t(StringId::ADDRESS_SEARCH_CONFIRM_TITLE)); + + tft.setTextColor(TFT_WHITE, TFT_BLACK); + layoutWrapped(tft, 10, VIEW_TOP, textMaxWidth, LINE_H, displayName, scrollY, VIEW_TOP, VIEW_BOTTOM, true); + + drawButton(tft, tryAgainBtn, I18n::t(StringId::ADDRESS_SEARCH_TRY_AGAIN)); + drawButton(tft, useBtn, I18n::t(StringId::ADDRESS_SEARCH_USE_THIS)); + if (scrollable) { + drawButton(tft, upBtn, "^"); + drawButton(tft, downBtn, "v"); + } + }; + + redraw(); while (true) { TouchInput::Point tap; - if (!TouchInput::wasTapped(tap)) { MenuStars::update(tft); delay(20); continue; } - if (tryAgainBtn.contains(tap.x, tap.y)) return 0; - if (useBtn.contains(tap.x, tap.y)) return 1; + if (TouchInput::wasTapped(tap)) { + if (tryAgainBtn.contains(tap.x, tap.y)) return 0; + 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); } } } diff --git a/src/first_run_complete_screen.cpp b/src/first_run_complete_screen.cpp index eceb8df..08117fc 100644 --- a/src/first_run_complete_screen.cpp +++ b/src/first_run_complete_screen.cpp @@ -84,6 +84,17 @@ void run(TFT_eSPI& tft) { Rect startBtn = {10, btnY, (int16_t)(Config::SCREEN_WIDTH - 20), BTN_H}; 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 // "Flightradar" text - continues automatically into the splash // screen/app afterward. Tapping the button during the countdown diff --git a/src/i18n.h b/src/i18n.h index 8e4c706..b394a5a 100644 --- a/src/i18n.h +++ b/src/i18n.h @@ -297,6 +297,12 @@ enum class StringId : uint8_t { MENU_FACTORY_RESET_DELETING, 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 }; diff --git a/src/i18n_de.h b/src/i18n_de.h index 77351d1..96be8c3 100644 --- a/src/i18n_de.h +++ b/src/i18n_de.h @@ -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ösche alle Daten...", "Zurücksetzen fehlgeschlagen.", + "Tippen zum Überspringen", }; static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT, diff --git a/src/i18n_en.h b/src/i18n_en.h index 312ab74..fe07770 100644 --- a/src/i18n_en.h +++ b/src/i18n_en.h @@ -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!", "Deleting all data...", "Reset failed.", + "Tap to skip", }; static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT, diff --git a/src/i18n_es.h b/src/i18n_es.h index 6b9d8a9..4939f14 100644 --- a/src/i18n_es.h +++ b/src/i18n_es.h @@ -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!", "Eliminando todos los datos...", "Error al reiniciar.", + "Toca para saltar", }; static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT, diff --git a/src/i18n_fr.h b/src/i18n_fr.h index 2317717..ef6125b 100644 --- a/src/i18n_fr.h +++ b/src/i18n_fr.h @@ -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 !", "Suppression de toutes les données...", "Échec de la réinitialisation.", + "Toucher pour passer", }; static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT, diff --git a/src/i18n_it.h b/src/i18n_it.h index cc73466..fded9e0 100644 --- a/src/i18n_it.h +++ b/src/i18n_it.h @@ -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!", "Eliminazione di tutti i dati...", "Reimpostazione fallita.", + "Tocca per saltare", }; static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT, diff --git a/src/i18n_tr.h b/src/i18n_tr.h index 26a87f0..aa96055 100644 --- a/src/i18n_tr.h +++ b/src/i18n_tr.h @@ -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!", "Tüm veriler siliniyor...", "Sıfırlama başarısız.", + "Atlamak için dokun", }; static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT, diff --git a/src/location_presets_screen.cpp b/src/location_presets_screen.cpp index 670b7ec..8c6a462 100644 --- a/src/location_presets_screen.cpp +++ b/src/location_presets_screen.cpp @@ -328,15 +328,6 @@ namespace { 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 "..." // abzuschneiden, scrollt er horizontal durch, falls er nicht in die // verfuegbare Breite passt. BEWUSST OHNE tft.setViewport() (das hatte @@ -431,6 +422,66 @@ namespace { 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) { MenuStars::reset(); @@ -563,8 +614,17 @@ void run(TFT_eSPI& tft) { snprintf(coords, sizeof(coords), "%d: %.2f, %.2f", i + 1, lat, lon); label = (active == (int8_t)i ? "> " : "") + presetWord + " " + coords; } - label = truncateForWidth(tft, label, (int16_t)(rowRect.w - 10)); - drawButton(tft, rowRect, label, active == (int8_t)i); + // 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); + } 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); } else { 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; MenuStars::update(tft); 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); } diff --git a/src/radar_screen.cpp b/src/radar_screen.cpp index 6f9f72d..6ddf5f3 100644 --- a/src/radar_screen.cpp +++ b/src/radar_screen.cpp @@ -485,21 +485,107 @@ namespace { 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 { bool valid = false; char hex[7] = {0}; - String callsignText, airlineText, modelText, typeText, - altText, speedText, climbText, distHeadingText, squawkText, seatsText; + String callsignText; + LineMarquee airline, model, type, alt, speed, climb, distHeading, squawk, seats; }; PanelState lastPanel; - void updateLine(TFT_eSPI& gfx, int16_t y, int16_t h, int16_t maxWidth, - uint16_t fg, String& cached, const String& newText, bool forceFull) { - if (!forceFull && cached == newText) return; + // Zeichnet eine Detail-Panel-Zeile NEU, wenn sich ihr Text geaendert + // hat (oder das Panel komplett neu aufgebaut wird) - merkt sich dabei + // 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.setTextColor(fg, TFT_BLACK); - printLineTruncated(gfx, 8, y, maxWidth, newText); - cached = newText; + gfx.setCursor(8, y); + 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) { @@ -533,29 +619,29 @@ namespace { } 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); y += LINE_H; String modelLine = details.loading ? 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)); - 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; 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; char buf[48]; snprintf(buf, sizeof(buf), "%s%.0fm / %.0fft", I18n::t(StringId::DETAIL_ALT), 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; snprintf(buf, sizeof(buf), "%s%.0fkm/h / %.0fkt", I18n::t(StringId::DETAIL_SPEED), 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; String climbLine; @@ -568,7 +654,7 @@ namespace { } else { 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; // 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), a.distanceKm, Units::kmToNm(a.distanceKm), Units::kmToMi(a.distanceKm), 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; 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; String seatsLine = a.estSeats > 0 ? String(I18n::t(StringId::DETAIL_SEATS_EST)) + a.estSeats : 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; if (forceFull) { @@ -867,6 +953,12 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) { if (selectedHex[0]) { tft.startWrite(); 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(); return; }