Files
StarPilot/openpilot/tools/cabana/ui/theme.cc
T
Trey Moen 95ed0213ec cabana: refine signal heatmap grid (#38964)
* cabana: refine signal heatmap grid

* cabana: restore direct signal color blending

* cabana: strengthen heatmap signal saturation

* Revert "cabana: strengthen heatmap signal saturation"

This reverts commit 07ee9664d533f260b5bbfaed1afc4391c795603c.

* cabana: remove signal cell gradients
2026-09-19 16:00:59 -07:00

252 lines
11 KiB
C++

#include "tools/cabana/ui/theme.h"
#include <algorithm>
#include <cmath>
#include <filesystem>
#include "implot.h"
#include "tools/cabana/core/settings.h"
namespace fs = std::filesystem;
namespace {
constexpr Palette DARK_PALETTE = {
.text = rgb(0xffffff), .text_disabled = rgb(0xcccccc), .text_inactive = rgb(0xa4a4a4), .text_selected = rgb(0xffffff),
.window = rgb(0x353535), .surface = rgb(0x2b2c2d),
.frame = rgb(0x2b2c2d), .frame_hovered = rgb(0x363738), .frame_active = rgb(0x434445),
.button = rgb(0x3f4041), .button_hovered = rgb(0x525354), .button_active = rgb(0x303132),
.header = rgb(0x2d74d2), .header_hovered = rgb(0x323944), .header_active = rgb(0x434445),
.accent = rgb(0x90bfff),
.border = rgb(0xaaaaaa), .separator = rgb(0x353535), .scrollbar_grab = rgb(0xaaaaaa),
.slider_track = rgb(0xaaaaaa),
.tab = rgb(0x2c2c2c), .tab_hovered = rgb(0x444647), .tab_selected = rgb(0x393a3b), .table_header = rgb(0x3f4041),
.grid = rgb(0xdddddd, 50.0f / 255.0f), .badge = rgb(0x626262),
.sparkline_saturation = 1.0f, .sparkline_value = 1.0f,
};
constexpr Palette LIGHT_PALETTE = {
.text = rgb(0x000000), .text_disabled = rgb(0x595959), .text_inactive = rgb(0x595959), .text_selected = rgb(0xffffff),
.window = rgb(0xefefef), .surface = rgb(0xffffff),
.frame = rgb(0xffffff), .frame_hovered = rgb(0xf5f9fc), .frame_active = rgb(0xe7f3fb),
.button = rgb(0xefefef), .button_hovered = rgb(0xe7f3fb), .button_active = rgb(0xd4e7f4),
.header = rgb(0x226a99), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0xd4e7f4),
.accent = rgb(0x226a99),
.border = rgb(0x747474), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0x747474),
.slider_track = rgb(0x747474),
.tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .tab_selected = rgb(0xffffff), .table_header = rgb(0xefefef),
.grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0x626262),
.sparkline_saturation = 2.0f, .sparkline_value = 0.7f,
};
const Palette *g_palette = &LIGHT_PALETTE;
ImFont *g_ui_font = nullptr;
ImFont *g_bold_font = nullptr;
ImFont *g_mono_font = nullptr;
ImFont *g_large_font = nullptr;
void addIconFont(float size, ImFont *base) {
ImFontConfig cfg;
cfg.MergeMode = base != nullptr;
cfg.GlyphMinAdvanceX = size;
if (base != nullptr) {
ImFontBaked *baked = base->GetFontBaked(size);
const float center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f;
cfg.GlyphOffset.y = std::round(size * 0.5f - center);
}
static const ImWchar ranges[] = {0xF000, 0xF8FF, 0};
ImGui::GetIO().Fonts->AddFontFromFileTTF(BOOTSTRAP_ICONS_TTF, size, &cfg, ranges);
}
ImFont *addFont(const fs::path &path, float size) {
ImFontConfig cfg;
cfg.OversampleH = 2;
cfg.OversampleV = 2;
ImFont *font = ImGui::GetIO().Fonts->AddFontFromFileTTF(path.c_str(), size, &cfg);
if (font != nullptr) addIconFont(size, font);
return font;
}
double linearChannel(double value) {
value /= 255.0;
return value <= 0.04045 ? value / 12.92 : std::pow((value + 0.055) / 1.055, 2.4);
}
double luminance(const CabanaColor &color) {
return 0.2126 * linearChannel(color.r) + 0.7152 * linearChannel(color.g) + 0.0722 * linearChannel(color.b);
}
// Directed rounding preserves the luminance bound in 8-bit sRGB.
CabanaColor boundLuminance(const CabanaColor &color, double bound, bool minimum) {
const double current = luminance(color);
if (minimum ? current >= bound : current <= bound) return color;
auto channel = [&](uint8_t value) {
double c = linearChannel(value);
c = minimum ? c + (1.0 - c) * (bound - current) / (1.0 - current) : c * bound / current;
const double srgb = c <= 0.0031308 ? 12.92 * c : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055;
const double scaled = std::clamp(srgb * 255.0, 0.0, 255.0);
return static_cast<uint8_t>(minimum ? std::ceil(scaled) : std::floor(scaled));
};
return {channel(color.r), channel(color.g), channel(color.b), color.a};
}
CabanaColor composite(const CabanaColor &color, const CabanaColor &base) {
auto channel = [&](int foreground, int background) {
return static_cast<uint8_t>(std::lround((foreground * color.a + background * (255 - color.a)) / 255.0));
};
return {channel(color.r, base.r), channel(color.g, base.g), channel(color.b, base.b)};
}
ImVec4 alpha(ImVec4 c, float a) { return ImVec4(c.x, c.y, c.z, a); }
} // namespace
void loadFonts() {
ImGuiIO &io = ImGui::GetIO();
const fs::path fonts = fs::path(CABANA_FONTS_DIR);
g_ui_font = addFont(fonts / "Inter-Regular.ttf", UI_FONT_SIZE);
g_bold_font = addFont(fonts / "Inter-SemiBold.ttf", UI_FONT_SIZE);
g_mono_font = addFont(fonts / "JetBrainsMono-Medium.ttf", 15.0f);
g_large_font = addFont(fonts / "Inter-Bold.ttf", 50.0f);
if (g_ui_font != nullptr) io.FontDefault = g_ui_font;
if (g_bold_font == nullptr) g_bold_font = g_ui_font;
if (g_mono_font == nullptr) g_mono_font = g_ui_font;
if (g_large_font == nullptr) g_large_font = g_bold_font;
}
void applyTheme(int theme) {
g_palette = theme == DARK_THEME ? &DARK_PALETTE : &LIGHT_PALETTE;
const Palette &p = *g_palette;
const ImVec4 none(0, 0, 0, 0);
ImGuiStyle &style = ImGui::GetStyle();
style = ImGuiStyle();
style.WindowRounding = 6.0f; // dialogs and tooltips; docked panels and os windows ignore it
style.ChildRounding = 6.0f;
style.PopupRounding = 6.0f;
style.FrameRounding = 4.0f;
style.GrabRounding = 4.0f;
style.ScrollbarRounding = 4.0f;
style.TabRounding = 4.0f;
style.WindowBorderSize = 1.0f;
style.FrameBorderSize = 1.0f;
style.TabBorderSize = 1.0f;
style.WindowPadding = ImVec2(spacing::CONTROL, spacing::CONTROL);
style.FramePadding = ImVec2(spacing::CONTROL, spacing::INNER);
style.ItemSpacing = ImVec2(spacing::CONTROL, spacing::CONTROL);
style.ItemInnerSpacing = ImVec2(spacing::INNER, spacing::INNER);
style.CellPadding = ImVec2(6.0f, 4.0f);
style.ScrollbarSize = 14.0f;
style.GrabMinSize = 13.0f;
ImVec4 *c = style.Colors;
c[ImGuiCol_Text] = p.text;
c[ImGuiCol_TextDisabled] = p.text_disabled;
c[ImGuiCol_WindowBg] = c[ImGuiCol_ScrollbarBg] = c[ImGuiCol_DockingEmptyBg] = p.window;
c[ImGuiCol_MenuBarBg] = p.window;
c[ImGuiCol_TitleBg] = c[ImGuiCol_TitleBgActive] = c[ImGuiCol_TitleBgCollapsed] = p.window;
c[ImGuiCol_ChildBg] = c[ImGuiCol_PopupBg] = p.surface;
c[ImGuiCol_Border] = c[ImGuiCol_TableBorderStrong] = p.border;
c[ImGuiCol_Separator] = c[ImGuiCol_TableBorderLight] = p.separator;
c[ImGuiCol_BorderShadow] = c[ImGuiCol_ResizeGrip] = c[ImGuiCol_TableRowBg] = none;
c[ImGuiCol_FrameBg] = p.frame;
c[ImGuiCol_FrameBgHovered] = p.frame_hovered;
c[ImGuiCol_FrameBgActive] = p.frame_active;
c[ImGuiCol_Button] = p.button;
c[ImGuiCol_ButtonHovered] = p.button_hovered;
c[ImGuiCol_SliderGrab] = p.accent;
c[ImGuiCol_ScrollbarGrab] = p.scrollbar_grab;
c[ImGuiCol_ScrollbarGrabHovered] = p.text_disabled;
c[ImGuiCol_ButtonActive] = p.button_active;
c[ImGuiCol_Header] = p.header;
c[ImGuiCol_HeaderHovered] = p.header_hovered;
c[ImGuiCol_HeaderActive] = p.header_active;
c[ImGuiCol_CheckMark] = c[ImGuiCol_NavCursor] = c[ImGuiCol_SliderGrabActive] = c[ImGuiCol_ScrollbarGrabActive] = p.accent;
c[ImGuiCol_SeparatorActive] = c[ImGuiCol_ResizeGripActive] = c[ImGuiCol_DragDropTarget] = p.accent;
c[ImGuiCol_TabSelectedOverline] = c[ImGuiCol_PlotHistogram] = p.accent;
c[ImGuiCol_SeparatorHovered] = c[ImGuiCol_ResizeGripHovered] = alpha(p.accent, 0.6f);
c[ImGuiCol_DockingPreview] = alpha(p.accent, 0.5f);
c[ImGuiCol_TextSelectedBg] = alpha(p.accent, 0.35f);
c[ImGuiCol_Tab] = c[ImGuiCol_TabDimmed] = p.tab;
c[ImGuiCol_TabHovered] = p.tab_hovered;
c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.tab_selected;
c[ImGuiCol_TabDimmedSelectedOverline] = none;
c[ImGuiCol_TableHeaderBg] = p.table_header;
c[ImGuiCol_TableRowBgAlt] = none;
c[ImGuiCol_PlotLines] = p.text;
// ImGuiStyle() seeds every slot from the dark theme: set the rest so the light theme does not keep a white caret.
c[ImGuiCol_InputTextCursor] = c[ImGuiCol_UnsavedMarker] = p.text;
c[ImGuiCol_TextLink] = c[ImGuiCol_PlotLinesHovered] = c[ImGuiCol_PlotHistogramHovered] = c[ImGuiCol_NavWindowingHighlight] = p.accent;
c[ImGuiCol_TreeLines] = p.separator;
c[ImGuiCol_DragDropTargetBg] = alpha(p.accent, 0.2f);
// Disable the modal dim fade to make dialogs appear immediately.
c[ImGuiCol_ModalWindowDimBg] = c[ImGuiCol_NavWindowingDimBg] = none;
// ChartView::drawAxes() pushes the other plot colors it needs; the rest are auto colors derived from the ImGui style.
ImPlot::GetStyle().Colors[ImPlotCol_AxisGrid] = p.grid;
}
const Palette &palette() { return *g_palette; }
CabanaColor contrastColor(CabanaColor color, const CabanaColor &background, double target) {
color.a = 255;
const double value = luminance(color), base = luminance(background);
if ((std::max(value, base) + 0.05) / (std::min(value, base) + 0.05) >= target) return color;
const bool lighter = base < 0.179;
const double bound = lighter ? target * (base + 0.05) - 0.05 : (base + 0.05) / target - 0.05;
return boundLuminance(color, std::clamp(bound, 0.0, 1.0), lighter);
}
CabanaColor byteColor(const CabanaColor &color) {
const bool dark = palette().text.x > 0.5f;
const auto [h, s, v] = color.hsv();
auto fill = CabanaColor::fromHsv(h, std::min(s, 0.30f), 0.86f, color.alphaF());
if (dark) fill = boundLuminance(fill, 0.16, false); // 5:1 with white text
fill = composite(fill, fromImVec4(palette().surface));
return dark ? fill : composite({255, 255, 255, 40}, fill);
}
CabanaColor signalFill(const CabanaColor &color, bool defined) {
const bool dark = palette().text.x > 0.5f;
// Constrain the full-strength color before blending to preserve the activity ramp.
if (defined) return composite(boundLuminance(color, 0.18, !dark), fromImVec4(palette().surface));
auto fill = composite(color, fromImVec4(palette().surface));
fill = composite(dark ? CabanaColor(0, 0, 0, 115) : CabanaColor(255, 255, 255, 40), fill);
return boundLuminance(fill, 0.18, !dark); // 4.5:1 with the theme's text
}
CabanaColor signalHighlight(CabanaColor color) {
color.a = 255;
return boundLuminance(boundLuminance(color, 0.175, true), 0.18, false);
}
CabanaColor signalOutline(CabanaColor color, bool hovered) {
const bool dark = palette().text.x > 0.5f;
if (dark && hovered) color = color.lighter(150);
color.a = 255;
// 3:1 against normal and highlighted fills, including neighboring signals.
return dark ? boundLuminance(color, 0.67, true) : boundLuminance(color, 0.02, false);
}
CabanaColor graphicColor(const CabanaColor &color, const ImVec4 &background) {
return contrastColor(color, fromImVec4(background), 3.0);
}
CabanaColor sparklineColor(const CabanaColor &color) {
const Palette &p = palette();
auto [h, s, v] = color.hsv();
return graphicColor(CabanaColor::fromHsv(h, std::min(1.0f, s * p.sparkline_saturation), v * p.sparkline_value));
}
ImFont *boldFont() { return g_bold_font; }
void pushMonoFont(float size) {
if (!g_mono_font) return;
size > 0.0f ? ImGui::PushFont(g_mono_font, size) : ImGui::PushFont(g_mono_font);
}
void popMonoFont() { if (g_mono_font) ImGui::PopFont(); }
void pushBoldFont() { if (g_bold_font) ImGui::PushFont(g_bold_font); }
void popBoldFont() { if (g_bold_font) ImGui::PopFont(); }
void pushLargeFont() { if (g_large_font) ImGui::PushFont(g_large_font); }
void popLargeFont() { if (g_large_font) ImGui::PopFont(); }