diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ce06f..24f8e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ The project follows Semantic Versioning once the first firmware release is tagge ### Added +- **Race mode has its own running-session screen**: the lap in progress in `mm:ss.sss`, + the fastest lap of the session, and the time left in it. Race previously borrowed Track + Day's countdown, which - as that screen's own header says - has nowhere to put a lap + time, so a road test produced a session that started correctly on pit exit, timed laps, + and showed none of them. The times were being computed every tick and formatted by + `ui::present`, and then nothing drew them +- the Race screen is **separate from Track Day's rather than lap fields added to it**. + Track Day withholding live lap times is a compliance rule, not a display preference, and + a screen that has no lap labels at all cannot leak one through a mode check that is + wrong. It consumes the `DeviceViewModel` the presenter already produces, so the + formatting - including `--:--.---` for a lap nothing has measured yet - is the one the + host tests cover rather than a fourth copy of it +- nine fixed cells for the lap in progress, so a lap past ten minutes still reads rather + than losing its leading digit, and each cell is rewritten only when its character + changes: the current lap advances on every fix and the other two almost never, while a + repaint is the most expensive thing the device does (#213) + - a **0-X** mode: the time from a standing start to a target speed, counting up from `0.00`, stopping the instant the target is reached and turning green. Coming back to rest re-arms it; a tap resets it sooner. The target is chosen in `SETUP` -> `0-X TARGET` diff --git a/firmware/components/ui/CMakeLists.txt b/firmware/components/ui/CMakeLists.txt index 52fe4cb..9ea6e59 100644 --- a/firmware/components/ui/CMakeLists.txt +++ b/firmware/components/ui/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register( SRCS "component.cpp" "active_session.cpp" "input.cpp" "shell_navigation.cpp" "device_mode.cpp" "value_picker.cpp" "gesture_input.cpp" "carousel_screen.cpp" "clock.cpp" "clock_screen.cpp" "time_roller.cpp" "time_roller_screen.cpp" "trackday_screen.cpp" "timer_font.cpp" "diagnostics.cpp" "diagnostics_screen.cpp" - "display_policy.cpp" "foundation.cpp" "gate_capture.cpp" "gate_capture_screen.cpp" "gps_only.cpp" "gps_only_screen.cpp" "speed_only.cpp" "speed_only_screen.cpp" "compass.cpp" "compass_screen.cpp" "acceleration.cpp" "acceleration_screen.cpp" "g_meter_screen.cpp" "g_radar_screen.cpp" "imu_meter.cpp" + "display_policy.cpp" "foundation.cpp" "gate_capture.cpp" "gate_capture_screen.cpp" "gps_only.cpp" "gps_only_screen.cpp" "speed_only.cpp" "speed_only_screen.cpp" "compass.cpp" "compass_screen.cpp" "acceleration.cpp" "acceleration_screen.cpp" "g_meter_screen.cpp" "g_radar_screen.cpp" "race_screen.cpp" "imu_meter.cpp" "lvgl_visual_system.cpp" "navigation.cpp" "presenter.cpp" "ready_screen.cpp" "rest_session.cpp" "review_cards.cpp" "session_trigger.cpp" "session_review.cpp" "session_review_screen.cpp" "settings_editor.cpp" "settings_screen.cpp" diff --git a/firmware/components/ui/include/track_timer/ui/race_screen.hpp b/firmware/components/ui/include/track_timer/ui/race_screen.hpp new file mode 100644 index 0000000..f352086 --- /dev/null +++ b/firmware/components/ui/include/track_timer/ui/race_screen.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "track_timer/ui/presenter.hpp" + +#include + +#include +#include + +namespace track_timer::ui { + +// The running-session screen for Race: the lap in progress, the fastest lap of the +// session, and the time left in it. +// +// Race used to borrow TrackdayScreen, which is a countdown and an estimated-laps figure +// and, as its own header says, has nowhere to put a lap time. The times were being +// computed every tick and formatted by ui::present, and then nothing drew them (#221). +// +// Separate from TrackdayScreen rather than lap fields added to it under a flag. Track Day +// withholding live lap times is the compliance rule #132 established, and a screen that +// has no lap labels at all cannot leak one through a mode check that is wrong. +// +// Fed the same DeviceViewModel that ui::present already produces, so the formatting - +// including "--:--.---" for a lap nothing has measured yet - is the one the host tests +// cover rather than a fourth copy of it. +class RaceScreen { + public: + explicit RaceScreen(lv_obj_t* root) noexcept; + + void update(const DeviceViewModel& model) noexcept; + void set_track_name(const char* name) noexcept; + + [[nodiscard]] lv_obj_t* root() const noexcept; + + RaceScreen(const RaceScreen&) = delete; + RaceScreen& operator=(const RaceScreen&) = delete; + + private: + // One object per character, each in a cell of fixed width, as every large numeral on + // this device is drawn: a 1 is narrower than an 8, so a single auto-sizing label would + // let the reading twitch sideways as it climbs. UI_FOUNDATION.md requires it. + // + // m m : s s . s s s - nine cells, so a lap past ten minutes still reads correctly + // rather than losing its leading digit. + static constexpr std::size_t kLapCells = 9; + // m m : s s + static constexpr std::size_t kSessionCells = 5; + + lv_obj_t* root_{nullptr}; + lv_obj_t* track_label_{nullptr}; + lv_obj_t* mode_label_{nullptr}; + lv_obj_t* current_caption_{nullptr}; + lv_obj_t* fastest_caption_{nullptr}; + lv_obj_t* session_caption_{nullptr}; + std::array current_cells_{}; + std::array fastest_cells_{}; + std::array session_cells_{}; + // What each row currently shows, so a cell is only rewritten when its character + // changes. The current lap advances every fix and the other two almost never, and a + // repaint is the most expensive thing this device does (#213). + std::array shown_current_{}; + std::array shown_fastest_{}; + std::array shown_session_{}; +}; + +} // namespace track_timer::ui diff --git a/firmware/components/ui/race_screen.cpp b/firmware/components/ui/race_screen.cpp new file mode 100644 index 0000000..5cd9bff --- /dev/null +++ b/firmware/components/ui/race_screen.cpp @@ -0,0 +1,234 @@ +#include "track_timer/ui/race_screen.hpp" + +#include "track_timer/ui/foundation.hpp" +#include "track_timer/ui/lvgl_visual_system.hpp" +#include "track_timer/ui/timer_font.hpp" + +#include + +namespace track_timer::ui { +namespace { + +constexpr std::int32_t kScreenWidth = 600; +constexpr std::int32_t kSideMargin = 30; +constexpr std::int32_t kUsableWidth = kScreenWidth - 2 * kSideMargin; + +// The numeral font's metrics, as every screen that uses it states them: a digit cell is +// 0.7235 of the size, a colon or point 0.288, and a line 1.218. +constexpr std::int32_t cell_width_for(const std::int32_t font_px) noexcept +{ + return font_px * 7235 / 10000; +} +constexpr std::int32_t punctuation_width_for(const std::int32_t font_px) noexcept +{ + return font_px * 288 / 1000; +} +constexpr std::int32_t cell_height_for(const std::int32_t font_px) noexcept +{ + return font_px * 1218 / 1000; +} + +// mm:ss.sss is seven digits and two pieces of punctuation. At 92 px that is 519 px, which +// fits the 540 px between the margins with room to spare - and it is the largest size that +// does, which matters because this is the number being read at speed. +constexpr std::int32_t kCurrentPx = 92; +// The two supporting figures, sized so the pair sits on one row: a lap at 56 px is 316 px +// and mm:ss is 178 px, which leaves a 46 px gap between them. +constexpr std::int32_t kSupportPx = 56; + +constexpr std::int32_t lap_block_width(const std::int32_t font_px) noexcept +{ + return cell_width_for(font_px) * 7 + punctuation_width_for(font_px) * 2; +} +constexpr std::int32_t session_block_width(const std::int32_t font_px) noexcept +{ + return cell_width_for(font_px) * 4 + punctuation_width_for(font_px); +} + +constexpr std::int32_t kCurrentBlockWidth = lap_block_width(kCurrentPx); +constexpr std::int32_t kFastestBlockWidth = lap_block_width(kSupportPx); +constexpr std::int32_t kSessionBlockWidth = session_block_width(kSupportPx); + +static_assert(kCurrentBlockWidth <= kUsableWidth, + "the lap in progress must fit the panel at this size, margins included"); +static_assert(kFastestBlockWidth + kSessionBlockWidth <= kUsableWidth, + "the fastest lap and the session clock share one row and must both fit"); + +constexpr std::int32_t kScreenHeight = 450; +constexpr std::int32_t kBottomMargin = 30; + +constexpr std::int32_t kTitleTop = 10; +// Where the title row stops mattering: the body font's line plus its top offset. +constexpr std::int32_t kTitleBottom = 44; +// The lap in progress sits centred in the band between the title and the supporting row, +// rather than pushed to the top of it. Derived so that moving the supporting row does not +// silently unbalance this one - the band is whatever is left between the two. +constexpr std::int32_t kCurrentCaptionHeight = 28; +constexpr std::int32_t kCurrentGroupHeight = + kCurrentCaptionHeight + cell_height_for(kCurrentPx); + +// The supporting row is anchored to the bottom of the panel rather than stacked under the +// lap in progress. Derived from the bottom margin so it stays put if either size changes, +// and so the empty space lands between the two groups where it separates them, instead of +// below both where it reads as the layout having been cut short. +constexpr std::int32_t kSupportTop = + kScreenHeight - kBottomMargin - cell_height_for(kSupportPx); +constexpr std::int32_t kSupportCaptionTop = kSupportTop - 30; + +constexpr std::int32_t kCurrentCaptionTop = + kTitleBottom + (kSupportCaptionTop - kTitleBottom - kCurrentGroupHeight) / 2; +constexpr std::int32_t kCurrentTop = kCurrentCaptionTop + kCurrentCaptionHeight; + +static_assert(kCurrentCaptionTop > kTitleBottom, + "the lap in progress must clear the title row"); +static_assert(kSupportCaptionTop > kCurrentTop + cell_height_for(kCurrentPx), + "the supporting row must clear the lap in progress"); +static_assert(kSupportTop + cell_height_for(kSupportPx) <= kScreenHeight - 1, + "the whole layout must fit the panel height"); + +// Which cells hold punctuation rather than a digit. Stated once so the construction loop +// and the update loop cannot disagree about the shape of the row. +constexpr bool lap_cell_is_digit(const std::size_t index) noexcept +{ + return index != 2 && index != 5; +} +constexpr bool session_cell_is_digit(const std::size_t index) noexcept +{ + return index != 2; +} + +lv_obj_t* make_cell(lv_obj_t* const parent, const lv_font_t* const font, + const std::int32_t width, const std::int32_t height, + const std::int32_t x, const std::int32_t y, + const std::uint32_t rgb) noexcept +{ + auto* cell = lv_label_create(parent); + if (font != nullptr) { + lv_obj_set_style_text_font(cell, font, 0); + } + lv_obj_set_style_text_color(cell, lv_color_hex(rgb), 0); + lv_obj_set_size(cell, width, height); + lv_obj_set_pos(cell, x, y); + lv_obj_set_style_text_align(cell, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(cell, ""); + return cell; +} + +// Writes a formatted time into a row of fixed cells, one character per cell, touching only +// the cells whose character has changed. +// +// The source may be shorter than the row - a lap under ten minutes formats as "1:23.456", +// eight characters into nine cells - so it is laid in from the right. The leading cell is +// then left blank rather than padded with a zero that would read as part of the number. +template +void write_row(const std::array& cells, + std::array& shown, const char* const text) noexcept +{ + const auto length = std::strlen(text); + for (std::size_t index = 0; index < Cells; ++index) { + const auto from_right = Cells - index; + const char character = length >= from_right ? text[length - from_right] : '\0'; + if (character == shown[index]) { + continue; + } + shown[index] = character; + if (cells[index] == nullptr) { + continue; + } + const char glyph[2] = {character, '\0'}; + lv_label_set_text(cells[index], character == '\0' ? "" : glyph); + } +} + +} // namespace + +RaceScreen::RaceScreen(lv_obj_t* const root) noexcept : root_(root) +{ + style_screen(root_); + + track_label_ = create_label(root_, Typography::body, color::text_secondary, + LV_TEXT_ALIGN_LEFT); + lv_obj_set_pos(track_label_, kSideMargin, kTitleTop); + lv_label_set_text(track_label_, ""); + + mode_label_ = create_label(root_, Typography::body, color::text_secondary, + LV_TEXT_ALIGN_RIGHT); + lv_obj_set_width(mode_label_, 200); + lv_obj_set_pos(mode_label_, kScreenWidth - 200 - kSideMargin, kTitleTop); + lv_label_set_text(mode_label_, "RACE"); + + current_caption_ = create_label(root_, Typography::caption, color::text_secondary, + LV_TEXT_ALIGN_LEFT); + lv_obj_set_pos(current_caption_, kSideMargin, kCurrentCaptionTop); + lv_label_set_text(current_caption_, "CURRENT LAP"); + + // The lap in progress, centred and in the brightest text on the screen: it is what the + // driver is reading, and the other two are reference. + const auto* const current_font = countdown_font(kCurrentPx); + const auto current_left = (kScreenWidth - kCurrentBlockWidth) / 2; + auto x = current_left; + for (std::size_t index = 0; index < kLapCells; ++index) { + const auto digit = lap_cell_is_digit(index); + const auto width = + digit ? cell_width_for(kCurrentPx) : punctuation_width_for(kCurrentPx); + current_cells_[index] = + make_cell(root_, current_font, width, cell_height_for(kCurrentPx), x, + kCurrentTop, color::text_primary); + x += width; + } + + const auto* const support_font = countdown_font(kSupportPx); + const auto session_left = kScreenWidth - kSideMargin - kSessionBlockWidth; + + fastest_caption_ = create_label(root_, Typography::caption, color::text_secondary, + LV_TEXT_ALIGN_LEFT); + lv_obj_set_pos(fastest_caption_, kSideMargin, kSupportCaptionTop); + lv_label_set_text(fastest_caption_, "FASTEST"); + + session_caption_ = create_label(root_, Typography::caption, color::text_secondary, + LV_TEXT_ALIGN_RIGHT); + lv_obj_set_width(session_caption_, kSessionBlockWidth); + lv_obj_set_pos(session_caption_, session_left, kSupportCaptionTop); + lv_label_set_text(session_caption_, "SESSION LEFT"); + + x = kSideMargin; + for (std::size_t index = 0; index < kLapCells; ++index) { + const auto digit = lap_cell_is_digit(index); + const auto width = + digit ? cell_width_for(kSupportPx) : punctuation_width_for(kSupportPx); + fastest_cells_[index] = + make_cell(root_, support_font, width, cell_height_for(kSupportPx), x, + kSupportTop, color::positive_bright); + x += width; + } + + x = session_left; + for (std::size_t index = 0; index < kSessionCells; ++index) { + const auto digit = session_cell_is_digit(index); + const auto width = + digit ? cell_width_for(kSupportPx) : punctuation_width_for(kSupportPx); + session_cells_[index] = + make_cell(root_, support_font, width, cell_height_for(kSupportPx), x, + kSupportTop, color::text_primary); + x += width; + } +} + +void RaceScreen::update(const DeviceViewModel& model) noexcept +{ + write_row(current_cells_, shown_current_, model.current_lap.data()); + write_row(fastest_cells_, shown_fastest_, model.best_lap.data()); + write_row(session_cells_, shown_session_, model.session_remaining.data()); +} + +void RaceScreen::set_track_name(const char* const name) noexcept +{ + if (track_label_ == nullptr || name == nullptr) { + return; + } + lv_label_set_text(track_label_, name); +} + +lv_obj_t* RaceScreen::root() const noexcept { return root_; } + +} // namespace track_timer::ui diff --git a/firmware/components/ui/timer_font.cpp b/firmware/components/ui/timer_font.cpp index 6de4935..90803d6 100644 --- a/firmware/components/ui/timer_font.cpp +++ b/firmware/components/ui/timer_font.cpp @@ -23,7 +23,7 @@ struct Entry { // than the count. Running out does not fail loudly: countdown_font returns nullptr, the // caller's label keeps the 14 px default, and a screen that should carry a 170 px numeral // renders a tiny one with nothing in the log to say why. -constexpr std::size_t kMaximumSizes = 6; +constexpr std::size_t kMaximumSizes = 8; std::array cache{}; std::size_t cached = 0; diff --git a/firmware/main/screen_router.cpp b/firmware/main/screen_router.cpp index e380d1c..db4f5a2 100644 --- a/firmware/main/screen_router.cpp +++ b/firmware/main/screen_router.cpp @@ -21,6 +21,7 @@ #include "track_timer/ui/carousel_screen.hpp" #include "track_timer/ui/diagnostics_screen.hpp" #include "track_timer/ui/g_radar_screen.hpp" +#include "track_timer/ui/race_screen.hpp" #include "track_timer/gnss/configure.hpp" #include "track_timer/gnss/i2c_transport.hpp" #include "track_timer/gnss/pipeline.hpp" @@ -541,7 +542,7 @@ class ScreenRouter { shell_.synchronize_session(active); if (active != session_was_active_) { session_was_active_ = active; - lv_screen_load(active ? trackday_root_ : home_screen()); + lv_screen_load(active ? running_screen() : home_screen()); } if (!active) { // Armed but not started: the screen stays, and the detail is re-evaluated @@ -575,6 +576,17 @@ class ScreenRouter { active_session_.update(ui_snapshot, static_cast(now_ms), display); trackday_->update(active_session_.view_model().trackday); + + // Race draws its own screen from the same snapshot, through the presenter that + // already formats these three figures - including "--:--.---" for a lap nothing + // has measured yet. Only when it is the visible screen: the figures are cheap to + // compute and the repaint is not (#213). + if (race_ != nullptr && race_root_ != nullptr && + lv_screen_active() == race_root_) { + race_->update(ui::present(ui_snapshot)); + race_->set_track_name(active_track_.name[0] != '\0' ? active_track_.name.data() + : "TIMER ONLY"); + } } // Two cadences out of one tick, because the receiver's deadline is not the display's. @@ -1071,6 +1083,16 @@ class ScreenRouter { ui::AccelerationScreen(acceleration_root_); ui::attach_gesture_input(acceleration_root_, on_input, this); + // Race's running screen. Its own rather than TrackdayScreen's, because Track Day + // must not show live lap times (#132) and a screen with no lap labels cannot leak + // one through a mode check that is wrong. + race_root_ = lv_obj_create(nullptr); + if (race_root_ == nullptr) { + return false; + } + race_ = new (race_storage_) ui::RaceScreen(race_root_); + ui::attach_gesture_input(race_root_, on_input, this); + // Gate capture drives itself from its own buttons rather than from the carousel // gestures, so this root deliberately gets no gesture binding: a swipe here must // not navigate away from a half-captured line. @@ -1633,6 +1655,17 @@ class ScreenRouter { static_cast(context)->handle_input(action); } + // Which screen a running session goes to. Race has its own, because it shows lap + // times and Track Day must not; every other mode keeps the countdown. + [[nodiscard]] lv_obj_t* running_screen() noexcept + { + if (ui::mode_from_settings(settings_) == ui::DeviceMode::race && + race_root_ != nullptr) { + return race_root_; + } + return trackday_root_; + } + // Two modes own the whole panel and are their own home: G-Only the radar, GPS Only // the receiver readout. Everything else starts at the ready dashboard. [[nodiscard]] lv_obj_t* home_screen() noexcept @@ -2588,6 +2621,8 @@ class ScreenRouter { std::uint16_t logged_rate_hz_{0}; std::int64_t logged_gnss_us_{0}; ui::TrackdayScreen* trackday_{nullptr}; + ui::RaceScreen* race_{nullptr}; + lv_obj_t* race_root_{nullptr}; lv_obj_t* trackday_root_{nullptr}; ui::TimeRollerScreen* roller_{nullptr}; ui::SettingsField roller_field_{ui::SettingsField::average_lap}; @@ -2653,6 +2688,7 @@ class ScreenRouter { alignas(ui::GateCaptureScreen) std::byte gate_capture_storage_[sizeof(ui::GateCaptureScreen)]{}; alignas(ui::TrackdayScreen) std::byte trackday_storage_[sizeof(ui::TrackdayScreen)]{}; + alignas(ui::RaceScreen) std::byte race_storage_[sizeof(ui::RaceScreen)]{}; alignas(ui::TimeRollerScreen) std::byte roller_storage_[sizeof(ui::TimeRollerScreen)]{}; }; diff --git a/tests/cpp/test_ui_foundation.cpp b/tests/cpp/test_ui_foundation.cpp index 632d7fe..e017bf8 100644 --- a/tests/cpp/test_ui_foundation.cpp +++ b/tests/cpp/test_ui_foundation.cpp @@ -52,6 +52,32 @@ int main() assert(std::strcmp(model.session_status.data(), "OVERTIME") == 0); assert(model.session_remaining[0] == '+'); + // The three figures Race draws (#221). Asserted here because the screen takes this + // model verbatim rather than formatting anything itself, so these strings are the + // contract between the presenter and what a driver reads at speed. + domain::UiSnapshot race{}; + race.session_active = true; + race.session_remaining_ms = 11 * 60'000 + 5'000; + auto race_model = ui::present(race); + // Nothing has measured a lap yet, so neither figure may read as a time. A zero here + // would be a lap time the device does not have. + assert(std::strcmp(race_model.current_lap.data(), "--:--.---") == 0); + assert(std::strcmp(race_model.best_lap.data(), "--:--.---") == 0); + assert(std::strcmp(race_model.session_remaining.data(), "11:05") == 0); + + race.current_lap_ms = 83'456; + race.best_lap_ms = 81'002; + race_model = ui::present(race); + assert(std::strcmp(race_model.current_lap.data(), "1:23.456") == 0); + assert(std::strcmp(race_model.best_lap.data(), "1:21.002") == 0); + + // Past ten minutes the minutes field grows a digit, which is why Race lays its cells + // out for mm:ss.sss rather than m:ss.sss - a nine-cell row still holds this. + race.current_lap_ms = 12 * 60'000 + 34'567; + race_model = ui::present(race); + assert(std::strcmp(race_model.current_lap.data(), "12:34.567") == 0); + assert(std::strlen(race_model.current_lap.data()) == 9); + static_assert(std::is_trivially_copyable_v); static_assert(std::is_trivially_copyable_v); std::cout << "UI foundation budgets, contrast, cues, and metrics passed\n";