375 lines
16 KiB
C++
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),
|
|
};
|
|
}
|