From 16186c18d1391056609fc210a3716b5e332d6d54 Mon Sep 17 00:00:00 2001 From: PurpleSentinel <199351535+PurpleSentinel@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:06:48 +0100 Subject: [PATCH] feat(ui): give Race its own screen, with the lap times it was computing and dropping A road test started a Race session on pit exit, ran laps, and showed no lap time at any point. The times were not missing: apply_lap_state fills current, previous and best into the UiSnapshot on every service tick, and ui::present formats all three. Nothing on the device drew that model - ui::present was called only from three host tests. Race borrowed TrackdayScreen, which has no lap labels at all, and whose own header says so: "this screen simply has nowhere to put a lap time". RaceScreen shows the lap in progress in mm:ss.sss, the fastest lap of the session, and the session time remaining. Separate from TrackdayScreen rather than lap fields added to it under a mode flag. Track Day withholding live lap times is the compliance rule #132 established, not a display preference, and a screen with no lap labels cannot leak one through a mode check that is wrong. running_screen() states the choice once, so the session lifecycle has one place that decides where a running session goes. It takes the DeviceViewModel the presenter already produces rather than formatting anything itself, so "--:--.---" for a lap nothing has measured yet is the presenter's behaviour and not a fourth copy of lap-time formatting. Those three strings are now asserted in test_ui_foundation, including that a lap past ten minutes formats to nine characters - which is why the row is nine fixed cells rather than eight, so such a lap reads rather than losing its leading digit. Cells are rewritten only when their character changes. The lap in progress advances on every fix while the other two almost never do, and a repaint is the most expensive thing this device does (#213); the screen is also only updated while it is the visible one. kMaximumSizes goes from 6 to 8. Two new numeral sizes would otherwise have been able to exhaust the font cache, which fails soft - countdown_font returns nullptr and the glyphs simply do not appear - and a silent cap is how the carousel came to be unselectable once before. Layout is derived rather than placed: the supporting row is anchored to the bottom margin and the lap in progress is centred in the band left between it and the title, with static_asserts that the rows cannot collide. Moving one no longer unbalances the other. Closes #221 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 ++ firmware/components/ui/CMakeLists.txt | 2 +- .../ui/include/track_timer/ui/race_screen.hpp | 66 +++++ firmware/components/ui/race_screen.cpp | 234 ++++++++++++++++++ firmware/components/ui/timer_font.cpp | 2 +- firmware/main/screen_router.cpp | 38 ++- tests/cpp/test_ui_foundation.cpp | 26 ++ 7 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 firmware/components/ui/include/track_timer/ui/race_screen.hpp create mode 100644 firmware/components/ui/race_screen.cpp 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";