Files
product-vamp/src/vamp_zero/ui.cpp
T
khodowany 142d843fac
ci / checks (push) Successful in 46m42s
Improve Catalog
2026-09-27 11:03:43 -05:00

375 lines
16 KiB
C++

#include <vamp_zero/ui.hpp>
#include <algorithm>
#include <cstdint>
#include <expected>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <string_view>
#include <utility>
#include <boost/asio/any_io_executor.hpp>
#include <luma.hpp>
#include <vamp_style.hpp>
#include <vamp_zero/color.hpp>
#include <vamp_zero/database.hpp>
#include <vamp_zero/errors.hpp>
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/parse.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/score_loader.hpp>
#include <vamp_zero/touch_input.hpp>
struct vamp_zero::ui_runtime {
ui_runtime(boost::asio::any_io_executor executor, std::function<void(vamp_protocol::page_command)> handler)
: touch_{std::move(executor), std::move(handler)} {}
vamp_zero::touch_input touch_;
std::mutex mutex_{};
vamp_zero::ui_state state_{};
};
namespace {
struct score_draw_cache {
explicit score_draw_cache(luma::application &application)
: textures{[this, &application](std::string const &name) -> std::expected<std::shared_ptr<luma::texture>, modulus::error> {
auto score = document.lock();
if (!score) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "score no longer selected"));
}
auto found = std::find_if(score->pages.begin(), score->pages.end(), [&](auto const &page) { return page.path == name; });
auto path = vamp_zero::validated_page_path(*score, static_cast<std::size_t>(std::distance(score->pages.begin(), found)));
if (!path) {
return std::unexpected(path.error());
}
return application.load_texture_uncached(*path);
}} {}
auto select(std::shared_ptr<vamp_zero::score_document const> const &score) -> void {
if (score && document.lock() == score) {
return;
}
textures.clear();
document = score;
}
auto draw(luma::draw_context &context, luma::rect bounds, std::size_t index) -> std::expected<void, modulus::error> {
auto score = document.lock();
if (!score || index >= score->pages.size()) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "page index outside score"));
}
auto texture = textures.get(score->pages[index].path);
if (!texture) {
return std::unexpected(texture.error());
}
auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F);
auto page = luma::rect{.x = bounds.x + (bounds.width - 976.0F * scale) / 2.0F,
.y = bounds.y + (bounds.height - 484.0F * scale) / 2.0F,
.width = 976.0F * scale,
.height = 484.0F * scale};
context.fill_rect(page, {.r = 255, .g = 255, .b = 255, .a = 255});
context.draw_texture(**texture, {.x = 0, .y = 0, .width = 976, .height = 484}, page, {.r = 255, .g = 255, .b = 255, .a = 255});
return {};
}
vamp_zero::score_page_cache textures;
std::weak_ptr<vamp_zero::score_document const> document{};
};
auto bind_port(vamp_zero::config const &config) -> std::uint16_t {
auto const &bind = config.control.endpoint.bind;
auto colon = bind.rfind(':');
if (colon == std::string::npos) {
return 8080;
}
return vamp_zero::parse_integer<std::uint16_t>(std::string_view{bind}.substr(colon + 1U), "port").value_or(8080);
}
auto status_color(vamp_zero::app_state const &state) -> vamp_style::color {
return state.pico_connected ? vamp_style::connected : vamp_style::retrying;
}
auto text_style(std::int32_t scale) -> luma::text_style {
auto size = 13.0F;
if (scale == 2) {
size = 20.0F;
} else if (scale == 3) {
size = 26.0F;
} else if (scale >= 4) {
size = 34.0F;
}
return {
.font = std::string{vamp_style::ui_font_path},
.size = size,
.line_height = size + 5.0F,
};
}
auto draw_text(luma::draw_context &context, std::float32_t x, std::float32_t y, std::string const &text, std::int32_t scale, vamp_style::color color, std::float32_t max_width) -> void {
if (text.empty() || max_width <= 0.0F) {
return;
}
auto style = text_style(scale);
context.push_clip({.x = x, .y = y, .width = max_width, .height = style.line_height});
context.draw_text(text, {.x = x, .y = y}, style, vamp_zero::to_luma(color));
context.pop_clip();
}
auto draw_header(luma::draw_context &context, luma::rect bounds, vamp_zero::ui_state const &state) -> void {
context.fill_rect(
{.x = bounds.x, .y = bounds.y, .width = bounds.width, .height = 80.0F}, vamp_zero::to_luma(vamp_style::panel));
context.fill_rect({.x = bounds.x, .y = bounds.y + 78.0F, .width = bounds.width, .height = 2.0F},
vamp_zero::to_luma(vamp_style::accent));
draw_text(context, bounds.x + 28.0F, bounds.y + 18.0F, "PRODUCT VAMP", 4, vamp_style::ink, 360.0F);
draw_text(context, bounds.x + 30.0F, bounds.y + 55.0F, "AP " + state.ap_ssid + " " + state.zero_ip + ":" + std::to_string(state.port), 1, vamp_style::muted, 520.0F);
auto indicator = status_color(state.app);
auto x = bounds.x + bounds.width - 148.0F;
context.fill_rect({.x = x, .y = bounds.y + 22.0F, .width = 24.0F, .height = 24.0F}, vamp_zero::to_luma(indicator));
draw_text(context, x + 36.0F, bounds.y + 26.0F, "PICO", 2, vamp_style::ink, 88.0F);
}
auto draw_row(luma::draw_context &context, luma::rect row, std::string const &primary, std::string const &secondary, std::bool_t selected) -> void {
if (selected) {
context.fill_rect(row, vamp_zero::to_luma(vamp_style::selection));
}
context.stroke_line({.x = row.x, .y = row.y + row.height},
{.x = row.x + row.width, .y = row.y + row.height},
1.0F,
vamp_zero::to_luma(vamp_style::rule));
draw_text(context, row.x + 16.0F, row.y + 10.0F, primary, 2, vamp_style::ink, row.width * 0.62F);
if (!secondary.empty()) {
draw_text(context, row.x + row.width - 250.0F, row.y + 17.0F, secondary, 1, selected ? vamp_style::ink : vamp_style::muted, 230.0F);
}
}
auto first_visible_row(std::size_t selected, std::size_t total, std::size_t max_rows) -> std::size_t {
if (total <= max_rows || selected < max_rows) {
return 0U;
}
return selected - max_rows + 1U;
}
auto draw_menu_rows(luma::draw_context &context, luma::rect bounds, std::size_t count, std::size_t selected, auto const &draw_item) -> void {
auto total = count + 1U;
auto visible = std::min<std::size_t>(total, 7U);
auto first = first_visible_row(selected, total, visible);
for (auto row = std::size_t{}; row < visible; ++row) {
auto index = first + row;
auto rectangle = luma::rect{.x = bounds.x + 36.0F,
.y = bounds.y + 112.0F + static_cast<std::float32_t>(row) * 54.0F,
.width = bounds.width - 72.0F,
.height = 54.0F};
if (index == 0U) {
draw_row(context, rectangle, "BACK", "", selected == 0U);
} else {
draw_item(index - 1U, rectangle, index == selected);
}
}
}
auto draw_main_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, "MENU", 4, vamp_style::ink, 260.0F);
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "PRODUCT VAMP", 1, vamp_style::muted, 320.0F);
auto list_y = bounds.y + 112.0F;
auto row_height = std::float32_t{54.0F};
auto row_width = bounds.width - 72.0F;
draw_row(context,
{.x = bounds.x + 36.0F, .y = list_y, .width = row_width, .height = row_height},
"Songs",
std::to_string(state.catalog->groups.size()) + " SONGS",
state.selected_main_menu_index == 0U);
draw_row(context,
{.x = bounds.x + 36.0F, .y = list_y + row_height, .width = row_width, .height = row_height},
"Playback",
state.audio.playback_enabled ? "ON" : "OFF",
state.selected_main_menu_index == 1U);
draw_row(context,
{.x = bounds.x + 36.0F, .y = list_y + row_height * 2.0F, .width = row_width, .height = row_height},
"Metronome",
state.audio.metronome_enabled ? "ON" : "OFF",
state.selected_main_menu_index == 2U);
auto tempo = std::to_string(state.audio.metronome_bpm) + " BPM";
if (state.audio.tempo_editing) {
tempo = "EDIT " + tempo;
}
draw_row(context,
{.x = bounds.x + 36.0F, .y = list_y + row_height * 3.0F, .width = row_width, .height = row_height},
"Tempo",
tempo,
state.selected_main_menu_index == 3U);
}
auto draw_performance_song_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, "PERFORMANCE", 4, vamp_style::ink, 360.0F);
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, std::to_string(state.catalog->groups.size()) + " SONGS " + std::to_string(state.catalog->parts.size()) + " PARTS", 1, vamp_style::muted, 320.0F);
draw_menu_rows(context, bounds, state.catalog->groups.size(), state.selected_song_index, [&](std::size_t index, luma::rect row, std::bool_t selected) {
auto const &group = state.catalog->groups[index];
draw_row(context, row, group.title, std::to_string(group.parts.size()) + " PARTS", selected);
});
if (state.catalog->groups.empty()) {
draw_text(context, bounds.x + 38.0F, bounds.y + 194.0F, "NO SONGS FOUND", 3, vamp_style::retrying, 420.0F);
}
}
auto draw_performance_part_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
auto const *group = selected_group(state);
draw_text(context,
bounds.x + 36.0F,
bounds.y + 28.0F,
group == nullptr ? std::string{"PARTS"} : group->title,
4,
vamp_style::ink,
bounds.width - 72.0F);
if (group == nullptr) {
draw_text(context, bounds.x + 38.0F, bounds.y + 118.0F, "NO PARTS FOUND", 3, vamp_style::retrying, 420.0F);
return;
}
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, group->composer, 1, vamp_style::muted, bounds.width - 72.0F);
draw_menu_rows(context, bounds, group->parts.size(), state.selected_part_index, [&](std::size_t index, luma::rect row, std::bool_t selected) {
auto part_index = group->parts[index];
if (part_index >= state.catalog->parts.size()) {
return;
}
auto const &part = state.catalog->parts[part_index];
draw_row(context, row, part.part, std::to_string(part.total_pages) + " PAGES", selected);
});
}
auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, score_draw_cache &score_cache) -> void {
if (active_part(state) == nullptr) {
draw_performance_song_menu(context, bounds, state);
return;
}
if (loaded.loading) {
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "LOADING SCORE", 4, vamp_style::ink, bounds.width - 72.0F);
return;
}
if (loaded.error || !loaded.document) {
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE ERROR", 4, vamp_style::danger, 360.0F);
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, loaded.error ? loaded.error->describe() : "Score unavailable", 1, vamp_style::muted, bounds.width - 72.0F);
return;
}
auto paper_bounds = luma::rect{
.x = bounds.x + 24.0F,
.y = bounds.y + 18.0F,
.width = bounds.width - 48.0F,
.height = bounds.height - 36.0F,
};
if (!state.audio.playback_error.empty()) {
draw_text(context, bounds.x + 24.0F, bounds.y + 12.0F, state.audio.playback_error, 1, vamp_style::danger, bounds.width - 48.0F);
paper_bounds.y += 48.0F;
paper_bounds.height -= 48.0F;
}
if (auto status = score_cache.draw(context, paper_bounds, state.page_index); !status) {
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE PAGE ERROR", 3, vamp_style::danger, bounds.width - 72.0F);
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, status.error().describe(), 1, vamp_style::muted, bounds.width - 72.0F);
}
}
class vamp_root_control final : public luma::control {
public:
vamp_root_control(std::shared_ptr<vamp_zero::ui_runtime> runtime, std::shared_ptr<vamp_zero::score_loader> loader, luma::application &application)
: runtime_{std::move(runtime)}, loader_{std::move(loader)}, score_cache_{application} {}
auto measure(luma::draw_context &, luma::theme const &, luma::constraints limits) -> luma::size override {
return luma::clamp({.width = 1024.0F, .height = 600.0F}, limits);
}
auto draw(luma::draw_context &context, luma::theme const &) const -> void override {
auto state = vamp_zero::ui_state{};
auto loaded = vamp_zero::score_load_state{};
{
auto lock = std::scoped_lock{runtime_->mutex_};
state = runtime_->state_;
loaded = loader_->snapshot();
}
score_cache_.select(loaded.document);
auto area = bounds();
context.fill_rect(area, vamp_zero::to_luma(vamp_style::background));
draw_header(context, area, state);
auto content = luma::rect{
.x = area.x,
.y = area.y + 80.0F,
.width = area.width,
.height = std::max<std::float32_t>(0.0F, area.height - 80.0F),
};
if (state.app.mode == vamp_zero::app_mode::MAIN_MENU) {
draw_main_menu(context, content, state.app);
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
draw_performance_song_menu(context, content, state.app);
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU) {
draw_performance_part_menu(context, content, state.app);
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
draw_score(context, content, state.app, loaded, score_cache_);
} else {
draw_main_menu(context, content, state.app);
}
}
auto handle_touch(luma::touch_event const &event) -> std::bool_t override {
return runtime_->touch_.handle(event);
}
private:
std::shared_ptr<vamp_zero::ui_runtime> runtime_;
std::shared_ptr<vamp_zero::score_loader> loader_;
mutable score_draw_cache score_cache_;
};
}
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler)
: application_{std::make_shared<luma::application>(std::move(config))},
runtime_{std::make_shared<vamp_zero::ui_runtime>(executor, std::move(command_handler))},
score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor, database = std::move(database), assets = std::move(assets_path)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }, [weak = std::weak_ptr{application_}] {
if (auto application = weak.lock()) {
static_cast<void>(application->request_layout());
} })} {}
vamp_zero::zero_ui::~zero_ui() {
stop();
}
auto vamp_zero::zero_ui::run() -> std::expected<void, modulus::error> {
if (auto status = application_->set_root(std::make_unique<vamp_root_control>(runtime_, score_loader_, *application_)); !status) {
return status;
}
auto status = application_->run();
stop();
return status;
}
auto vamp_zero::zero_ui::stop() -> void {
runtime_->touch_.stop();
score_loader_->stop();
application_->request_stop();
}
auto vamp_zero::zero_ui::publish(vamp_zero::ui_state state) -> std::expected<void, modulus::error> {
{
auto lock = std::scoped_lock{runtime_->mutex_};
if (!state.app.catalog) {
state.app.catalog = vamp_zero::empty_catalog();
}
score_loader_->select(state.app.catalog, vamp_zero::selected_score_reference(state.app));
runtime_->state_ = std::move(state);
}
return application_->request_layout();
}
auto vamp_zero::zero_ui::snapshot() const -> vamp_zero::ui_state {
auto lock = std::scoped_lock{runtime_->mutex_};
return runtime_->state_;
}
auto vamp_zero::zero_ui::selected_score_loader() const -> std::shared_ptr<vamp_zero::score_loader> {
return score_loader_;
}
auto vamp_zero::make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state {
return vamp_zero::ui_state{
.app = std::move(app),
.ap_ssid = config.control.ap_ssid,
.zero_ip = config.control.zero_ip,
.port = bind_port(config),
};
}