#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include struct vamp_zero::ui_runtime { ui_runtime(boost::asio::any_io_executor executor, std::function 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, 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::distance(score->pages.begin(), found))); if (!path) { return std::unexpected(path.error()); } return application.load_texture_uncached(*path); }} {} auto select(std::shared_ptr 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 { 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 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::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_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, "MODE", 4, vamp_style::ink, 260.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT WORKFLOW", 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}, "Performance Mode", std::to_string(state.catalog->performance.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}, "Practice Mode", std::to_string(state.catalog->practice.exercises.size()) + " EXERCISES", 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}, "Playback", state.audio.playback_enabled ? "ON" : "OFF", state.selected_main_menu_index == 2U); draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + row_height * 3.0F, .width = row_width, .height = row_height}, "Metronome", state.audio.metronome_enabled ? "ON" : "OFF", state.selected_main_menu_index == 3U); 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 * 4.0F, .width = row_width, .height = row_height}, "Tempo", tempo, state.selected_main_menu_index == 4U); } 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->performance.groups.size()) + " SONGS " + std::to_string(state.catalog->performance.parts.size()) + " PARTS", 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; auto total_rows = state.catalog->performance.groups.size() + 1U; auto max_rows = std::min(total_rows, 7U); auto first = first_visible_row(state.selected_song_index, total_rows, max_rows); for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_song_index == 0U); continue; } auto const &group = state.catalog->performance.groups[index - 1U]; draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, group.title, std::to_string(group.parts.size()) + " PARTS", index == state.selected_song_index); } if (state.catalog->performance.groups.empty()) { draw_text(context, bounds.x + 38.0F, list_y + row_height + 28.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); auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; auto total_rows = group->parts.size() + 1U; auto max_rows = std::min(total_rows, 7U); auto first = first_visible_row(state.selected_part_index, total_rows, max_rows); for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_part_index == 0U); continue; } auto part_index = group->parts[index - 1U]; if (part_index >= state.catalog->performance.parts.size()) { continue; } auto const &part = state.catalog->performance.parts[part_index]; draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, part.part, std::to_string(part.total_pages) + " PAGES", index == state.selected_part_index); } } auto draw_practice_type_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, "PRACTICE", 4, vamp_style::ink, 300.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, std::to_string(state.catalog->practice.types.size()) + " TYPES " + std::to_string(state.catalog->practice.exercises.size()) + " EXERCISES", 1, vamp_style::muted, 420.0F); auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; auto total_rows = state.catalog->practice.types.size() + 1U; auto max_rows = std::min(total_rows, 7U); auto first = first_visible_row(state.selected_practice_type_index, total_rows, max_rows); for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_type_index == 0U); continue; } auto const &type = state.catalog->practice.types[index - 1U]; auto exercise_count = std::size_t{}; for (auto const &lesson : type.lesson_groups) { exercise_count += lesson.exercises.size(); } draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, type.title, std::to_string(exercise_count) + " EXERCISES", index == state.selected_practice_type_index); } if (state.catalog->practice.types.empty()) { draw_text(context, bounds.x + 38.0F, list_y + row_height + 28.0F, "NO PRACTICE EXERCISES FOUND", 3, vamp_style::retrying, 560.0F); } } auto draw_practice_lesson_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { auto const *type = selected_practice_type(state); draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, type == nullptr ? std::string{"PRACTICE"} : type->title, 4, vamp_style::ink, bounds.width - 72.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT LESSON GROUP", 1, vamp_style::muted, 360.0F); if (type == nullptr) { draw_text(context, bounds.x + 38.0F, bounds.y + 118.0F, "NO LESSON GROUPS FOUND", 3, vamp_style::retrying, 520.0F); return; } auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; auto total_rows = type->lesson_groups.size() + 1U; auto max_rows = std::min(total_rows, 7U); auto first = first_visible_row(state.selected_practice_lesson_index, total_rows, max_rows); for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_lesson_index == 0U); continue; } auto const &lesson = type->lesson_groups[index - 1U]; draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, lesson.title, std::to_string(lesson.exercises.size()) + " EXERCISES", index == state.selected_practice_lesson_index); } } auto draw_practice_exercise_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { auto const *lesson = selected_practice_lesson(state); draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, lesson == nullptr ? std::string{"EXERCISES"} : lesson->title, 4, vamp_style::ink, bounds.width - 72.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT EXERCISE", 1, vamp_style::muted, 320.0F); if (lesson == nullptr) { draw_text(context, bounds.x + 38.0F, bounds.y + 118.0F, "NO EXERCISES FOUND", 3, vamp_style::retrying, 440.0F); return; } auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; auto total_rows = lesson->exercises.size() + 1U; auto max_rows = std::min(total_rows, 7U); auto first = first_visible_row(state.selected_practice_exercise_index, total_rows, max_rows); for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_exercise_index == 0U); continue; } auto exercise_index = lesson->exercises[index - 1U]; if (exercise_index >= state.catalog->practice.exercises.size()) { continue; } auto const &exercise = state.catalog->practice.exercises[exercise_index]; auto secondary = supports_key_selection(exercise) ? std::string{"KEY SELECT"} : "KEY " + effective_practice_key(state, exercise); draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, exercise.title, secondary, index == state.selected_practice_exercise_index); } } auto draw_practice_key_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { auto const *exercise = selected_practice_exercise(state); draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, exercise == nullptr ? std::string{"KEY"} : exercise->title, 4, vamp_style::ink, bounds.width - 72.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT PRACTICE KEY", 1, vamp_style::muted, 360.0F); auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; auto total_rows = (exercise == nullptr ? 0U : exercise->variants.size()) + 1U; auto max_rows = std::min(total_rows, 7U); auto first = first_visible_row(state.selected_practice_key_index, total_rows, max_rows); for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_key_index == 0U); continue; } auto key = exercise == nullptr ? std::string{"C"} : exercise->variants[index - 1U].key_signature; auto source = exercise == nullptr || exercise->authored_key_signature.empty() ? std::string{} : "SOURCE " + exercise->authored_key_signature; draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, key, source, index == state.selected_practice_key_index); } } 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 (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { auto const *part = active_part(state); if (part == nullptr) { draw_performance_song_menu(context, bounds, state); return; } } else { auto const *exercise = active_practice(state); if (exercise == nullptr) { draw_practice_type_menu(context, bounds, state); return; } auto const *variant = selected_practice_variant(state, *exercise); if (variant == nullptr) { draw_practice_key_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 runtime, std::shared_ptr 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(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::PRACTICE_TYPE_MENU) { draw_practice_type_menu(context, content, state.app); } else if (state.app.mode == vamp_zero::app_mode::PRACTICE_LESSON_MENU) { draw_practice_lesson_menu(context, content, state.app); } else if (state.app.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU) { draw_practice_exercise_menu(context, content, state.app); } else if (state.app.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) { draw_practice_key_menu(context, content, state.app); } else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE || state.app.mode == vamp_zero::app_mode::PRACTICE_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 runtime_; std::shared_ptr 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 command_handler) : application_{std::make_shared(std::move(config))}, runtime_{std::make_shared(executor, std::move(command_handler))}, score_loader_{std::make_shared(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(application->request_layout()); } })} {} vamp_zero::zero_ui::~zero_ui() { stop(); } auto vamp_zero::zero_ui::run() -> std::expected { if (auto status = application_->set_root(std::make_unique(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 { { auto lock = std::scoped_lock{runtime_->mutex_}; if (!state.app.catalog) { state.app.catalog = vamp_zero::empty_catalogue(); } 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 { 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), }; }