Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion firmware/components/ui/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
66 changes: 66 additions & 0 deletions firmware/components/ui/include/track_timer/ui/race_screen.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#pragma once

#include "track_timer/ui/presenter.hpp"

#include <lvgl.h>

#include <array>
#include <cstdint>

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<lv_obj_t*, kLapCells> current_cells_{};
std::array<lv_obj_t*, kLapCells> fastest_cells_{};
std::array<lv_obj_t*, kSessionCells> 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<char, kLapCells + 1> shown_current_{};
std::array<char, kLapCells + 1> shown_fastest_{};
std::array<char, kSessionCells + 1> shown_session_{};
};

} // namespace track_timer::ui
234 changes: 234 additions & 0 deletions firmware/components/ui/race_screen.cpp
Original file line number Diff line number Diff line change
@@ -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 <cstring>

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 <std::size_t Cells>
void write_row(const std::array<lv_obj_t*, Cells>& cells,
std::array<char, Cells + 1>& 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
2 changes: 1 addition & 1 deletion firmware/components/ui/timer_font.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<Entry, kMaximumSizes> cache{};
std::size_t cached = 0;

Expand Down
Loading