This commit is contained in:
khodowany
2026-09-30 18:23:25 -05:00
parent 896de203b3
commit 2a1aa2c67a
40 changed files with 1197 additions and 610 deletions
+194
View File
@@ -0,0 +1,194 @@
#include <boost/test/unit_test.hpp>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <map>
#include <string>
#include <boost/json/parse.hpp>
#include <boost/json/object.hpp>
#include <raylib.h>
#include <vamp_pico/screen.hpp>
#include <vamp_zero/menu_view.hpp>
#include <vamp_zero/page_control.hpp>
namespace {
auto read_bytes(std::filesystem::path const &path) -> std::string {
auto input = std::ifstream{path, std::ios::binary};
BOOST_REQUIRE(input.good());
return {std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
}
auto color(luma::color value) -> Color {
return {value.r, value.g, value.b, value.a};
}
auto rectangle(luma::rect value) -> Rectangle {
return {value.x, value.y, value.width, value.height};
}
// Uses the same raylib glyph rasterizer and metrics as Luma, without a GPU/window.
class image_context final : public luma::draw_context {
public:
image_context() = default;
~image_context() override {
UnloadImage(image);
for (auto const &[size, font] : fonts_) {
UnloadFontData(font.glyphs, font.glyphCount);
MemFree(font.recs);
}
}
auto fill_rect(luma::rect bounds, luma::color value) -> void override {
ImageDrawRectangleRec(&image, rectangle(bounds), color(value));
}
auto stroke_rect(luma::rect bounds, std::float32_t thickness, luma::color value) -> void override {
ImageDrawRectangleLines(&image, rectangle(bounds), static_cast<std::int32_t>(thickness), color(value));
}
auto stroke_line(luma::point from, luma::point to, std::float32_t thickness, luma::color value) -> void override {
ImageDrawLineEx(&image, {from.x, from.y}, {to.x, to.y}, static_cast<std::int32_t>(thickness), color(value));
}
auto fill_ellipse(luma::rect bounds, luma::color value) -> void override {
BOOST_TEST(bounds.width == bounds.height);
ImageDrawCircle(&image, static_cast<std::int32_t>(bounds.x + bounds.width / 2.0F), static_cast<std::int32_t>(bounds.y + bounds.height / 2.0F), static_cast<std::int32_t>(bounds.width / 2.0F), color(value));
}
auto stroke_ellipse(luma::rect, std::float32_t, luma::color) -> void override {
BOOST_FAIL("unexpected ellipse outline");
}
auto draw_texture(luma::texture const &, luma::rect, luma::rect, luma::color) -> void override {
BOOST_FAIL("menu must not load score textures");
}
auto draw_text(std::string_view text, luma::point origin, luma::text_style const &style, luma::color value) -> void override {
BOOST_TEST(style.font == vamp_style::ui_font_path);
auto measured = measure_text(text, style);
BOOST_REQUIRE(clip_.has_value());
BOOST_TEST(measured.width <= clip_->width);
BOOST_TEST(origin.y >= clip_->y);
BOOST_TEST(origin.y + measured.height <= clip_->y + clip_->height);
auto face = font(style.size);
auto owned = std::string{text};
auto x = origin.x;
for (auto i = std::size_t{}; i < owned.size();) {
auto bytes = std::int32_t{};
auto codepoint = GetCodepointNext(owned.c_str() + i, &bytes);
auto index = GetGlyphIndex(face, codepoint);
auto const &glyph = face.glyphs[index];
if (codepoint != ' ') {
ImageDraw(&image, glyph.image, {0.0F, 0.0F, face.recs[index].width, face.recs[index].height}, {x + static_cast<std::float32_t>(glyph.offsetX), origin.y + static_cast<std::float32_t>(glyph.offsetY), face.recs[index].width, face.recs[index].height}, color(value));
}
x += (glyph.advanceX == 0 ? face.recs[index].width : static_cast<std::float32_t>(glyph.advanceX)) + 1.0F;
i += static_cast<std::size_t>(bytes);
}
}
auto push_clip(luma::rect bounds) -> void override {
BOOST_TEST(bounds.x >= 0.0F);
BOOST_TEST(bounds.y >= 0.0F);
BOOST_TEST(bounds.x + bounds.width <= 1024.0F);
BOOST_TEST(bounds.y + bounds.height <= 600.0F);
clip_ = bounds;
}
auto pop_clip() -> void override {
clip_.reset();
}
auto measure_text(std::string_view text, luma::text_style const &style) const -> luma::size override {
auto size = MeasureTextEx(font(style.size), std::string{text}.c_str(), style.size, 1.0F);
return {size.x, size.y};
}
Image image{GenImageColor(1024, 600, WHITE)};
private:
auto font(std::float32_t size) const -> Font {
auto key = static_cast<std::int32_t>(std::ceil(size));
auto found = fonts_.find(key);
if (found != fonts_.end()) {
return found->second;
}
auto bytes = read_bytes(std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "package/assets/fonts/petaluma/PetalumaScript.otf");
auto face = Font{};
face.baseSize = key;
face.glyphs = LoadFontData(reinterpret_cast<std::uchar_t const *>(bytes.data()), static_cast<std::int32_t>(bytes.size()), key, nullptr, 95, FONT_DEFAULT, &face.glyphCount);
BOOST_REQUIRE(face.glyphs != nullptr);
auto atlas = GenImageFontAtlas(face.glyphs, &face.recs, face.glyphCount, key, 4, 0);
BOOST_REQUIRE(atlas.data != nullptr);
for (auto i = std::int32_t{}; i < face.glyphCount; ++i) {
UnloadImage(face.glyphs[i].image);
face.glyphs[i].image = ImageFromImage(atlas, face.recs[i]);
}
UnloadImage(atlas);
face.texture.id = 1U;
fonts_.emplace(key, face);
return face;
}
mutable std::map<std::int32_t, Font> fonts_{};
std::optional<luma::rect> clip_{};
};
auto preview(vamp_zero::app_state const &state, std::string const &name, vamp_pico::link_status link = vamp_pico::link_status::CONNECTED, std::bool_t busy = false) -> void {
auto directory = std::filesystem::path{VAMP_TEST_UI_DIR};
std::filesystem::create_directories(directory);
auto context = image_context{};
vamp_zero::draw_header(context, {0.0F, 0.0F, 1024.0F, 600.0F}, state);
vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state);
BOOST_REQUIRE(ExportImage(context.image, (directory / ("zero-" + name + ".png")).c_str()));
auto remote = vamp_pico::remote_display_state{};
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
auto title = vamp_pico::title_cache{};
if (remote.title_image[0] != 0) {
title.select(remote.title_image.data());
BOOST_REQUIRE(title.accept(remote.title_image.data(), read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "pico/titles" / (std::string{remote.title_image.data()} + ".mask"))));
}
auto atlas = read_bytes(directory / "static.p4");
auto image = GenImageColor(320, 240, WHITE);
auto row = std::array<std::uint16_t, vamp_pico::display_width>{};
auto dark = std::size_t{};
for (auto y = 0U; y < 240U; ++y) {
vamp_pico::render_scanline(row, y, remote, link, busy, title, atlas);
for (auto x = 0U; x < 320U; ++x) {
auto pixel = row[x];
auto value = Color{static_cast<std::uint8_t>((pixel >> 11U) * 255U / 31U), static_cast<std::uint8_t>(((pixel >> 5U) & 63U) * 255U / 63U), static_cast<std::uint8_t>((pixel & 31U) * 255U / 31U), 255};
ImageDrawPixel(&image, x, y, value);
dark += value.r < 128U && value.g < 128U && value.b < 128U ? 1U : 0U;
}
}
BOOST_TEST(dark > 200U);
BOOST_REQUIRE(ExportImage(image, (directory / ("pico-" + name + ".png")).c_str()));
UnloadImage(image);
}
}
BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays) {
auto state = vamp_zero::app_state{};
preview(state, "main");
preview(state, "offline", vamp_pico::link_status::OFFLINE);
preview(state, "busy", vamp_pico::link_status::CONNECTED, true);
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
state.pico_connected = true;
state.audio.playback_enabled = true;
state.audio.metronome_enabled = true;
preview(state, "settings");
state.selected_settings_index = 3U;
state.audio.tempo_editing = true;
preview(state, "tempo");
state.audio.tempo_editing = false;
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
preview(state, "empty");
auto catalog = std::make_shared<vamp_zero::catalog>();
auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn-leaves-electric.json"));
auto title = std::string{score.at("presentation").at("title_image").as_string()};
catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = 32U, .title_image = title});
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
state.catalog = catalog;
state.selected_song_index = 1U;
preview(state, "songs");
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
state.selected_part_index = 1U;
preview(state, "parts");
catalog->groups[0].title = std::string(200U, 'M');
catalog->groups[0].composer = std::string(200U, 'W');
preview(state, "long-title");
}