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:
@@ -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) {
|
||||||
tft.print(line);
|
int16_t screenY = (int16_t)(y - scrollY);
|
||||||
|
if (screenY >= viewTop && screenY <= viewBottom) {
|
||||||
|
tft.setCursor(x, screenY);
|
||||||
|
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();
|
||||||
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;
|
constexpr int16_t textMaxWidth = Config::SCREEN_WIDTH - 20;
|
||||||
if (shown.length() > 150) shown = shown.substring(0, 150) + "...";
|
constexpr int16_t LINE_H = 18;
|
||||||
tft.setTextColor(TFT_WHITE, TFT_BLACK);
|
constexpr int16_t VIEW_TOP = 40;
|
||||||
layoutWrapped(tft, 10, 40, (int16_t)(Config::SCREEN_WIDTH - 20), 18, shown);
|
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};
|
// Volle Adresse OHNE Kuerzung anzeigen (frueher wurde bei > 150
|
||||||
Rect useBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50), (int16_t)(Config::SCREEN_WIDTH - 20), 40};
|
// Zeichen mit "..." abgeschnitten) - stattdessen wird der Absatz bei
|
||||||
drawButton(tft, tryAgainBtn, I18n::t(StringId::ADDRESS_SEARCH_TRY_AGAIN));
|
// Bedarf vertikal scrollbar, exakt dasselbe Muster wie beim "Wie
|
||||||
drawButton(tft, useBtn, I18n::t(StringId::ADDRESS_SEARCH_USE_THIS));
|
// 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) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
drawButton(tft, rowRect, label, active == (int8_t)i);
|
// 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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user